diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml
index 2b911da88..086a29915 100644
--- a/.github/workflows/test-migrations.yml
+++ b/.github/workflows/test-migrations.yml
@@ -44,7 +44,9 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
- image: postgres:16
+ # Release migration 0013 installs the pgvector extension in the shared
+ # business database; CI must exercise the same extension availability.
+ image: pgvector/pgvector:pg16
env:
POSTGRES_USER: langbot
POSTGRES_PASSWORD: langbot
@@ -75,4 +77,10 @@ jobs:
- name: Run PostgreSQL migration tests
env:
TEST_POSTGRES_URL: postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test
- run: uv run pytest tests/integration/persistence/test_migrations_postgres.py -q --tb=short
\ No newline at end of file
+ run: >-
+ uv run pytest
+ tests/integration/persistence/test_migrations_postgres.py
+ tests/integration/persistence/test_pgvector_postgres.py
+ tests/integration/persistence/test_release_migration_postgres.py
+ tests/integration/persistence/test_plugin_identity_migration.py
+ -q --tb=short
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ded90e7ea..2b5defb82 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -178,6 +178,12 @@ In this repo:
- `pkg/provider/tools/loaders/native.py`, `mcp_stdio.py`, and skill loaders depend on Box availability.
- `pkg/skill/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed.
+Durable Box Workspace storage is shared across placement generations, but
+sandbox sessions and managed processes are generation-scoped. LangBot validates
+the current execution binding before an MCP stdio relay attach and sends the
+Workspace/generation binding in authenticated headers, so a placement cutover
+retires stale processes and closes already-attached relays.
+
In `langbot-plugin-sdk`:
- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`.
diff --git a/Dockerfile b/Dockerfile
index 99fce8f2b..fb88c9151 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -38,7 +38,7 @@ COPY --from=node /app/web/dist ./web/dist
COPY --from=nsjail-build /usr/local/bin/nsjail /usr/local/bin/nsjail
RUN apt-get update \
- && apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
+ && apt-get install -y --no-install-recommends gcc ca-certificates curl git gnupg \
# nsjail runtime libraries (the build toolchain stays in the nsjail-build
# stage; only these shared libs are needed to execute the binary).
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 \
@@ -63,8 +63,8 @@ RUN apt-get update \
&& rm -f /tmp/nodesource_setup.sh \
&& python -m pip install --no-cache-dir uv \
&& uv sync \
- && apt-get purge -y --auto-remove curl gnupg \
+ && apt-get purge -y --auto-remove curl git gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv
-CMD [ "uv", "run", "--no-sync", "main.py" ]
\ No newline at end of file
+CMD [ "uv", "run", "--no-sync", "main.py" ]
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index bdd347021..b86f79d43 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -14,6 +14,13 @@ services:
restart: on-failure
environment:
- TZ=Asia/Shanghai
+ # Shared with the langbot service and sent only as a WebSocket handshake
+ # header. Generate with: openssl rand -hex 32
+ - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
+ # Process-wide admission for every asyncio.to_thread() call.
+ - LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
+ - LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING=${LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING:-128}
+ - LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE=${LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE:-4}
command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.cli.__init__", "rt"]
networks:
- langbot_network
@@ -40,9 +47,19 @@ services:
restart: on-failure
environment:
- TZ=Asia/Shanghai
+ # Shared control-plane secret used to authenticate both the RPC socket
+ # and managed-process relay. Generate once (for example with
+ # ``openssl rand -hex 32``) and export it before enabling this profile.
+ # An empty value is accepted by Compose so Box can remain optional, but
+ # the Box runtime itself fails closed when the profile is started.
+ - LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
+ # Box has its own process-wide blocking-work budget.
+ - LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
+ - LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING=${LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING:-128}
+ - LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE=${LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE:-4}
# The Box runtime does NOT read box.local.* from config.yaml or env; it
- # receives its configuration from LangBot via the INIT RPC action.
- # Do not add LANGBOT_BOX_* / BOX__* here — they would be silently ignored.
+ # receives its functional configuration from LangBot via the INIT RPC
+ # action. Do not add BOX__* here because those would be ignored.
# Launched through the same CLI entry point as the plugin runtime
# (`langbot_plugin.cli.__init__ `). WebSocket is the default
# control transport — mirrors `rt`, which also runs with no flag. Pass
@@ -60,6 +77,17 @@ services:
restart: on-failure
environment:
- TZ=Asia/Shanghai
+ # Must match langbot_plugin_runtime. Empty/missing values make the
+ # external control channel fail closed.
+ - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
+ # Must match the value supplied to langbot_box. The token is sent only
+ # in WebSocket handshake headers, never in URLs or action payloads.
+ - LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
+ # Core process-wide blocking-work admission. These are native config
+ # overrides and are persisted with the effective data/config.yaml.
+ - SYSTEM__BLOCKING_EXECUTOR__MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
+ - SYSTEM__BLOCKING_EXECUTOR__MAX_PENDING=${LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING:-128}
+ - SYSTEM__BLOCKING_EXECUTOR__MAX_INFLIGHT_PER_SCOPE=${LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE:-4}
# Unified env-override convention: SECTION__SUBSECTION__KEY overrides the
# matching config.yaml field (see LoadConfigStage). These map onto
# box.* and are forwarded to the Box runtime via INIT RPC.
diff --git a/docker/kubernetes.yaml b/docker/kubernetes.yaml
index d92ef8ce9..5504e5219 100644
--- a/docker/kubernetes.yaml
+++ b/docker/kubernetes.yaml
@@ -4,6 +4,10 @@
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
#
# Usage:
+# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
+# --from-literal=token="$(openssl rand -hex 32)"
+# kubectl -n langbot create secret generic langbot-box-control \
+# --from-literal=token="$(openssl rand -hex 32)"
# kubectl apply -f kubernetes.yaml
#
# Prerequisites:
@@ -87,6 +91,12 @@ metadata:
data:
TZ: "Asia/Shanghai"
PLUGIN__RUNTIME_WS_URL: "ws://langbot-plugin-runtime:5400/control/ws"
+ SYSTEM__BLOCKING_EXECUTOR__MAX_WORKERS: "8"
+ SYSTEM__BLOCKING_EXECUTOR__MAX_PENDING: "128"
+ SYSTEM__BLOCKING_EXECUTOR__MAX_INFLIGHT_PER_SCOPE: "4"
+ LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS: "8"
+ LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING: "128"
+ LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE: "4"
# Box sandbox runtime endpoint. LangBot connects to the Box runtime over
# WebSocket. The hostname MUST match the langbot-box Service name. Note the
# in-container default ("langbot_box") uses an underscore, which is an
@@ -127,6 +137,26 @@ spec:
configMapKeyRef:
name: langbot-config
key: TZ
+ - name: LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: langbot-plugin-runtime-control
+ key: token
+ - name: LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS
+ - name: LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING
+ - name: LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE
volumeMounts:
- name: plugin-data
mountPath: /app/data/plugins
@@ -248,9 +278,28 @@ spec:
configMapKeyRef:
name: langbot-config
key: TZ
+ - name: LANGBOT_BOX_CONTROL_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: langbot-box-control
+ key: token
+ - name: LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS
+ - name: LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: LANGBOT_BLOCKING_EXECUTOR_MAX_PENDING
+ - name: LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: LANGBOT_BLOCKING_EXECUTOR_MAX_INFLIGHT_PER_SCOPE
# The Box runtime does NOT read box.local.* / BOX__* from its own env;
- # it receives its configuration from LangBot via the INIT RPC action.
- # Do not add BOX__* here — they would be silently ignored.
+ # it receives its functional configuration from LangBot via INIT.
volumeMounts:
# Box workspace root — identical path on node, box, and sandbox
# containers (see the IMPORTANT note above).
@@ -276,7 +325,9 @@ spec:
failureThreshold: 3
readinessProbe:
httpGet:
- path: /healthz
+ # Unlike liveness, readiness validates the configured backend and
+ # all strict managed-mode isolation guarantees.
+ path: /readyz
port: 5410
initialDelaySeconds: 10
periodSeconds: 5
@@ -360,6 +411,26 @@ spec:
configMapKeyRef:
name: langbot-config
key: PLUGIN__RUNTIME_WS_URL
+ - name: LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: langbot-plugin-runtime-control
+ key: token
+ - name: SYSTEM__BLOCKING_EXECUTOR__MAX_WORKERS
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: SYSTEM__BLOCKING_EXECUTOR__MAX_WORKERS
+ - name: SYSTEM__BLOCKING_EXECUTOR__MAX_PENDING
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: SYSTEM__BLOCKING_EXECUTOR__MAX_PENDING
+ - name: SYSTEM__BLOCKING_EXECUTOR__MAX_INFLIGHT_PER_SCOPE
+ valueFrom:
+ configMapKeyRef:
+ name: langbot-config
+ key: SYSTEM__BLOCKING_EXECUTOR__MAX_INFLIGHT_PER_SCOPE
# Box (sandbox) runtime endpoint. Connects LangBot to the langbot-box
# Service over WebSocket. Remove this (and the langbot-box Deployment)
# and set BOX__ENABLED=false if you do not want the sandbox.
@@ -368,6 +439,13 @@ spec:
configMapKeyRef:
name: langbot-config
key: BOX__RUNTIME__ENDPOINT
+ # Same Secret as langbot-box. It authenticates the RPC and managed-
+ # process relay handshakes and is never put in a URL or RPC payload.
+ - name: LANGBOT_BOX_CONTROL_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: langbot-box-control
+ key: token
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
# host_root MUST match the box-root hostPath mountPath below AND the box
# Deployment's box-root mountPath, so that skill package paths resolve
@@ -400,7 +478,7 @@ spec:
# Liveness probe to restart container if it becomes unresponsive
livenessProbe:
httpGet:
- path: /
+ path: /healthz
port: 5300
initialDelaySeconds: 60
periodSeconds: 10
@@ -409,7 +487,7 @@ spec:
# Readiness probe to know when container is ready to accept traffic
readinessProbe:
httpGet:
- path: /
+ path: /healthz
port: 5300
initialDelaySeconds: 30
periodSeconds: 5
diff --git a/docs/API_KEY_AUTH.md b/docs/API_KEY_AUTH.md
index ad3bb6693..49d80b6f9 100644
--- a/docs/API_KEY_AUTH.md
+++ b/docs/API_KEY_AUTH.md
@@ -8,13 +8,21 @@ API keys can be managed through the web interface:
1. Log in to the LangBot web interface
2. Click the "API Keys" button at the bottom of the sidebar
-3. Create, view, copy, or delete API keys as needed
+3. Create an API key and copy its secret immediately
+4. Revoke keys that are no longer needed
+
+Database-backed API-key secrets are returned exactly once. LangBot stores only
+a SHA-256 lookup hash, so an existing secret cannot be displayed or recovered
+later. Each key belongs to one Workspace, has explicit permission scopes, and
+may have an expiry. The Workspace is derived from the authenticated key; an
+`X-Workspace-Id` header cannot redirect it to another tenant.
## Global API Key (config.yaml)
In addition to web-UI-created keys (stored in the database, prefixed `lbk_`),
LangBot supports a **global API key** defined directly in `data/config.yaml`.
-This is useful for automated deployments, infrastructure-as-code, and AI agents
+This is a Community-edition bootstrap option for automated deployments,
+infrastructure-as-code, and AI agents
that need API/MCP access **without a login session and without creating a
database record first**.
@@ -27,10 +35,12 @@ api:
Behavior:
-- When `api.global_api_key` is a non-empty string, that exact value is accepted
- anywhere a normal API key is accepted — the `X-API-Key` header or
- `Authorization: Bearer ` — across the HTTP service API **and the MCP
- server**.
+- In Community edition's singleton Workspace, a non-empty
+ `api.global_api_key` is bound to that Workspace and accepted across the HTTP
+ service API and the MCP server.
+- The global config key is rejected when multi-Workspace SaaS mode is enabled;
+ SaaS automation must use a database-backed Workspace key or a closed control
+ plane credential.
- The global key does **not** require the `lbk_` prefix; use any sufficiently
strong secret.
- Leave it empty (`''`, the default) to disable it entirely; only database-backed
@@ -38,9 +48,10 @@ Behavior:
- Existing installs are unaffected until you add the key — config completion only
backfills top-level keys, and the lookup is defensive when the field is absent.
-> **Security:** the global key is stored in plaintext in `config.yaml`. Only
-> enable it on trusted/internal deployments, keep the file permissions tight,
-> always serve over HTTPS, and rotate the value if it may have leaked.
+> **Security:** the global key is stored in plaintext in `config.yaml` and has
+> the singleton Workspace's full fixed permission set. Only enable it on
+> trusted/internal Community deployments, keep file permissions tight, always
+> serve over HTTPS, and rotate it if it may have leaked.
## Using API Keys
@@ -60,7 +71,9 @@ Authorization: Bearer lbk_your_api_key_here
## Available APIs
-All existing LangBot APIs now support **both user token and API key authentication**. This means you can use API keys to access:
+Endpoints that declare API-key authentication accept either a user token or a
+Workspace API key. The key must include the permission required by the route.
+This includes:
- **Model Management** - `/api/v1/provider/models/llm` and `/api/v1/provider/models/embedding`
- **Bot Management** - `/api/v1/platform/bots`
@@ -227,6 +240,11 @@ or
}
```
+### 403 Forbidden
+
+The key is valid for its Workspace but does not include the fixed permission
+required by the route.
+
### 500 Internal Server Error
```json
@@ -240,7 +258,7 @@ or
1. **Keep API keys secure**: Store them securely and never commit them to version control
2. **Use HTTPS**: Always use HTTPS in production to encrypt API key transmission
-3. **Rotate keys regularly**: Create new API keys periodically and delete old ones
+3. **Rotate keys regularly**: Create new API keys periodically and revoke old ones
4. **Use descriptive names**: Give your API keys meaningful names to track their usage
5. **Delete unused keys**: Remove API keys that are no longer needed
6. **Use X-API-Key header**: Prefer using the `X-API-Key` header for clarity
@@ -317,7 +335,6 @@ curl -X POST \
## Notes
-- The same endpoints work for both the web UI (with user tokens) and external services (with API keys)
+- API-key-enabled endpoints use the same resource shapes as the web UI
- No need to learn different API paths - use the existing API documentation with API key authentication
-- All endpoints that previously required user authentication now also accept API keys
-
+- API keys never select a Workspace from a request header; their persisted binding is authoritative
diff --git a/docs/multi-tenant/DATABASE_MIGRATION_GUIDE.md b/docs/multi-tenant/DATABASE_MIGRATION_GUIDE.md
new file mode 100644
index 000000000..e802e6188
--- /dev/null
+++ b/docs/multi-tenant/DATABASE_MIGRATION_GUIDE.md
@@ -0,0 +1,166 @@
+# LangBot 多租户数据库迁移指南
+
+## 概述
+
+LangBot 从单租户 OSS 架构迁移到多租户 SaaS 架构,需要执行 7 个数据库迁移(0009-0015)。
+
+## 迁移序列
+
+```
+0009_workspace_tenancy_kernel → 创建 Workspace、成员、邀请表
+0010_scope_tenant_resources → 所有业务表添加 workspace_uuid
+0011_postgres_tenant_rls → PostgreSQL 行级安全策略
+0012_plugin_installation_identity → 插件实例租户绑定
+0013_tenant_pgvector → RAG 向量存储隔离
+0014_cloud_directory_projection → Cloud 控制平面同步
+0015_cloud_core_collaboration → 协作和权限功能
+```
+
+## 执行步骤
+
+### 1. 备份(必须)
+
+```bash
+# SQLite
+cp ~/.langbot/data/langbot.db ~/.langbot/data/langbot.db.backup-$(date +%Y%m%d)
+
+# PostgreSQL
+pg_dump -U langbot_user -d langbot_db -F c -f langbot_backup_$(date +%Y%m%d).dump
+```
+
+### 2. 执行迁移
+
+```bash
+# 停止服务
+sudo -S -p '' systemctl stop langbot
+
+# 执行迁移
+python -m langbot.pkg.persistence.migration upgrade head
+
+# 验证
+python -m langbot.pkg.persistence.migration current
+# 预期: 0015_cloud_core_collaboration
+
+# 启动服务
+sudo -S -p '' systemctl start langbot
+```
+
+### 3. OSS 单租户自动迁移
+
+迁移会自动:
+- 创建默认 Workspace(名称:"Default Workspace")
+- 第一个用户成为 Owner
+- 所有现有资源绑定到该 Workspace
+
+### 4. 验证检查
+
+```bash
+# 检查 Workspace
+python << EOF
+from langbot.pkg.persistence import manager
+import sqlalchemy as sa
+
+with manager.engine.connect() as conn:
+ ws = conn.execute(sa.text("SELECT uuid, name FROM workspaces LIMIT 1")).first()
+ print(f"Workspace: {ws[1]} ({ws[0]})")
+
+ # 检查资源绑定
+ bot_count = conn.execute(sa.text(
+ f"SELECT COUNT(*) FROM bots WHERE workspace_uuid='{ws[0]}'"
+ )).scalar()
+ print(f"Bots: {bot_count}")
+EOF
+```
+
+## 回滚方案
+
+### 完全回滚(丢失多租户数据)
+
+```bash
+# 1. 停止服务
+sudo -S -p '' systemctl stop langbot
+
+# 2. 恢复备份
+cp ~/.langbot/data/langbot.db.backup-YYYYMMDD ~/.langbot/data/langbot.db
+
+# 3. 回退代码
+git checkout v4.10.x
+pip install -e .
+
+# 4. 启动
+sudo -S -p '' systemctl start langbot
+```
+
+### 降级迁移(保留数据但移除多租户)
+
+```bash
+# 警告:会移除 Workspace 表但保留资源
+python -m langbot.pkg.persistence.migration downgrade 0008_mcp_resource_prefs
+```
+
+## 常见问题
+
+### Q: 迁移后无法登录
+
+```bash
+# 检查用户 UUID
+python << EOF
+from langbot.pkg.persistence import manager
+import sqlalchemy as sa
+
+with manager.engine.connect() as conn:
+ users = conn.execute(sa.text("SELECT id, user, uuid, status FROM users")).all()
+ for u in users:
+ print(f"{u[1]}: UUID={u[2]}, Status={u[3]}")
+EOF
+```
+
+### Q: 资源看不见了
+
+检查 Workspace 上下文:
+```bash
+# 前端请求需要带 X-Workspace-ID header
+curl -H "Authorization: Bearer $TOKEN" \
+ -H "X-Workspace-ID: $WORKSPACE_UUID" \
+ http://localhost:5200/api/v1/platform/bots
+```
+
+### Q: 迁移速度慢
+
+```bash
+# SQLite 优化
+sqlite3 ~/.langbot/data/langbot.db << EOF
+PRAGMA journal_mode=WAL;
+PRAGMA synchronous=NORMAL;
+VACUUM;
+EOF
+```
+
+## 性能调优
+
+### PostgreSQL 索引
+
+```sql
+-- 迁移后创建
+CREATE INDEX CONCURRENTLY idx_model_providers_workspace
+ ON model_providers(workspace_uuid);
+
+CREATE INDEX CONCURRENTLY idx_bots_workspace
+ ON bots(workspace_uuid);
+
+CREATE INDEX CONCURRENTLY idx_pipelines_workspace
+ ON pipelines(workspace_uuid);
+```
+
+## 预估时间
+
+- SQLite < 100MB: 2-5 分钟
+- SQLite 100MB-1GB: 5-15 分钟
+- PostgreSQL: < 5 分钟(取决于数据量)
+
+## 支持
+
+问题反馈:https://github.com/langbot-app/LangBot/issues
+
+**版本**: 1.0
+**最后更新**: 2026-07-30
diff --git a/docs/multi-tenant/cloud-runtime-soak-gate.md b/docs/multi-tenant/cloud-runtime-soak-gate.md
new file mode 100644
index 000000000..35151d1bb
--- /dev/null
+++ b/docs/multi-tenant/cloud-runtime-soak-gate.md
@@ -0,0 +1,114 @@
+# LangBot Cloud 24 小时资源 Soak 门禁
+
+`scripts/cloud_runtime_soak.py` 是生产候选拓扑的最终资源稳定性门禁。它不替代单元测试、历史 churn 探针或 nsjail 隔离测试;它把以下三类证据按同一时间轴采集并给出可机读的 pass/fail:
+
+- Core、Plugin Runtime 和 Box Runtime 的 HTTP liveness/readiness。
+- 三个 Python 进程的 event-loop recent max/p95 调度延迟。
+- Linux `/proc` 进程树的 current RSS、累计 CPU、线程、文件描述符和子进程数。
+- cgroup v2 的 `memory.current/peak/events`、swap、CPU usage/throttling、PID current/events 和实际硬限制。
+
+生产批准必须使用 cgroup 证据。`--pid` 只适合本地诊断,因为进程指标无法证明 OOM kill、PID limit 或 CPU throttling。
+
+## 运行位置
+
+建议把采集器放在独立的 node agent 或监控 sidecar 中,并只读挂载三个目标容器的 cgroup 路径。不要把采集器和样本文件放进被测容器自己的 cgroup/数据卷,否则采集器的 CPU、内存和 page cache 会污染目标数据。
+
+Kubernetes/containerd 生成的 cgroup 路径不是稳定 API。每次生产候选部署都必须从实际 pod/container ID 解析,不能从 pod 名猜路径。传入的每个目录都必须至少可读:
+
+- `memory.current`、`memory.events`
+- `cpu.stat`、`cpu.max`
+- `pids.current`、`pids.events`
+
+最终门禁应加 `--require-hard-limits`。该选项要求每个目标 cgroup 都能观察到有限的 CPU quota、memory、swap 和 PID 上限;任一值为 `max` 都失败。
+
+## 标准 24 小时命令
+
+```bash
+uv run python scripts/cloud_runtime_soak.py \
+ --duration 24h \
+ --startup-grace 5m \
+ --sample-interval 15s \
+ --cooldown 30m \
+ --analysis-window 30m \
+ --http-timeout 5s \
+ --max-memory-growth-mib 64 \
+ --max-memory-slope-mib-per-hour 32 \
+ --max-tail-cpu-cores 0.5 \
+ --max-throttled-period-ratio 0.25 \
+ --max-event-loop-lag-ms 1000 \
+ --max-event-loop-p95-lag-ms 250 \
+ --require-hard-limits \
+ --endpoint core=http://langbot:5300/healthz \
+ --endpoint plugin=http://langbot-plugin-runtime:5400/healthz \
+ --endpoint box=http://langbot-box:5410/readyz \
+ --cgroup core=/host-cgroup/CURRENT_CORE_CONTAINER \
+ --cgroup plugin=/host-cgroup/CURRENT_PLUGIN_RUNTIME_CONTAINER \
+ --cgroup box=/host-cgroup/CURRENT_BOX_RUNTIME_CONTAINER \
+ --samples-file artifacts/cloud-soak-samples.jsonl \
+ --report-file artifacts/cloud-soak-report.json \
+ --workload uv run python tests/load/cloud_candidate_workload.py
+```
+
+`--duration` 是包含启动观察、负载和冷却期的最大墙钟时间。工作负载必须在截止时间前退出并至少留出 30 分钟冷却;否则门禁会终止负载并失败。若负载由外部系统控制,可以省略 `--workload`,但必须保证最后 `--analysis-window` 完全无测试流量,该窗口才可解释为空闲尾段。
+
+工作负载命令的 stdout/stderr 会转发到采集器 stderr,不会混入 stdout 的最终 JSON 报告。命令以独立 process group 启动;超时或中断时整组收到 TERM,10 秒后仍未退出则收到 KILL。
+
+凭据只能通过 workload 进程环境或 secret mount 注入,不能放在命令参数中。最终报告只记录可执行文件名和参数个数,不保存参数正文;采集器也拒绝带 userinfo、query 或 fragment 的健康 URL。
+
+## 必须覆盖的负载
+
+同一候选版本至少要覆盖:
+
+1. 大批 Workspace 注册、成员邀请、登录和 entitlement 刷新。
+2. Plugin installation reconcile、依赖准备、正常调用、进程崩溃与重启。
+3. Dashboard/Embed/平台 WebSocket 建连、突发消息和批量断连。
+4. Box session、文件同步、并发 exec、managed-process 输出和清理。
+5. PostgreSQL pool 接近容量、事务超时和恢复。
+6. Core、Plugin Runtime、Box 分别收到 SIGTERM 后的优雅重启。
+
+工作负载不能把 API 过载拒绝当作成功吞掉。默认情况下,Core health 中 blocking executor 的 global/scope rejection counter 只要增长,门禁即失败;只有专门验证“过载会正确返回 429”的独立测试才可以使用 `--allow-rejections`,该次运行不能作为生产批准证据。
+
+## 判定规则
+
+整个有效观察期内出现以下任一情况即失败:
+
+- 健康接口请求失败、非 2xx、Core `code != 0`,或 Box `ready=false`。
+- `memory.events.high/max/oom/oom_kill/oom_group_kill` 增长。
+- `pids.events.max` 增长。
+- cgroup 单调计数器回退,表示目标很可能发生了未记录的重启或 cgroup 替换。
+- CPU throttled-period ratio 超过配置阈值。
+- 任一健康采样窗口的 event-loop recent max 超过 1 秒,或冷却尾段 recent p95 超过 250 ms。
+- 健康接口缺少 event-loop monitor、monitor 未持续运行,或其 sample counter 回退。
+- blocking executor rejection counter 增长。
+- Plugin Runtime restart circuit 的累计打开次数增长。
+- Core 目录 active Workspace、最近 snapshot/delta Workspace 或 membership 基数
+ 超过各自配置上限,或 PostgreSQL `checked_out` 超过配置 pool 容量;相关 current/max
+ 指标只出现一半或 max 非法也失败。
+
+负载结束后的冷却尾段还必须满足:
+
+- `memory.current`/RSS 的稳健首尾增长和线性斜率不能同时超过阈值。
+- 平均 CPU 核数不超过 `--max-tail-cpu-cores`。
+- event-loop recent p95 不超过 `--max-event-loop-p95-lag-ms`。
+- blocking executor `pending` 至少回到过零;不能整个尾段持续积压。
+- Plugin Runtime restart coordinator 的 active launch、half-open probe 和
+ circuit open remaining time 必须回到零,`gate_waiters` 必须至少归零一次。
+- Core 的 MCP projection retirement queue/worker 和 message aggregation
+ buffer/scope 必须至少归零一次。
+- telemetry、QueryPool、MCP host/dispatch、Box creating/closing/background 等临时 gauge 不能继续增长。
+
+内存判定要求“增长量”和“斜率”同时越界,避免几 MiB allocator/page-cache 噪声在短窗口被外推成很大的每小时斜率。最终报告仍保留实际增长与斜率,人工审查时不能只看 verdict。
+
+## 产物与退出码
+
+- `--samples-file`:逐样本 JSONL,写入后立即 flush,供时序图和故障定位。
+- `--report-file`:最终汇总、阈值、资源硬限制、OOM/PID/throttle delta、尾段斜率和 workload 状态。
+- stdout:与 report 文件相同的最终 JSON;workload 日志只写 stderr。
+
+退出码:
+
+- `0`:全部门禁通过。
+- `1`:采样完成但资源门禁失败。
+- `2`:CLI 参数或目标配置错误。
+
+必须保存原始 JSONL、最终报告、三个镜像 digest、LangBot/SDK commit、生产配置摘要和工作负载版本。滚动更新、节点迁移或镜像变化后,旧报告不能继续作为新候选版本的批准证据。
diff --git a/docs/multi-tenant/cloud-v2-pending-verification.md b/docs/multi-tenant/cloud-v2-pending-verification.md
new file mode 100644
index 000000000..7b90856b5
--- /dev/null
+++ b/docs/multi-tenant/cloud-v2-pending-verification.md
@@ -0,0 +1,273 @@
+# Cloud v2 仍待验证事项
+
+状态:`NOT APPROVED FOR SAAS ACTIVATION`
+
+更新日期:2026-07-29
+
+本文是 Cloud v2 首期上线前的剩余验证清单。它只记录尚不能由当前代码审查、
+单元测试、集成测试、合成容量探针或短时 Linux 容器实验替代的证据。这里的项目
+不属于 2026-07-29 代码与本地测试资源审查的完成条件,也不会让该审查持续保持未完成;
+它们只在准备最终 SaaS 激活时重新进入验收范围。
+
+相关文档:
+
+- [多租户架构决策](./pending-architecture-decisions.md)
+- [实现决策记录](./implementation-decisions.md)
+- [Runtime 资源安全审查](./runtime-resource-audit-2026-07-28.md)
+- [24 小时资源 Soak 门禁](./cloud-runtime-soak-gate.md)
+
+## 1. 当前已形成的交付基线
+
+- LangBot Core 全量 `2855 passed, 33 skipped`,Plugin SDK 全量
+ `1328 passed`,闭源适配器 `40 passed`,Space Go 全量测试通过;三仓格式、
+ 静态检查和 `git diff --check` 已通过。
+- Plugin Runtime 和 Box Runtime 的公开健康接口、event-loop lag 与有界
+ blocking executor 指标已经过真实进程短时验证。
+- 仓库 Dockerfile 构建的 Linux/cgroup v2 短时探针已证明 CPU、memory、
+ swap 和 PID 限制代码路径可工作。
+- PostgreSQL 16 + RLS 的 1,000 Workspace 真实启动测试,以及 5,000
+ Workspace 三代替换合成探针已通过。
+- Core 已精确钉住 Plugin SDK 提交
+ `1d65ed301a6afc52150a998043f73cd6032c8162`。最终验证必须使用包含该提交的
+ Core、Plugin Runtime 和 Box Runtime 镜像,不能混用旧 SDK。
+- 独立资源复核已经移除 Cloud MCP 每会话 5 秒查询执行绑定的轮询,改由签名目录
+ 投影提交后向一个合并回收任务发布代次变化;工具与资源调用前后仍使用数据库
+ execution fence。Plugin restart 冷却等待者、MCP 投影回收、消息聚合 buffer/scope
+ 均已纳入健康快照和 soak 归零门禁。
+- 单实例目录现在有一致的操作容量契约:Space 在注册事务内通过 PostgreSQL
+ advisory lock 串行执行 active Workspace check-and-create;Space 全量快照只返回
+ active Workspace,并在查询阶段限制 Workspace/membership 数量;闭源适配器限制
+ 解压后的 HTTP 响应字节和签名目录基数;Core 在持有目录投影行锁的事务内再次
+ COUNT active Workspace,超限时整批回滚且不推进 cursor。任何一层都不截断权威数据。
+- Core Cloud PostgreSQL pool 的 `pool_size + max_overflow` 有绝对上限 100,
+ Cloud runtime 连接默认强制 60 秒 statement/idle-transaction timeout 和 5 秒
+ lock timeout;pool 使用量、超时累计数与目录 active/max 基数进入 `/healthz`。
+ Box Runtime 的 session、process、admission record、RPC 文件和 completed retention
+ 配置也有不可被实例配置放大的绝对上限。
+- Space 的 concurrent registration 容量准入已在一次性 PostgreSQL 16 上真实执行:
+ 两个 Account 同时争用最后一个 Workspace 槽位时,精确一个事务成功、一个事务
+ 得到 capacity error,最终 active Workspace 数为 1。active-only snapshot 与
+ archived delta tombstone 的同一真实 PostgreSQL 集成流程也通过。
+- Core Cloud manager 已连接一次性 PostgreSQL 16,并从 `pg_settings` 读回
+ `statement_timeout=60000ms`、`lock_timeout=5000ms` 和
+ `idle_in_transaction_session_timeout=60000ms`;测试结束后引擎已显式 dispose。
+- 独立异常路径复核已补齐 HTTPX 超限/取消时的底层流关闭;Monitoring 查询、导出和
+ detail 物化量均有实例上限与绝对上限,detail 统计使用数据库聚合。Token statistics
+ 不再拉取全部历史 LLM call 在 Python 中分桶,而由 PostgreSQL/SQLite 聚合并只返回
+ 有界的最新时间桶和模型分组,截断状态在响应中显式可见。邀请、Monitoring 和 Storage
+ 周期清理已合并为一个先等待首个 interval 的调度器,同一周期只进行一次 Workspace
+ discovery;数据库删除批次和本地/S3 文件候选也有每轮硬上限。
+
+以上结果是进入生产候选验证的前提,不是 SaaS 上线批准。
+
+## 2. 尚有实现前置条件的阻断项
+
+以下项目不是“再跑一次测试”即可关闭。必须先完成实现,再执行对应验收。
+
+| 编号 | 阻断项 | 完成实现后的最低验收证据 |
+| --- | --- | --- |
+| B-01 | Cloud 插件缺少生产 egress policy | 证明插件只能访问允许的公网目标,不能访问 Core/Box/数据库、其他内部服务、loopback、link-local 或云 metadata endpoint |
+| B-02 | Plugin installation 与 Box Workspace/Skill/root/tmp/home 缺少真实的 byte 和 inode 硬配额 provider | 在写入边界原子拒绝超额;并发写入、重启和配额耗尽后不能越界,也不能用目录扫描或事后清理冒充硬配额 |
+| B-03 | 普通业务写入尚未具备贯穿 commit 的 generation-aware fence、同事务 business outbox,以及 generation cutover 后稳定的 durable-object 引用 | 在旧 generation 与新 generation 并发、事务提交竞态和重复投递下,旧 owner 不产生业务写入或外部副作用,outbox 可幂等恢复 |
+
+任一 B 类项目未关闭时,不得把 24 小时 soak 的通过结果解释为可以上线。
+
+## 3. 最终部署环境验证
+
+### V-01:Plugin Runtime 与 Box 的 Linux 隔离
+
+必须在最终 Cloud Pod security context、容器 runtime 和 cgroup 拓扑中验证,
+不能使用开发机或权限不同的一次性容器替代。
+
+验证内容:
+
+1. nsjail 可以建立 mount、PID、IPC、UTS namespace 和 private `/proc`。
+2. delegated cgroup v2 对每个插件进程和 sandbox 强制 CPU、memory、
+ `memory.swap.max=0` 和 PID 上限。
+3. open files、process 和单文件大小 rlimit 生效。
+4. 插件不能枚举、读取或 signal Runtime 及其他 installation 的进程,
+ 不能读取其他 installation 的 home/tmp/data,也不能修改共享只读
+ artifact/environment。
+5. 超额只杀死或拒绝当前 installation/sandbox;Runtime、其他租户和健康接口
+ 继续工作。
+6. 进程退出、取消、超时、generation 切换和 Pod SIGTERM 后,cgroup、nsjail
+ 目录、子进程和文件描述符均被回收。
+7. 硬限制或 namespace 能力缺失时 readiness 失败关闭,不允许降级成普通进程。
+
+通过证据必须包含实际容器安全配置、cgroup 文件值、探针原始输出和失败注入结果。
+
+### V-02:Box 持久卷与硬存储配额
+
+在 B-02 的 quota provider 实现后,必须验证:
+
+1. Core 与 Box Runtime 通过随机 marker challenge 证明使用同一共享持久卷。
+2. Workspace、Skill store、ephemeral root/tmp/home 的 byte 和 inode quota
+ 都在写入点生效。
+3. 并发写、压缩包展开、文件同步、重启恢复和删除重建不能绕过配额。
+4. 配额耗尽只影响目标 Workspace,其他 Workspace 仍能执行。
+5. 任一硬存储能力缺失时 `/readyz` 返回非 2xx,Pod 不进入就绪流量。
+
+### V-03:PostgreSQL 与 pgvector 生产边界
+
+必须在最终 PostgreSQL endpoint、凭据和网络策略下验证:
+
+1. migrator 与 runtime 使用不同 role;runtime role 无 superuser、
+ `BYPASSRLS`、DDL、对象所有权、role membership 或额外 schema 权限。
+2. runtime credential 只能连接目标 business database。需要专用
+ cluster/endpoint,或经过测试的 HBA/proxy policy;database 内 catalog
+ audit 本身不能证明这一点。
+3. release migration Job 的 advisory lock、失败重试、回滚和精确 Alembic head
+ 校验有效;应用启动角色不能执行 migration 或其他 DDL。
+4. 若使用 PgBouncer,transaction pooling、异常回滚和连接复用不会残留
+ tenant context。
+5. 故意遗漏应用层 Workspace filter 时,RLS 仍阻止跨租户读写。
+6. 两个 Workspace 使用相同 `vector_id`、猜测其他 Workspace ID、后台任务和
+ 连接复用时,pgvector CRUD 均不能越权。
+7. dimension mismatch、extension/schema/ACL drift 或 runtime audit 失败时,
+ Core 启动失败且不回退到其他向量后端。
+
+### V-04:最终镜像和配置一致性
+
+生产候选验证前必须固定:
+
+- Core、Plugin Runtime、Box Runtime 的不可变镜像 digest;
+- LangBot 与 SDK commit;
+- `data/config.yaml` 的非敏感摘要和所有环境变量覆写;
+- Space 的 `CLOUD_V2_MAX_DIRECTORY_WORKSPACES` 必须与 Core
+ `cloud.directory.max_active_workspaces` 和
+ `cloud.directory.max_snapshot_workspaces` 一致;Space 的 membership 上限必须与
+ Core `cloud.directory.max_snapshot_memberships` 一致;
+- Core 的 PostgreSQL pool、statement/lock/idle-transaction timeout,以及 Plugin
+ Runtime/Box Runtime 的全部实例级资源上限;
+- PostgreSQL migration revision;
+- Cloud Adapter、Space control plane 和 workload 的版本。
+
+滚动更新、节点迁移、配置变化或任一镜像 digest 变化后,旧验证报告失效。
+
+## 4. 多租户行为与故障注入
+
+### V-05:目录、entitlement 与 generation
+
+在真实 Space control plane、闭源 Cloud Adapter 和 Core 之间验证:
+
+1. 注册自动创建个人 Workspace、owner membership、Free subscription、
+ entitlement snapshot 和 outbox 事件,且重试不重复创建。
+2. 邀请、成员变更、套餐变更和 Workspace 撤销只影响目标 Workspace。
+3. 全量快照与增量事件覆盖乱序、重复、断点续传、缺页、签名错误、
+ high-water gap、snapshot coverage 和消费者重启。
+4. Core、Plugin Runtime 或 Box 重启后,权威 desired state 可恢复;
+ 本地进程表和缓存不是唯一真相。
+5. generation/revision 切换期间,旧 callback、RPC、WebSocket、Box relay、
+ plugin worker 和缓存写入全部失败关闭。
+6. 为未来多副本预留的 replica-local cursor 语义通过故障注入:
+ 一个副本追平不能使另一个副本跳过本地 cache 刷新。
+7. 同时使大量 plugin worker 因系统性故障退出,证明 restart launch 全局并发受限、
+ 失败阈值触发 Runtime circuit、冷却后只有一个 half-open probe,且 probe 未稳定前
+ 其他 installation 不会继续重启;冷却计时器/状态等待者数量不得超过全局 restart
+ 并发,取消 probe 不得把 circuit 永久卡在 half-open;24 小时门禁必须把 circuit
+ 打开或 `gate_waiters` 未归零判为失败。
+8. 使用大量空闲 remote MCP session 做 generation 切换,证明目录投影只创建一个
+ 合并回收任务,不产生每 session 周期数据库查询、计时器或同时唤醒;旧 session
+ 最终关闭,`mcp_projection_retirements` 和
+ `mcp_projection_reconcile_active` 在冷却期归零。
+
+### V-06:套餐、Box 与 stdio MCP
+
+1. Free/非 Pro Workspace 不会自动获得 managed sandbox。
+2. 合资格 Workspace 最多只有一个持久 `global` sandbox,且新增 Workspace
+ 不创建专属 Runtime、Pod、PVC、database、schema、role 或连接池。
+3. Cloud 即使 `box.enabled=true`,也不能 create/update/test/start stdio MCP;
+ 旧记录和直接 API 调用同样失败关闭,且不创建 `mcp-shared` session。
+4. OSS 默认仍是单 Workspace、多用户,stdio MCP 保持兼容,多租户能力不会被
+ 未签名配置或普通环境变量开启。
+
+### V-07:跨租户安全回归
+
+至少使用两个恶意测试 Workspace 验证:
+
+- 同 digest 插件只共享只读代码和依赖;进程、secret、日志及所有可写目录隔离。
+- 同 author/name/version 但 digest 不同的 artifact 不共享目录。
+- Plugin Host API、Box RPC、对象 key、WebSocket、RAG、storage、model/session
+ cache 和平台回调不能接受调用方伪造的 Workspace scope。
+- 撤销 entitlement、删除 installation 或 generation 切换后,已有长连接和
+ in-flight 请求不能继续访问旧权限。
+
+## 5. 生产候选容量与 24 小时门禁
+
+### V-08:真实容量曲线
+
+现有 fake adapter/requester/Plugin handler 探针不能替代真实容量数据。必须使用
+计划上线的平台 SDK、外部 HTTP/WebSocket 连接池、真实插件进程、真实
+PostgreSQL/pgvector 和代表性 Workspace 配置分布,测量:
+
+- 空 Workspace、活跃 Workspace、每个启用插件和每个 Pro sandbox 的边际
+ RSS、线程、文件描述符、连接和 PostgreSQL pool 成本;
+- 启动、目录重放、批量 reconcile 和故障恢复的耗时与峰值;
+- remote MCP 数量增加及目录 generation 批量切换时的数据库 QPS、回收队列和
+ event-loop lag,确认不存在与 session 数量成比例的空闲轮询;
+- 在最大 retention/backlog 和并发 Dashboard 请求下执行不带时间范围的 Monitoring
+ overview/token statistics,验证 SQL 分桶、statement timeout、响应截断和 cleanup
+ 追赶不会形成 PostgreSQL CPU 尖峰或 Core RSS 增长;
+- 单实例可批准的 Workspace、活跃 Bot、plugin worker 和 sandbox 上限。
+
+容量上限必须写入生产配置与告警,不能只保留在测试报告中。
+代码中的默认值和绝对上限只是失控配置的最后防线,不等于生产容量结论。V-08 必须
+根据最终镜像的真实曲线把 Space 与 Core 的匹配上限调到已验证容量以内;如果最终
+批准值高于当前默认 1,000 active Workspace,必须重新执行目录启动、故障恢复和
+24 小时门禁。
+
+### V-09:24 小时资源 soak
+
+使用 [标准 24 小时命令](./cloud-runtime-soak-gate.md#标准-24-小时命令),并强制
+`--require-hard-limits`。工作负载至少覆盖:
+
+1. 注册、邀请、登录和 entitlement 刷新;
+2. plugin reconcile、依赖准备、调用、崩溃与重启;
+3. Dashboard/Embed/平台 WebSocket 建连、突发消息和断连;HTTP Bot 覆盖
+ 高基数 session/idempotency、硬容量拒绝、空闲回收及 callback 堵塞;
+ remote MCP 覆盖大量空闲连接、批量 generation 切换和合并回收;
+4. Box session、文件同步、并发 exec、输出与清理;
+5. PostgreSQL pool 接近容量、事务超时和恢复;
+6. Core、Plugin Runtime、Box 分别 SIGTERM 和恢复。
+
+最后至少保留 30 分钟无测试流量冷却。任一健康失败、OOM/memory pressure、
+PID limit、blocking executor rejection、超阈值 CPU throttling/event-loop lag、
+目录 `active_workspaces > max_active_workspaces`、数据库 pool 使用量超过配置容量、
+冷却尾段内存持续增长,或 Plugin restart `gate_waiters`、MCP 投影回收、
+消息聚合 buffer/scope 等临时 gauge 不回落都判为失败。
+标准 soak 工具已自动比较目录 active/最近批次与各自配置上限,并比较 PostgreSQL
+`checked_out` 与配置 pool 容量;名为 `core` 的标准 endpoint 缺少任一容量指标、
+current/max 只出现一半、数值非法或任一样本越界都会直接失败。
+
+必须归档:
+
+- 原始 `cloud-soak-samples.jsonl`;
+- 最终 `cloud-soak-report.json`;
+- 三个镜像 digest、Core/SDK commit;
+- 生产配置摘要、数据库 migration revision 和 workload 版本;
+- 故障注入时间线及关联日志/trace。
+
+## 6. 本轮不作为验收条件的后续事项
+
+以下能力已明确暂缓,不能混入当前验证结果,也不能以“尚未验证”为理由临时发明方案:
+
+- Workspace export、释放、delete、单 Workspace restore 和在线迁移;
+- Workspace 级 BYOK E2B WebUI 配置;
+- 多 Core/Plugin Runtime/Box replica 的 lease store 与调度实现;
+- PostgreSQL 多 shard、dedicated shard 和跨地域部署;
+- 多 CloudInstance、Cell Router 或 Workspace Placement。
+
+这些事项需要后续单独决策。首期实现仍需保留稳定 UUID、generation fence、
+幂等事件和无副本地址泄漏的协议边界。
+
+## 7. 关闭规则
+
+每个 B/V 项只能通过以下方式关闭:
+
+1. 记录被测 commit、镜像 digest、配置摘要和环境拓扑;
+2. 保存可复现命令、原始输出和失败注入证据;
+3. 由报告明确给出 pass/fail,不能只依赖日志中“看起来正常”;
+4. 任一生产候选输入变化后,重跑受影响的验证。
+
+在 B-01 至 B-03 全部实现,且 V-01 至 V-09 均有当前生产候选版本的通过证据前,
+Cloud v2 状态保持 `NOT APPROVED FOR SAAS ACTIVATION`。
diff --git a/docs/multi-tenant/implementation-checklist.md b/docs/multi-tenant/implementation-checklist.md
new file mode 100644
index 000000000..c74c3dd57
--- /dev/null
+++ b/docs/multi-tenant/implementation-checklist.md
@@ -0,0 +1,292 @@
+# Multi-tenant implementation checklist
+
+This checklist turns the Workspace architecture into implementation and
+verification gates. Exact commands and observed results are recorded in the
+[verification report](./verification-report.md).
+
+## Scope guard
+
+- [x] LangBot uses branch feat/multi-tenants.
+- [x] langbot-plugin-sdk uses branch feat/multi-tenants.
+- [x] langbot-space implements the greenfield Cloud v2 modular-monolith control plane without extending the legacy per-account Pod topology; the old Pod UI remains available when Cloud v2 is disabled and only retained Pods appear in the v2 view.
+- [x] Unrelated untracked files in either repository remain untouched.
+- [x] Open-source startup cannot enable SaaS multi-workspace through edition flags or unsigned configuration.
+
+## SaaS activation gates
+
+These items intentionally remain incomplete. Some require additional Core
+transaction/cutover primitives and others require the closed Control Plane or
+deployment. The feature branch delivers the Core isolation kernel, not the
+closed SaaS product or a production Cloud v2 deployment. Checked implementation
+items later in this document do not supersede these gates.
+
+- [x] The closed Control Plane owns the global Account, Workspace, Membership, and Invitation directory.
+- [ ] The closed Control Plane execution-ownership module issues monotonic generations and owner leases for projected Workspaces.
+- [x] Core verifies a signed `InstanceManifest` before the closed bootstrap can inject `CloudWorkspacePolicy`.
+- [ ] Tenant database writes hold a generation-aware shared transaction fence through commit, while execution-owner cutovers take the exclusive fence.
+- [ ] Business writes and non-transactional side effects use a generation-stamped outbox or equivalent publish fence.
+- [ ] Durable object references survive an execution-generation change through stable published keys or an explicitly atomic key/reference migration.
+- [ ] The SaaS runtime pools enforce tenant-safe egress and SSRF controls for Webhooks, providers, MCP servers, and every tenant-configurable outbound URL.
+- [ ] Entitlement checks, usage aggregation, and subscription lifecycle are implemented in the closed Control Plane; production activation still requires provider callback amount/currency/session/expiry binding inside the locked fulfillment transaction.
+- [ ] Account registration persists a `new_api.provision_account` outbox item with the Account and personal Workspace, and an in-process reconciler provisions New API idempotently after commit.
+- [ ] EPay and Stripe callbacks bind provider identity, amount, currency, channel/session, payment status, and expiry to the locked order before entitlement fulfillment.
+- [ ] OAuth state and directory projection use an atomic shared store suitable for horizontally scaled SaaS services.
+- [ ] A greenfield Cloud v2 deployment is designed and validated independently of the legacy Space deployment scheme.
+- [ ] The Plugin Runtime shared profile refuses to run without delegated cgroup v2 CPU, memory-plus-swap, and PID limits, all verified in a real Linux container; production tenant-safe egress remains incomplete.
+- [x] The Plugin Runtime Supervisor automatically restores an unexpectedly exited enabled worker with bounded per-installation backoff.
+- [ ] Jitter, a global restart concurrency limit, and a Runtime-level circuit breaker prevent a systemic failure from creating a cross-tenant restart storm.
+- [ ] Until authenticated Runtime takeover or an owner lease/fence exists, the M0 deployment rolls Core and Plugin Runtime together and forbids an independent Core-only rollout.
+- [ ] Plugin installation data has an operator-owned hard disk quota provider that atomically rejects writes over the limit; directory scans are not accepted as enforcement.
+- [ ] The Box deployment provides an operator-owned quota provider that proves hard byte and inode limits for Workspace, Skill, root, tmp, and home storage.
+- [ ] Core and Box Runtime mount the same durable volume and pass the authenticated marker challenge during startup and reconnect.
+- [ ] Production provisions distinct migrator/runtime credentials and runs the implemented same-host/port/database release command as a one-shot Job, with tested orchestration retry, backup, and rollback procedures.
+- [ ] Production PostgreSQL uses a dedicated cluster/endpoint, or a tested HBA/proxy policy proves the cluster-wide runtime credential can connect only to the target business database.
+- [ ] Any future direct-migrator/pooler-runtime endpoint split is admitted only by a migrator-owned, runtime-read-only database cluster identity that the runtime role cannot spoof.
+- [ ] Legacy pgvector migration failure and retry integration paths prove exact source-table RLS/FORCE restoration; the non-superuser, non-`BYPASSRLS` success path is already covered below.
+- [ ] Multi-workspace is enabled in SaaS only after all closed Control Plane, deployment, and security gates pass.
+
+## 1. Persistence foundation
+
+### Account and directory
+
+- [x] User has a stable, unique account UUID and explicit status.
+- [x] Existing email and password behavior remains compatible during migration.
+- [x] Workspace table represents the instance-local tenant.
+- [x] WorkspaceMembership has a unique Workspace and Account pair.
+- [x] WorkspaceInvitation stores only a token hash and supports expiry, revoke, and one-time accept.
+- [x] WorkspaceExecutionState stores generation, state, source, and write fence.
+- [x] OSS initialization creates exactly one Workspace and one owner membership atomically.
+- [x] OSS refuses a second Workspace while allowing multiple members.
+
+### Migration
+
+- [x] Alembic migration upgrades SQLite.
+- [x] Alembic migration upgrades PostgreSQL.
+- [x] Existing first user becomes owner of the default Workspace.
+- [x] Existing tenant resources are backfilled with the default Workspace UUID.
+- [x] SQLite destructive boundaries create verified, revision-aware backups and atomically restore after failure.
+- [x] Migration can resume safely after interruption.
+- [x] New installs and upgraded installs produce the same tenancy-kernel schema.
+- [x] The first Cloud release pins migrator and runtime sessions to `public` with `current_schemas(false)` containing only that business schema; runtime-role/database `search_path` overrides are rejected.
+- [x] Cloud sessions require `session_replication_role=origin`, `row_security=on`, and `lo_compat_privileges=off`; every persistent `pg_db_role_setting` applicable to the runtime role or current business database is rejected.
+- [x] The release migrator grants the runtime role exact business-table DML, `alembic_version` read-only access, and business-sequence `USAGE/SELECT`, with no `WITH GRANT OPTION` or non-business object grants.
+- [x] Every Cloud runtime startup revalidates the login role, current user, schema, effective/direct ACLs, ownership, memberships in all directions, column ACLs, routines, extensions, foreign objects, parameter ACLs, and other-schema access before serving traffic.
+- [x] The business database requires `vector`, permits only `plpgsql`/`vector` extensions, forbids runtime extension ownership, and contains no foreign data wrapper, foreign server, or user mapping.
+- [x] The runtime role and `PUBLIC` have no explicit routine or parameter ACL; the runtime owns no routine and cannot effectively execute any `SECURITY DEFINER` routine, including extension-owned routines.
+- [x] PostgreSQL's default `PUBLIC TEMP` is documented and tested as a dedicated-business-database v1 compatibility exception; the migrator never grants `TEMP` directly to the runtime role.
+- [x] Legacy pgvector migration succeeds as a non-superuser, non-`BYPASSRLS` source-table owner and restores mixed source-table RLS/FORCE states exactly.
+- [ ] Legacy pgvector migration still needs explicit failure-and-retry integration coverage before SaaS activation.
+
+### Runtime transaction enforcement
+
+- [x] Each tenant UoW owns one task, root transaction, database bind, and transaction-local scope.
+- [x] A scoped Session and every captured bound method become permanently unusable when the owning UoW exits.
+- [x] Public transaction/session control, raw/textual SQL, connection/bind escape, nested transactions, execution/loader options, foreign binds, live results, unapproved functions/operators/casts/types, `INSERT FROM SELECT`, hidden `ON CONFLICT` and batch-value expressions, forced-unquoted identifiers, and custom AST/compiler nodes fail closed and make the UoW rollback-only.
+- [x] ORM `SessionEvents` fail before a registered callback can receive the synchronous Session or transaction connection; rollback cleanup cannot execute the rejected listener.
+- [x] ORM flush, implicit autoflush, and commit reject SQL expressions assigned to mapped attributes before compilation.
+- [x] Tenant relationship loading uses eager loading or explicit async `refresh`; synchronous object-session access and `AsyncAttrs.awaitable_attrs` are not supported tenant APIs.
+- [x] The UoW guard is documented as a trusted-Core misuse boundary rather than an in-process Python sandbox; mapped metadata/compiler registration is trusted, plugins remain out of process, and SQLAlchemy upgrades must rerun the private-container regression suite.
+
+## 2. Authentication and authorization
+
+### Identity
+
+- [x] JWT sub uses account UUID, with a bounded compatibility path for legacy email tokens.
+- [x] Disabled or deleted accounts cannot authenticate.
+- [x] Local password and Space-linked account flows support more than one local Account.
+- [x] Public registration closes after initialization by default.
+- [x] Invitation registration works without requiring SMTP.
+- [x] An unknown Space OAuth subject cannot claim an existing Account by email; explicit account-bound binding is required.
+
+### Request context
+
+- [x] PrincipalContext identifies Account, API Key, or trusted runtime principal.
+- [x] WorkspaceContext contains Workspace, Membership, role, permissions, and revision.
+- [x] RequestContext contains instance UUID, Workspace context, auth type, request ID, and generation.
+- [x] ExecutionContext propagates Workspace and generation to runtime work.
+- [x] SaaS-style requests never fall back to the first or most recent Workspace.
+- [x] OSS may resolve the single Workspace when the selector is omitted.
+- [x] Account-token bootstrap can list only the authenticated Account's active memberships before a Workspace selector exists.
+
+### Fixed RBAC
+
+- [x] owner, admin, developer, operator, and viewer permissions match the architecture matrix.
+- [x] Invitation cannot grant owner.
+- [x] The last owner cannot be removed or demoted.
+- [x] Cross-Workspace resources return 404.
+- [x] Same-Workspace permission failures return 403.
+
+## 3. Workspace and member APIs
+
+- [x] GET /api/v1/workspaces returns the OSS singleton Workspace.
+- [x] POST /api/v1/workspaces returns edition_limit in OSS.
+- [x] Current Workspace endpoint returns the authenticated Membership.
+- [x] Member list is permission scoped.
+- [x] Invitation create, revoke, inspect, and accept are atomic.
+- [x] Member role update and removal enforce owner rules.
+- [x] Invitation tokens travel in a request body and are redacted from logs.
+- [x] Relevant MCP tools and in-repo skills are updated with the same contract.
+
+## 4. Tenant-scoped persistence and services
+
+Each row type must have a non-null Workspace UUID, scoped indexes, scoped uniqueness, and scoped CRUD tests.
+
+- [x] Bots and bot admins.
+- [x] Legacy pipelines and pipeline run records.
+- [x] Model providers.
+- [x] LLM models.
+- [x] Embedding models.
+- [x] Rerank models.
+- [x] Plugin installations, settings, and configuration.
+- [x] MCP servers and resource preferences.
+- [x] Knowledge bases, files, and chunks.
+- [x] Vector collections and handles.
+- [x] Monitoring messages, calls, sessions, errors, embeddings, and feedback.
+- [x] API keys and scopes.
+- [x] Webhooks and public route resolution.
+- [x] Binary storage and Workspace storage.
+- [x] Workspace metadata, separated from system metadata.
+
+### Service and API rules
+
+- [x] Every tenant Service receives RequestContext or an explicit Workspace UUID.
+- [x] No tenant Service treats context None as global access.
+- [x] Every applicable get, list, create, update, delete, copy, export, and bulk operation is scoped.
+- [x] Parent-child references use the same Workspace.
+- [x] API Key authentication derives Workspace from the key, not a header.
+- [x] Webhook and Bot public routes derive Workspace from a trusted resource.
+- [x] Background jobs carry Workspace and generation explicitly.
+
+## 5. Runtime isolation
+
+### Core runtime
+
+- [x] RuntimeBot carries Workspace UUID and execution generation (currently stored in the compatibility field `placement_generation`).
+- [x] RuntimePipeline carries Workspace UUID and execution generation (currently stored in the compatibility field `placement_generation`).
+- [x] Query and Event carry Workspace UUID without making it an authorization source.
+- [x] Session key includes Workspace UUID, Bot UUID, launcher type, and launcher ID.
+- [x] QueryPool and manager indexes cannot collide across Workspaces.
+- [x] Query and aggregation cache keys and locks include Workspace UUID.
+- [x] Runtime transports, cached results, object operations, and long-lived tasks revalidate WorkspaceExecutionState generation at side-effect boundaries.
+- [ ] Ordinary tenant database writes hold the generation fence in the same transaction until commit; this remains a SaaS activation gate.
+
+### Plugin
+
+- [x] Plugin installation and configuration are Workspace scoped.
+- [x] Runtime control actions carry trusted Workspace binding and execution generation (wire-compatible as `placement_generation`).
+- [x] The Plugin Runtime supervisor is instance-scoped and intentionally serves multiple Workspaces.
+- [x] Every plugin process is bound to exactly one Workspace, installation, generation, revision, and verified artifact digest.
+- [x] Same-digest plugin code may be cached once, while worker processes and writable data remain isolated.
+- [x] Same-digest plugin dependencies are prepared once in a Runtime-owned immutable environment and mounted read-only into each isolated worker; dependency failure is surfaced before launch and recorded per installation without blocking other desired-state recovery.
+- [x] Host API derives Workspace from the connection, installation, and trusted action context, not plugin input.
+- [x] Plugin get_bots, models, tools, vector, RAG, configuration, and messaging calls are scoped.
+- [x] Plugin Workspace storage no longer uses owner default.
+- [x] Plugin page APIs check Membership and installation ownership.
+- [x] Local plugin launches use short-lived, one-use registration capabilities bound to manifest identity.
+
+### MCP, RAG, and Box
+
+- [x] MCP runtime key contains instance UUID, Workspace UUID, execution generation, and server UUID.
+- [x] Same-named MCP servers in two Workspaces do not share sessions.
+- [x] Pipeline cannot reference another Workspace's MCP resource.
+- [x] RAG collection names and handles are server-derived and Workspace scoped.
+- [x] Legacy global vector migration is available only to the local OSS singleton Workspace.
+- [x] Object storage paths include instance, Workspace, and execution generation for the fixed-generation OSS runtime.
+- [x] Object storage revalidates generation before touching a provider or resolving an opaque key.
+- [ ] Cloud cutover uses generation-scoped staging plus stable published object references, rather than making the staging generation the durable identity.
+- [x] Box persistent and ephemeral namespaces include the required instance, Workspace, and generation scope.
+- [x] Same-named Box sessions and processes cannot collide across Workspaces or execution generations.
+- [x] Box relay and process I/O reject or retire stale generations.
+- [x] External paths and privileged mounts cannot be supplied by an untrusted plugin.
+- [x] Cloud attachment host I/O uses query UUIDs and link-free dirfd operations with bounded inode traversal.
+- [x] Cloud Skill package paths are Runtime-owned, Workspace-scoped, read-only mounts; Python env/cache stays tenant-writable.
+- [x] Skill ZIP preview/install rejects path escape, links, non-regular files, duplicate entries, excessive compression ratio, entry count, per-file size, and total size.
+- [x] Cloud Box code paths and automated tests require the authenticated marker challenge before startup or reconnect can proceed.
+- [x] Cloud Box readiness fails until hard Workspace, Skill, ephemeral-storage, and inode quota capabilities are available.
+
+## 6. SDK and protocol
+
+- [x] Public Query, Event, Session, and context entities carry backward-compatible Workspace data.
+- [x] Action RPC request models carry trusted Workspace binding where required.
+- [x] Action enums and callers remain consistent.
+- [x] Old plugins continue to deserialize compatible events.
+- [x] Plugins cannot select an arbitrary Workspace through a Host API argument.
+- [x] Runtime storage uses the bound Workspace UUID.
+- [x] SDK API tests pass.
+- [x] Runtime tests pass.
+- [x] Action consistency script passes.
+
+## 7. Frontend
+
+- [x] Every browser tenant API request carries the current Workspace selector after bootstrap.
+- [x] OSS automatically selects the singleton Workspace.
+- [x] OSS does not show Create Workspace or a misleading switcher.
+- [x] Workspace settings show current Workspace information.
+- [x] Members page lists roles and permissions.
+- [x] Invitation creation shows a one-time link when SMTP is unavailable.
+- [x] Invitation acceptance supports a signed-out user flow.
+- [x] Role controls are hidden or disabled consistently with backend permissions.
+- [x] Switching accounts clears stale Workspace query cache and local state.
+- [x] User-facing strings support en_US, zh_Hans, and ja_JP.
+
+## 8. Automated verification
+
+### Persistence and authorization
+
+- [x] SQLite fresh install.
+- [x] SQLite upgrade from pre-tenant schema, including verified failure recovery.
+- [x] PostgreSQL fresh install.
+- [x] PostgreSQL upgrade from pre-tenant schema.
+- [x] All fixed roles have positive and negative permission-matrix tests.
+- [x] Concurrent invitation acceptance creates one Membership.
+- [x] Concurrent owner changes never leave zero owners.
+
+### Cross-tenant isolation
+
+- [x] Two Workspaces are created through a test-only policy.
+- [x] Applicable resource operations and parent-child references have cross-Workspace negative coverage.
+- [x] Resource UUID guessing cannot cross Workspace.
+- [x] API Key cannot cross Workspace.
+- [x] Plugin cannot enumerate or invoke another Workspace's resources.
+- [x] Sessions, caches, locks, MCP, RAG, Box, storage, and monitoring do not collide.
+- [x] Background jobs cannot execute without an explicit Workspace and execution generation.
+
+### Security and revocation
+
+- [x] Space login and binding use purpose-bound, one-time opaque OAuth state; caller-supplied state is rejected.
+- [x] OAuth redirects trust only server-configured WebUI or webhook origins, never request `Host` or `Origin` headers.
+- [x] Dashboard WebSockets revalidate authentication, Membership, resource, permission, and generation per message.
+- [x] Public embed WebSockets re-resolve Bot availability and execution binding per message.
+- [x] Runtime, storage, Plugin Runtime, MCP, RAG, and Box reject a stale execution generation.
+- [x] Unhandled API and webhook failures return a generic error plus request ID without exception text.
+- [x] URL user information and sensitive query parameters are redacted before configuration is serialized or logged.
+
+### Regression
+
+- [x] LangBot unit tests pass.
+- [x] LangBot integration tests pass.
+- [x] Frontend lint completes without errors and the production build passes.
+- [x] SDK focused and full relevant tests pass.
+- [x] LangBot is pinned to the exact pushed SDK commit and cross-repo tests pass against that revision.
+
+## 9. Real browser E2E
+
+- [x] Start from a clean local data directory.
+- [x] First user initializes the singleton Workspace as owner.
+- [x] Owner creates an invitation link.
+- [x] A second signed-out browser identity accepts the invitation and registers.
+- [x] owner, admin, developer, operator, and viewer UI permissions match backend enforcement.
+- [x] Direct API calls cannot bypass hidden controls.
+- [x] Account switch does not expose prior account or Workspace data.
+- [x] Refresh and a new browser tab recover the correct Workspace safely.
+- [x] OSS rejects a second Workspace with `edition_limit`; same-name and same-identifier isolation is covered by the test-only multi-Workspace policy because OSS deliberately has no multi-Workspace browser surface.
+- [x] Explicit error states are visible for expired, revoked, reused, and email-mismatched invitations.
+
+## 10. Completion evidence
+
+- [x] LangBot and SDK branch refs are recorded in the verification report.
+- [x] Space contains the closed adapter package and Cloud v2 control plane, billing, migration, and Workspace UI changes; unrelated pre-existing files remain unstaged.
+- [x] Migration output is captured for SQLite and PostgreSQL.
+- [x] Test commands and results are recorded.
+- [x] Browser E2E actions and observed results are recorded.
+- [x] No remaining tenant table, global Service query, owner default, or unscoped runtime key is found by the final audit.
diff --git a/docs/multi-tenant/implementation-decisions.md b/docs/multi-tenant/implementation-decisions.md
new file mode 100644
index 000000000..a74c13f32
--- /dev/null
+++ b/docs/multi-tenant/implementation-decisions.md
@@ -0,0 +1,305 @@
+# Multi-tenant implementation decisions
+
+This log records implementation choices made while delivering the Workspace architecture. It is intended to make trade-offs auditable without interrupting implementation for routine decisions.
+
+> Architecture decisions, activation gates, and still-open follow-ups are tracked in
+> [pending-architecture-decisions.md](./pending-architecture-decisions.md). Sections marked as decided there are authoritative;
+> this file records the concrete implementation choices and compatibility names used to realize them.
+
+## 2026-07-18
+
+### OSS remains a singleton Workspace with multiple Accounts
+
+- Decision: Community builds create exactly one Workspace per LangBot instance and allow multiple Accounts through invitations.
+- Reason: This preserves a simple self-hosted deployment while making authorization and ownership explicit. Creating a second Workspace is an edition error, not a hidden fallback.
+- SaaS boundary: Multi-Workspace directory, execution ownership, entitlement, and billing are the responsibility of a separate closed SaaS Control Plane. Core consumes a validated projection and remains the final isolation and authorization enforcement point; it does not become the SaaS system of record or billing engine.
+- Deployment boundary: Cloud v2 is a greenfield deployment design. The previous per-account instance/pod scheme is not migrated or extended and remains available only for existing subscriptions. Existing OAuth, marketplace, and payment rails are reused through explicit adapters where they still fit; new Workspace, subscription, entitlement, directory, and usage modules live alongside the legacy Pod flow in `langbot-space`.
+
+### Workspace selection is trusted only after authentication
+
+- Decision: Browser requests carry `X-Workspace-Id`, but the server resolves it against the authenticated Account membership. API keys, public Bot routes, webhooks, jobs, and plugin calls derive Workspace from their trusted owning resource or binding instead of trusting the header.
+- Reason: A selector is routing input, not authorization evidence.
+- Compatibility: Community builds may select the singleton Workspace when the header is omitted. A multi-Workspace-capable build must reject an omitted selector.
+
+### Stable Account UUID is the token subject
+
+- Decision: New JWTs use the stable Account UUID as `sub`; a bounded compatibility path accepts legacy email-subject tokens and rotates them when checked.
+- Reason: Email can change and therefore cannot be a durable authorization identity.
+
+### Fixed roles are authoritative in Core
+
+- Decision: `owner`, `admin`, `developer`, `operator`, and `viewer` map to a fixed permission matrix in LangBot Core. The last owner cannot be removed or demoted, and invitations cannot create an owner directly.
+- Reason: Core must remain the final authorization boundary in both OSS and SaaS deployments.
+
+### Cross-Workspace access is indistinguishable from absence
+
+- Decision: Resource lookups always include Workspace UUID. A guessed UUID belonging to another Workspace returns 404; a visible resource with insufficient same-Workspace permission returns 403.
+- Reason: This avoids leaking resource existence across tenants while preserving actionable same-tenant errors.
+
+### Plugin Runtime is shared; every plugin process is single-Workspace
+
+- Decision: One instance-scoped Plugin Runtime control plane serves all Workspaces in the logical LangBot instance. Each running plugin installation has its own nsjail worker with an immutable binding containing `instance_uuid`, `workspace_uuid`, `execution_generation` (stored as the compatibility field `placement_generation` until the schema rename), `installation_uuid`, `runtime_revision`, and verified artifact digest; enabled-resident is the desired semantic. A worker never routes actions for another Workspace or installation, and plugin-supplied scope fields are stripped.
+- Isolation: Plugin code is mounted read-only. Home, tmp, and data paths are installation-scoped; process, file-descriptor, file-size, CPU, memory, and PID limits come only from `data/config.yaml` (including native environment overrides), never from a plugin manifest. Cloud requires nsjail and delegated cgroup v2 hard limits or fails closed.
+- Cost boundary: Identical verified package bytes share one digest-addressed code cache. A dependency environment is keyed by the artifact and requirements digests, Python ABI, Runtime version, and installer schema, then atomically published read-only for reuse. Installations and processes are not merged, even for the same plugin and version. Registration creates database desired state only; a worker is launched only for an enabled installation.
+- Recovery: PostgreSQL installation desired state and durable binary storage are authoritative. Runtime reconnect performs an instance-wide full reconciliation, removes stale workers, and can replay a verified package after Runtime-local cache loss. Dependency preparation failure is recorded per installation with `dependency_prepare_failed`; it prevents that worker launch without blocking recovery of other desired installations, and the same revision can be retried. The installation Supervisor now restores an unexpectedly exited enabled worker through a completion callback with bounded exponential backoff. Jitter, global restart concurrency limits, and a Runtime-wide circuit breaker are still required to prove that an infrastructure-wide failure cannot create a cross-tenant restart storm.
+- Compatibility: Older SDK payloads and legacy `data/plugins` remain an OSS-only bridge. Shared mode requires complete bindings and rejects incomplete context.
+- Reason: Sharing the supervisor and immutable code cache removes per-Workspace service cost without turning an untrusted plugin process into a cross-tenant router.
+
+### Invitation delivery does not require SMTP
+
+- Decision: Core returns an invitation secret once for copy-and-share, persists only its hash, and supports expiry, revocation, and one-time acceptance.
+- Reason: Self-hosted OSS must support adding users without an email service while avoiding recoverable invitation secrets at rest.
+- Browser handling: The copyable invitation URL carries the secret in its fragment, which browsers do not send in HTTP requests or Referer headers. The acceptance page immediately removes the fragment and keeps the secret only in `sessionStorage` until login or acceptance completes; it is never placed in a path, query string, analytics event, or persistent local storage.
+
+### Schema rollout is additive before enforcement
+
+- Decision: Add Account/Workspace directory tables first, then add non-null Workspace ownership to every tenant resource with a deterministic default-Workspace backfill. Runtime and service enforcement is enabled only with matching migration and isolation tests.
+- Reason: A Workspace column alone is not isolation, and enforcing queries before data backfill would break upgraded installations.
+
+### Login capability discovery is instance-scoped, not account-scoped
+
+- Decision: The unauthenticated login bootstrap endpoint reports only which login mechanisms the instance supports. It does not inspect the first Account or expose whether that Account has a password. Both password and Space OAuth entry points are available on a multi-user instance; the submitted identity determines which mechanism is valid.
+- Reason: This avoids projecting the original owner's authentication type onto invited users and removes a public Account-state disclosure.
+
+### Space OAuth identity does not choose a SaaS Workspace
+
+- Decision: Space OAuth tokens remain Account credentials. In OSS singleton mode, an OAuth refresh may update the singleton Workspace's Space provider only when that Account's role can manage provider secrets. In SaaS multi-Workspace mode an OAuth callback without an authenticated Workspace selector never guesses which Workspace to mutate; explicit Workspace configuration or the closed control plane owns that linkage.
+- Reason: An Account may belong to several Workspaces, and authentication must not silently mutate a shared tenant secret.
+
+### SaaS execution state is a validated Core projection
+
+- Decision: Core can resolve both local and `cloud_projection` Workspaces, but only from an explicit Workspace UUID and an active, unfenced `WorkspaceExecutionState` for the current instance and matching source. OSS-only bootstrap paths additionally require `source=local`.
+- Reason: The closed control plane owns execution ownership and generation decisions, while Core remains the enforcement point for instance binding, generation, and write fences.
+
+### API-key secrets are one-time and Workspace-bound
+
+- Decision: Database API keys persist only a globally unique SHA-256 hash, an opaque UUID, one Workspace UUID, explicit fixed-permission scopes, status, expiry, creator, and last-used time. The raw secret is returned once. Authentication derives Workspace and generation from the key record and ignores Workspace selectors. Legacy plaintext keys are hashed during migration and receive a compatibility `*` scope. The plaintext config key works only for the OSS singleton Workspace and is disabled in multi-Workspace mode.
+- Reason: A bearer key is an identity and routing credential, not merely a password layered on top of caller-controlled tenant selection.
+
+### MCP tools inherit the authenticated API-key context
+
+- Decision: The MCP ASGI mount authenticates the API key once, binds an immutable per-request `RequestContext`, and every tool checks a fixed permission before calling tenant services with that same context.
+- Reason: Authenticating the transport without propagating Workspace identity into tool calls would leave the direct service path globally scoped.
+
+### Unreleased SDK protocol is pinned reproducibly without publishing
+
+- Decision: The SDK tenancy protocol is versioned as 0.4.18. This task does not create a GitHub release or publish PyPI because the user authorized pushing code, not a package release. After the SDK feature branch is final, LangBot's feature branch temporarily pins the exact pushed SDK Git commit. Before merging to master, the release gate is to publish `langbot-plugin==0.4.18` and replace the Git pin with the registry pin.
+- Reason: The current registry release does not contain the complete tenant action context and shared Runtime hardening. An exact Git commit is reproducible and keeps the feature branch testable without expanding release authority.
+
+### Cloud directory writes stay outside Core
+
+- Decision: The open-source Core startup always installs `SingleWorkspacePolicy`, creates or repairs one local Workspace, and permits local membership/invitation workflows. Changing mutable configuration such as `system.edition` cannot activate multi-Workspace routing. The future closed Cloud bootstrap will install `CloudWorkspacePolicy` only after verifying a signed `InstanceManifest`; that policy requires an explicit projected Workspace selector, does not create Workspaces, and rejects invitation or membership mutations with `control_plane_required`; member reads use the versioned local projection.
+- Ownership split: The closed Control Plane owns the global Account/Workspace/Membership/Invitation directory, execution ownership and generation, entitlements, subscription state, usage aggregation, and billing decisions. Core owns request authorization, resource scoping, execution-generation validation, and fail-closed enforcement. Provisioning and invoice computation do not belong in open-source Core.
+- Reason: The closed control plane is authoritative for SaaS Account, Workspace, Membership, and Invitation state. Allowing Core to mutate the same directory would create split-brain ownership and would make an ownerless compatibility Workspace a dangerous fallback.
+- Release gate: Multi-Workspace activation is deliberately unavailable in the open-source bootstrap. Production Cloud v2 must implement the signed `InstanceManifest` verifier and closed bootstrap described in the architecture document before it can inject `CloudWorkspacePolicy`; `edition=cloud`, an environment variable, or any unsigned local configuration is never a valid activation credential.
+
+### Workspace bootstrap is reactive and ordered before browser resource calls
+
+- Decision: The web application blocks Workspace-owned pages until Account and current Workspace bootstrap completes. A `useSyncExternalStore` Workspace store publishes permission changes to React consumers; direct mutation-only routes and controls are hidden or disabled when the fixed role lacks the required permission.
+- Reason: Mutating a module-level variable after the initial React render did not reliably re-render permission controls, and mounting resource pages before the selector was established could issue tenant requests without `X-Workspace-Id`.
+
+### JWTs are bound to one LangBot instance
+
+- Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy.
+- Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary.
+
+### Runtime control transports authenticate before protocol dispatch
+
+- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate.
+- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse.
+- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket.
+
+### Dashboard WebSocket sessions are tenant runtime objects
+
+- Decision: A dashboard WebSocket sends an authentication frame immediately after upgrade. The server validates Account, Membership, permission, Pipeline ownership, instance, Workspace, and execution generation before registering the connection. Connection indexes, sessions, broadcasts, attachments, and resets include the complete execution scope.
+- Reason: Browser WebSocket APIs cannot attach the normal authorization headers, and a process-global `pipeline_uuid` or `session_type` index can collide across Workspaces.
+
+### Read permissions never imply secret permissions
+
+- Decision: `resource.view` responses recursively redact Bot, Plugin, MCP, and provider credentials. Provider secrets require `provider_secret.manage`; Bot and Plugin configuration writes require `resource.manage`. Masked Plugin values can be round-tripped by a manager without overwriting the stored secret. Plugin Runtime debug credentials require `resource.manage`, not the operator-only `runtime.operate` permission.
+- Reason: A multi-user Workspace needs useful viewer access without turning every visible configuration endpoint into credential export. Plugin debug attachment can register executable code and is therefore a resource-management operation.
+
+### Temporary credential exchanges are bound to their initiator
+
+- Decision: Lark, Weixin, DingTalk, WeComBot, and QQOfficial one-click registration sessions require `resource.manage` and store the initiating instance, Workspace, execution generation, and principal. Status and cancellation by any other scope return the same 404 as an unknown session.
+- Reason: Random session IDs reduce guessing probability but do not authorize access to credentials returned by a completed exchange.
+
+### Uploaded images and documents use different storage capabilities
+
+- Decision: Browser images use the scoped `upload_image` owner type and may be resolved only through the opaque public-image route. RAG documents use `upload_document` and can be read, sized, or deleted only by an exact instance, Workspace, generation, and owner-type match. Legacy `upload` objects are cleanup-only.
+- Reason: Treating every upload as a public image made a leaked document key sufficient to bypass authenticated RAG access.
+
+## 2026-07-19
+
+### Space OAuth state is server-issued and single-use
+
+- Decision: Core issues an opaque, cryptographically random OAuth state for each Space login or Account-binding attempt, stores only its digest, and consumes it exactly once within a short expiry. Login and binding states are different capabilities; a binding state is additionally bound to the authenticated Account. Caller-supplied state, including a LangBot JWT, is rejected.
+- Redirect boundary: Callback redirects are accepted only for the known callback path and an origin declared by the server-side `api.webui_url` or `api.webhook_prefix`. Request `Host` and `Origin` headers never expand this allowlist.
+- Current deployment: The OSS state store is bounded and process-local, so a Core restart safely invalidates outstanding attempts. A horizontally scaled SaaS deployment must move this exchange to an atomic, shared Control Plane store before enabling the closed Cloud bootstrap.
+- Reason: OAuth state is a narrow, one-time CSRF and flow-binding capability. Reusing a bearer JWT or trusting caller-controlled Host or Origin data would turn an authorization redirect into an Account-token theft or open-redirect primitive.
+
+### OAuth provider subjects, not email addresses, bind Accounts
+
+- Decision: A known Space `account_uuid` may refresh the credentials of its already-bound local Account. An unknown provider subject that presents an email belonging to an existing Account is rejected, even when the normalized emails match. The Account owner must authenticate locally and use the one-time, account-bound binding flow.
+- Reason: Email is contact and display data, not a stable federated identity key. Email-only auto-linking would let provider verification drift or identity reassignment become a local Account takeover.
+
+### Workspace discovery is an account-only bootstrap capability
+
+- Decision: `ACCOUNT_TOKEN` validates the active Account JWT but intentionally cannot resolve a Workspace, receive `RequestContext`, or declare Workspace permissions. Its narrow bootstrap endpoint returns only active Workspace memberships belonging to that Account and never chooses the first Workspace when several exist. All tenant resource routes still require the explicit selector in multi-Workspace mode.
+- Reason: Requiring a Workspace header to discover the Account's Workspaces creates an authentication deadlock; allowing the bootstrap route to perform tenant actions would create an authorization bypass. Separating the two capabilities resolves the cycle without weakening tenant routes.
+
+### SQLite tenancy migrations have a verified recovery boundary
+
+- Decision: Before each destructive tenant-schema boundary, a file-backed SQLite installation creates an online-consistent backup with its source and target revisions, runs `PRAGMA quick_check`, writes a durable manifest, and fsyncs restrictive-permission files and directories. A failed boundary disposes the engine, removes stale journal sidecars, atomically restores the verified source revision, and verifies the restored database before startup continues.
+- Compatibility: In-memory SQLite cannot provide this recovery guarantee and is rejected for destructive production migration boundaries; it remains usable in tests that create the final schema directly.
+- Reason: SQLite batch table rebuilds can leave an installation between schemas if a process or migration fails. A verified pre-boundary image makes retry behavior recoverable instead of merely idempotent in the happy path.
+
+### Execution generation is an execution revocation capability
+
+- Decision: RuntimeBot, RuntimePipeline, background tasks, object storage, Plugin Runtime, MCP, RAG, and Box operations carry the complete instance, Workspace, and execution-generation scope. The current schema and wire compatibility field remains `placement_generation` until a coordinated rename. They revalidate the active execution binding before accessing a provider or transport; long-running calls validate again before accepting results. A stale generation is fenced before it can read, write, or reuse a cached object.
+- Plugin boundary: Each locally launched plugin receives a short-lived, one-use registration capability bound to the expected manifest identity and execution scope. The production child environment does not inherit the reusable debug credential, and Host APIs derive scope from the trusted connection and action context.
+- Box boundary: Persistent skill content remains Workspace-scoped, while session/process state and relay requests also include execution generation. A generation change retires matching live sessions and closes a stale relay before further stdin, stdout, or file operations.
+- Transaction boundary: Request admission and runtime side effects are fenced in this branch, but ordinary tenant database mutations do not yet hold a generation-aware lock through commit. The closed Cloud bootstrap must remain disabled until Core provides the shared-write/exclusive-cutover transaction primitive and a generation-stamped outbox (or an equivalent atomic publish fence). The OSS singleton policy has a fixed local generation and cannot trigger an execution-owner cutover.
+- Durable-object boundary: Current opaque storage keys include generation and therefore fail closed after a generation change. That is safe for OSS's fixed generation, but a Cloud cutover must not strand durable KB files, images, or plugin references. Cloud v2 must publish stable final object identities from generation-scoped staging, or perform an atomic object-and-reference migration before activating the new generation.
+- Reason: Workspace UUID prevents cross-tenant collisions, but it cannot revoke work after execution ownership changes or is fenced. Execution generation is the monotonic revocation value that makes old runtimes unusable; it does not express membership in a product-level deployment entity.
+
+### Long-lived WebSockets continuously revalidate authority
+
+- Decision: Dashboard WebSockets re-authenticate the Account, Membership, permission, resource ownership, instance, Workspace, and execution generation for every inbound message, not only during the initial frame. A changed role, removed Membership, or fenced execution binding takes effect without waiting for reconnect.
+- Public embed boundary: The embed connection re-resolves its Bot before every message and rejects a Bot that was disabled, deleted, moved, or rebound. The public connection may identify a Bot, but it cannot make the initial Bot object an indefinite authorization capability.
+- Reason: Authorization and resource state can change while a socket remains open. Connection-time validation alone leaves a revocation gap.
+
+### Legacy vector migration is an OSS-local compatibility path
+
+- Decision: Status, backup, execute, dismiss, and background entry points for legacy global vector collections require an active local Workspace binding under `SingleWorkspacePolicy`. A `cloud_projection` Workspace cannot observe or migrate the old global collection, even when it carries a legacy marker.
+- Reason: The legacy collection predates tenant ownership. Treating it as a SaaS fallback would expose one installation's historical vectors to an arbitrary projected Workspace.
+
+### External errors and persisted URLs are redacted centrally
+
+- Decision: Unhandled HTTP and webhook failures return a stable `internal_error` response and request ID, and expose that ID in `X-Request-Id`; the detailed exception is retained only in server logs correlated by the same ID. Explicit domain and validation errors keep their documented status and code.
+- Secret boundary: Shared sanitization removes URL user information and masks sensitive query parameters before provider or MCP configuration is serialized, logged, or shown to a reader. Masked placeholders can be round-tripped by an authorized manager without replacing the stored secret.
+- Reason: Tenant isolation is incomplete if framework exceptions, connection URLs, or configuration reads can export credentials across otherwise authorized interfaces.
+
+### Box is one shared control plane with one admitted sandbox per Workspace
+
+- Decision: The logical instance has one shared Box Runtime control plane, implemented by one Runtime replica in M0. A closed entitlement adapter projects generic `managed_sandbox` capability and `managed_sandbox_sessions` limit; Core and Runtime never branch on a plan name. An eligible Workspace receives at most one persistent logical `global` session, while each ordinary command remains a one-shot nsjail process. Managed processes and network are disabled in the first Cloud release.
+- Storage boundary: Core and Runtime prove they see the same durable volume with an authenticated random-marker challenge. Attachments use opaque query UUID directories and link-free dirfd operations. Skill packages remain in the Runtime-owned Workspace store and enter a sandbox only as a read-only logical-name mount; Python environments and caches live in the tenant's writable Workspace.
+- Resource boundary: Cloud readiness requires cgroup v2 plus hard byte and inode limits for Workspace files, Skill storage, root, tmp, and home. The existing directory scan is only a compatibility soft check. Plain nsjail reports these storage capabilities as unavailable, so Cloud Box intentionally cannot start until the greenfield deployment supplies and verifies a real quota provider.
+- Archive boundary: Skill ZIP processing is bounded by compressed input, entry count, per-entry size, total uncompressed size, and compression ratio, and rejects links, non-regular entries, duplicates, and path escape before streaming extraction.
+- Reason: A shared supervisor removes per-Workspace services and idle control-plane cost, but storage and process admission must still fail closed at the untrusted execution boundary.
+
+### Cloud business data and vectors share one PostgreSQL schema
+
+- Decision: SaaS uses one PostgreSQL business database and shared schema. Every tenant row has an explicit Workspace key; application scope is the first boundary and precise `ENABLE` plus `FORCE ROW LEVEL SECURITY` policies are the second. The Cloud runtime role must be non-owner and have neither superuser nor `BYPASSRLS`.
+- Vector boundary: pgvector is the Cloud default in the same business database. Vectors use `(workspace_uuid, knowledge_base_uuid, vector_id)` identity, an untyped vector column with explicit checked dimension, and release-created partial expression indexes for the enabled dimensions. Cloud never falls back to Chroma or performs vector DDL at runtime.
+- Transaction boundary: A tenant UoW binds `SET LOCAL` and SQL to one transaction. Long-running pipeline and streaming MCP execution carry a trusted transaction-free tenant scope; each database helper opens a short scoped transaction, avoiding a held pool connection during LLM or network waits. Detached tasks start only after commit and create their own short UoW; rollback cancels them.
+- Schema boundary: The first release has exactly one business schema, `public`. Both migrator and runtime sessions must report `current_schema() = 'public'` and `current_schemas(false) = ARRAY['public']`; the runtime role and business database must not carry a `search_path` override. Runtime startup validates this before using the prepared schema and reruns the complete catalog and privilege validation on every process start; it never runs DDL.
+- Session boundary: Both Cloud modes require `session_replication_role = 'origin'`, `row_security = 'on'`, and `lo_compat_privileges = 'off'`. Every persistent setting applicable to the runtime role or current business database in `pg_db_role_setting` is rejected, even if its present value appears safe; tenant context remains transaction-local application state rather than a persistent role/database override.
+- Grant boundary: The migrator grants the runtime role direct `CONNECT` on the dedicated business database and `USAGE` on `public`; exact `SELECT, INSERT, UPDATE, DELETE` on every allowlisted business table; `SELECT` only on `alembic_version`; and exact `USAGE, SELECT` on business-owned sequences. It grants neither `CREATE`, `TRUNCATE`, `REFERENCES`, `TRIGGER`, sequence `UPDATE`, nor any privilege with `WITH GRANT OPTION`, and grants nothing on other relations or schemas.
+- Role boundary: The runtime identity is a `LOGIN` role with no superuser, `BYPASSRLS`, `CREATEDB`, `CREATEROLE`, or replication attribute; no role membership in any direction, including acting as grantor; no ownership of the business database, `public` schema, relations, sequences, routines, or extensions; no column ACLs; and no use, create, or ownership in another non-system schema. Neither the runtime role nor `PUBLIC` may have an explicit routine or parameter ACL, and the runtime role may not effectively execute any `SECURITY DEFINER` routine, including an extension-owned one. PostgreSQL's default `TEMP` privilege inherited from `PUBLIC` is an explicit first-release compatibility decision for this dedicated business database, not a direct runtime-role grant.
+- Catalog boundary: The business database must contain `vector` and may contain no extension other than `plpgsql` and `vector`; the runtime role owns neither. It contains no foreign data wrapper, foreign server, or user mapping. These checks remove catalog-level escape paths without forbidding the ordinary implicit execution of non-`SECURITY DEFINER` built-in routines.
+- Migration boundary: In the first release, the migrator and runtime URLs must name the same normalized PostgreSQL host, port, and database while using different roles. The migrator owns the application schema, establishes the exact allowlist above, and validates both required access and every prohibited escalation path before releasing the advisory lock. An exact Alembic head, RLS checks, and pgvector table/index/constraint validation remain mandatory; concurrent Jobs fail explicitly and are retried by orchestration.
+- Deployment boundary: PostgreSQL roles are cluster-wide, while the in-database audit proves only the target business database contract. SaaS production must therefore use a dedicated PostgreSQL cluster or endpoint that exposes only this business database to the runtime credential, or enforce and test an HBA/proxy policy proving that the credential cannot connect to any other database. This external connectivity proof is still an incomplete SaaS activation gate.
+- Endpoint evolution: A future deployment may use a direct endpoint for migrations and a pooler endpoint for runtime traffic. That topology may relax literal host/port equality only after both endpoints are proven to reach the same database through a database-internal, migrator-owned cluster identity that the runtime role can read but cannot create, alter, or spoof.
+- Legacy pgvector boundary: Revision 0013 records the exact `ENABLE` and `FORCE ROW LEVEL SECURITY` state of each RLS-protected source table, temporarily suspends those source policies as their table owner inside the migration transaction, and restores every table to its recorded state in `finally`. The migrator does not require superuser or `BYPASSRLS` for this data move.
+- Activation gate: The shared schema, pgvector adapter, and database-local runtime audit are implemented, but the external cluster/endpoint or HBA/proxy connectivity proof remains deployment work. Ordinary business writes also do not yet hold a generation-aware fence through commit; a generation-stamped outbox (or equivalent atomic publish fence) and stable durable-object references across generation cutover remain required before SaaS activation.
+- Reason: Sharing one database and pool keeps marginal Workspace cost low, while transaction-local context and RLS prevent that shared storage from becoming shared authority.
+
+### stdio MCP has an independent deployment gate
+
+- Decision: `mcp.stdio.enabled` is independent of Box availability and entitlement. OSS defaults it on for compatibility; Cloud requires it off at bootstrap and enforces the same gate on create, update, test, startup loading, and final runtime execution.
+- Reason: Treating Box availability as stdio permission would silently create another persistent `mcp-shared` sandbox for each Workspace and bypass the one-sandbox subscription and cost boundary.
+
+## 2026-07-20
+
+### The tenant UoW owns its task, root transaction, bind, and scope
+
+- Decision: A tenant UoW creates one task-owned `TenantScopedAsyncSession` and one root transaction. Public commit, rollback, close, connection, bind, nested-transaction, synchronous-Session, live-streaming, raw SQL, public execution options, and public `set_config` paths fail closed and mark the transaction rollback-only. ORM objects cannot expose a usable synchronous Session, captured methods cannot run in child tasks, an explicit foreign bind is rejected, and a captured Session is permanently retired when its UoW exits rather than being reset for reuse. Tenant scope is installed only through a private UoW capability; pgvector index-plan `SET LOCAL`/`EXPLAIN` diagnostics use a test/operator connection rather than the business Session API.
+- SQL boundary: Public UoW calls accept only structured SQLAlchemy query and DML trees. `TextClause`, literal SQL columns, textual labels, prefixes/suffixes/hints, statement execution options, `VALUES` roots, `INSERT FROM SELECT`, `EXTRACT`, literal-execute parameters, unknown/custom AST nodes, forced-unquoted identifiers, named `ON CONFLICT` constraints, unknown dialect post-values clauses, and untrusted casts/types fail closed. PostgreSQL/SQLite `ON CONFLICT DO UPDATE` and batch-insert containers are traversed explicitly because SQLAlchemy's standard visitor omits their executable values. Function classes are exactly allowlisted as `count`, `coalesce`, `sum`, `now`, `length`, and `nullif`; the only custom operator/cast admitted is the validated pgvector cosine operator and `Vector` cast.
+- Legacy migration boundary: The local-only RAG backup restore uses explicit table and column objects, never raw SQL, but deliberately leaves legacy values untyped. This preserves SQLite's string-valued `DATETIME` rows and both the historical PostgreSQL `TEXT` and fresh-schema `JSON` settings columns while keeping every value bound rather than interpolated.
+- ORM boundary: SQLAlchemy `SessionEvents` are unsupported on a tenant-scoped Session. If a listener is registered before or during a UoW, the operation fails before the callback executes and cleanup proceeds against an empty dispatch surface. Public `get`, `get_one`, `refresh`, and `merge` reject caller-supplied loader, bind, lock, shard, and execution options. Flush, implicit autoflush, and commit reject a SQL expression assigned to a mapped attribute before it can reach the compiler. Tenant code uses the async Session directly; relationships use eager loading or explicit `await session.refresh(entity, [attribute])`. LangBot's persistence base does not expose `AsyncAttrs.awaitable_attrs` as a supported tenant API.
+- Compiler trust boundary: This guard prevents accidental scope/transaction escape by trusted LangBot Core code; it is not an in-process Python sandbox. Registered SQLAlchemy compilers and mapped schema metadata are trusted boot-time code. The fail-closed traversal of dialect containers necessarily covers SQLAlchemy private fields, so dependency upgrades require the regression suite and remain pinned until verified. Untrusted plugins cannot import or call this Session because they remain isolated in Plugin Runtime child processes.
+- Result boundary: A caught database or boundary failure rolls back the root transaction and cancels after-commit work. Buffered results contain only already-authorized rows and no live connection; live database results cannot escape the UoW operation.
+- Reason: `SET LOCAL` plus RLS protects a tenant only while every statement stays on the same owned connection and callers cannot end the transaction, replace the GUC, recover the synchronous proxy, or route a statement through another bind.
+
+### Parallel request work re-enters tenant scope explicitly
+
+- Decision: Child coroutines created by request-level `asyncio.gather` open their own explicit transaction-free tenant scope before calling persistence-backed Plugin, MCP, or Skill operations. They never inherit the parent's active database Session.
+- Reason: Python copies ContextVars into child tasks, but SQLAlchemy Sessions are not task-safe and task identity is part of the tenant UoW boundary.
+
+### Ordinary monitoring is readable; audit and export remain privileged
+
+- Decision: Workspace monitoring dashboards, Bot logs, sessions, messages, calls, errors, and feedback require `resource.view`. Monitoring export requires `data.export`; system/runtime audit logs keep `audit.view`. Frontend tabs and controls use the same split.
+- Reason: A Viewer needs useful read-only product observability, while bulk data extraction and privileged runtime/system logs are separate capabilities.
+
+### Invitation failures survive login without contradictory success state
+
+- Decision: Invitation terminal codes map to stable browser states. A login-mediated email mismatch preserves the fragment-captured secret only in session storage, returns to the acceptance page with the stable error code, and suppresses the generic login-success toast. A transient acceptance failure retains the authenticated session and offers the same one-time invitation for retry. Invalid Bearer tokens on acceptance map to the normal authentication error instead of an internal failure.
+- Reason: Invitation acceptance crosses signed-out and authenticated states; losing or masking the domain error makes recovery ambiguous and can present contradictory UI feedback.
+
+### Shared Plugin Runtime starts only from verified desired state
+
+- Decision: SDK shared mode waits for immutable runtime configuration before inspecting plugin state, never scans or launches legacy `data/plugins`, and rejects legacy install/restart/delete/upgrade control actions. Worker RPC files use installation-private directories with aggregate size enforcement, and resident nsjail workers explicitly disable the default 600-second wall-time limit.
+- Remaining gate: completion-callback recovery, jittered per-installation backoff, globally bounded restart launch admission, Runtime-level circuit breaking, one half-open probe, and worker ready timeout are implemented. Production cross-tenant fault injection must still prove the restart-storm controls. Hard installation disk quota and production egress policy remain Cloud activation requirements; Linux nsjail/cgroup CPU, memory-plus-swap, PID, namespace, and cgroup-reaping behavior have real-container evidence.
+- Reason: A shared supervisor reduces per-Workspace services only if legacy global paths, writable transfer state, and lifecycle defaults cannot bypass installation isolation.
+
+## 2026-07-24
+
+### Cloud v2 remains a modular monolith with one closed adapter
+
+- Decision: Workspace directory, plans, subscriptions, payment fulfillment, entitlements, usage, and signed runtime feeds are implemented as modules in the existing Space service and PostgreSQL database. The only separately packaged closed component is the thin Core bootstrap/control-plane adapter; it verifies signed data and owns no business state.
+- Compatibility: The legacy Pod card and fulfillment path remain visible only to accounts with old subscriptions. New purchases create Workspace subscriptions and never provision a per-user LangBot Pod.
+- Reason: A separate tenancy or billing service would add deployment, queue, network, and consistency cost without providing an isolation boundary. The signed adapter keeps SaaS logic closed while Core retains the ORM, RLS, authorization, and runtime enforcement boundary.
+
+### Registration creates data, not infrastructure
+
+- Decision: Account registration and personal Workspace creation share one transaction and include an active owner membership, Free subscription, entitlement snapshot, and outbox notifications. Repair is idempotent for older accounts. Empty Workspace creation starts no Plugin worker, Box sandbox, database, queue, bucket, or tenant service.
+- Activation: This automatic Cloud v2 transaction is enabled only when Space has an explicit valid `CLOUD_V2_INSTANCE_UUID`. A legacy marketplace-only Space deployment with no Cloud v2 instance configured keeps its existing Account registration path; Cloud v2 internal endpoints still fail closed instead of inventing an instance identity.
+- Billing boundary: Core and runtimes consume only generic capability and numeric-limit entitlements. They never branch on `free` or `pro`; plan names, prices, payment providers, and fulfillment stay in Space.
+- Reason: This keeps the marginal cost of a new user close to a few PostgreSQL rows while preserving an immediate, usable Workspace.
+
+### Directory bootstrap is full; steady-state projection is per Workspace
+
+- Decision: Space generates the initial signed directory snapshot and its outbox high-water mark in one read-only PostgreSQL `REPEATABLE READ` transaction. After bootstrap, each event page names affected Workspaces and Core fetches one signed `directory.delta` for that set. Missing requested Workspaces are tombstones; unrelated Workspaces are untouched.
+- Cursor boundary: A delta carries no event cursor. Each signed event page carries the transaction-consistent current high-water mark, while Core advances only to the final event it actually consumed, even when the authoritative delta already contains a later revision. Event payload Workspace and revision fields must exactly match the signed event envelope; readiness is renewed only after the replica-local cursor reaches the signed high-water and the shared projection is not ahead.
+- Replica boundary: Every Core replica owns a process-local consumer cursor because its verified entitlement cache is process-local. Replicas share the PostgreSQL projection high-water mark and inbox. The state separately records which cursor range was atomically subsumed by a full snapshot, so a lagging replica can add missing receipts within that coverage and refresh its local caches without repeating tenant mutations; a missing receipt beyond snapshot coverage fails closed.
+- Reason: A shared consumer cursor would let one replica starve another replica's local cache, while a full snapshot on every change would make steady-state cost grow with every registered Workspace.
+
+### Cloud OAuth can authenticate only an existing projected Account
+
+- Decision: In multi-Workspace Cloud mode, Space OAuth may refresh tokens only for an active `cloud_projection` Account whose Space subject UUID and normalized email exactly match. It cannot create an Account, relink by email, mutate directory identity, or choose a Workspace.
+- Redirect boundary: When Cloud v2 configures its Core public URL, Space issues authorization codes only to that exact callback origin or the explicitly retained legacy managed-Pod domain. Community installations remain dynamic OAuth clients because they cannot pre-register with the public Space service: the consent screen displays their hostname, and only HTTPS or loopback HTTP with the fixed callback path is accepted. Remote HTTP, userinfo, fragments, arbitrary callback paths, and unrecognized query parameters always fail closed.
+- Reason: Space is the SaaS identity and directory authority, but Core remains the authentication enforcement point. Projected-only matching avoids split-brain identities; redirect allowlisting prevents bearer authorization-code exfiltration.
+
+## 2026-07-29
+
+### Directory capacity is one instance-level admission contract
+
+- Decision: Space and Core share an explicitly configured operational ceiling for active
+ Workspace and snapshot membership cardinality. Space serializes new personal-Workspace
+ creation across replicas with a PostgreSQL transaction advisory lock and rejects the
+ registration transaction before the limit is crossed. Core independently counts the
+ projected active Workspace set while holding the per-instance directory projection row
+ lock. Exceeding any limit rolls back the complete projection and does not advance its
+ cursor; neither side truncates authoritative data.
+- Snapshot boundary: A full snapshot is current desired state and contains active
+ Workspaces only. Archived Workspace revisions are retained as bounded, targeted deltas
+ so Core can validate and apply monotonic tombstones without every bootstrap carrying
+ unbounded history.
+- Memory boundary: Space bounds database result cardinality before signing. The closed
+ adapter bounds decompressed response bytes before JSON/JWS parsing and validates
+ Workspace/membership cardinality before entitlement-cache fan-out. Core schema models
+ have absolute list ceilings, validate duplicates in one pass, and bulk-read Accounts in
+ bounded chunks rather than issuing two serial queries per Account. Manifest and
+ entitlement responses have smaller endpoint-specific byte ceilings; entitlement refresh
+ validates and releases batches of at most 16 raw responses instead of retaining the
+ entire directory fan-out.
+- Operations boundary: Core health exposes aggregate active/max directory cardinality and
+ PostgreSQL pool occupancy/timeouts. The default active Workspace ceiling is 1,000, with
+ a hard code ceiling of 5,000; this is a safety stop, not a production capacity claim.
+ Space and Core configuration must match, and the approved production value comes from
+ the real V-08 capacity curve plus the V-09 24-hour soak.
+- Reason: One LangBot instance should admit new tenants at the cheapest data-only boundary,
+ while still preventing legitimate registration growth or a malformed control-plane
+ response from causing an unbounded startup allocation, database connection storm, or
+ CPU spike.
diff --git a/docs/multi-tenant/pending-architecture-decisions.md b/docs/multi-tenant/pending-architecture-decisions.md
new file mode 100644
index 000000000..b490940fc
--- /dev/null
+++ b/docs/multi-tenant/pending-architecture-decisions.md
@@ -0,0 +1,525 @@
+# Cloud v2 多租户架构决策与待决策项
+
+状态:`DECIDED — Core isolation kernel implemented; SaaS activation gates remain`
+创建日期:2026-07-19
+最近更新:2026-07-24
+
+本文记录 Cloud v2 多租户架构中已经确认的首期决策、明确淘汰的方案和仍需在后续阶段决定的扩展项。
+本文同时记录实现状态。“实现完成”仅指开源 Core/SDK 的隔离内核和 fail-closed 门禁,
+不表示闭源 Control Plane、计费或 Cloud v2 部署已经可上线。最终实现选择同步记录在
+[implementation-decisions.md](./implementation-decisions.md),剩余发布门禁记录在实施清单和验证报告。
+
+## 0. 已确认的 SaaS 拓扑前提
+
+1. SaaS 只有一个逻辑 LangBot 实例,全部 Workspace 都是该实例内的租户。
+2. 产品和领域模型中不引入 Cell 内多个 CloudInstance、Workspace Placement 或 Workspace 到 CloudInstance 的路由。
+3. 当前不实现分布式,但同一个逻辑实例未来可以运行多个 Core、Plugin Runtime 和 Box Runtime replica,
+ 也可以增加 PostgreSQL shard;这些只是内部实现,不成为新的租户或产品实体。
+4. 所有副本共享稳定的 `instance_uuid`;`replica_id`、`worker_id` 和进程地址是短期运行身份,不能写进业务资源的永久主键。
+5. `workspace_uuid` 始终是数据、任务和运行时的租户键,也是未来内部路由与分片的候选键。
+6. generation/epoch 的语义是执行所有权、故障转移和任务撤销,不代表 Workspace 在多个 CloudInstance 之间 Placement。
+7. 注册 Account 时自动创建 Workspace,但只新增目录记录和业务行,不创建租户专属部署、数据库、队列或 Runtime。
+8. OSS 仍是单租户 LangBot 实例,但允许该 Workspace 内存在多个用户;只有 SaaS 开启多 Workspace 租户模式。
+
+“单个 LangBot 实例”表示单个逻辑服务和安全域,不等于永远只有一个 OS 进程或一个 Kubernetes replica。
+当前代码字段 `placement_generation` 在完成架构迁移前继续兼容,目标语义和候选命名是 `execution_generation`。
+
+### 0.1 本轮确认的首期决策
+
+| 编号 | 结论 | 首期状态 |
+| ----- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
+| D-001 | 一个共享 Plugin Runtime 控制面;每个运行中的 plugin installation 独占一个 nsjail 子进程;只有 digest 相同且已验证的代码 artifact 可以只读共享 | `IMPLEMENTED — egress/disk-quota pending; restart-storm fault injection pending` |
+| D-002 | 一个共享 Box Runtime;Cloud 固定使用 nsjail;符合套餐的 Workspace 最多一个持久 `global` 逻辑 sandbox,普通执行按需启动 nsjail 进程 | `IMPLEMENTED FAIL-CLOSED — hard filesystem quota provider pending` |
+| D-003 | SaaS 业务数据使用 PostgreSQL shared schema、应用层作用域和 RLS 双重隔离;pgvector 使用同一 PostgreSQL,作为 SaaS 默认向量后端 | `PARTIALLY IMPLEMENTED — transaction/outbox/deployment gates remain` |
+| D-004 | stdio MCP 与 Box availability 解耦;Cloud v2 首期强制关闭 stdio MCP,避免为每个 Workspace 创建额外的 `mcp-shared` persistent sandbox | `IMPLEMENTED` |
+| D-005 | 目录启动使用事务一致的全量快照,运行时按事件涉及的 Workspace 拉取增量;每个 Core replica 独立消费事件,共享 PostgreSQL 投影和 inbox | `IMPLEMENTED — production fault injection pending` |
+
+Workspace 的具体创建、释放、数据导出和单 Workspace 恢复机制不在本轮决定;本文只保证这些后续能力不会改变稳定的
+`workspace_uuid`,也不会要求重建租户专属部署。
+
+## 1. 本轮重构的最高目标
+
+> 共享可信控制面和基础设施池,隔离不可信执行单元;减少独立部署、扩缩容和运维组件,使新增 Account 或 Workspace 的静态成本接近零。
+
+这里的“减少组件”指减少独立 Deployment、Service、数据库、消息系统和租户专属常驻控制面,
+不是通过合并安全边界来减少必要的隔离进程。
+
+统一评估原则:
+
+1. 注册 Account、自动创建空 Workspace 时,不启动 Plugin worker 或 Box sandbox。
+2. 启用插件后,每个 installation 的常驻成本来自其独立安全边界;首次使用托管 sandbox 后,符合套餐的 Workspace 才承担一个持久逻辑 session 的成本。
+3. 可信 supervisor、artifact cache、数据库连接池和 Runtime 容量可以多租户共享。
+4. 一个不可信插件进程不能服务多个 installation;一个 sandbox/session 不能服务多个 Workspace。
+5. 默认使用共享 Runtime;dedicated 只作为未来高隔离、大客户或合规资源等级,不建立第二套外部协议。
+6. 没有明确容量证据前,不新增 Kafka、Redis、Runtime 专用数据库、Box 专用数据库或租户级调度服务。
+7. 多租户隔离必须覆盖身份、路由、存储、缓存、日志、配额、撤销和故障恢复,不能只给请求增加 `workspace_uuid`。
+
+## 2. 首期部署形态与未来演进
+
+```mermaid
+flowchart LR
+ Traffic["SaaS traffic"] --> Core["One logical LangBot instance
1 Core replica in MVP"]
+ Core --> PluginRuntime["Shared Plugin Runtime
trusted supervisor"]
+ Core --> BoxRuntime["Shared Box Runtime
nsjail backend"]
+ PluginRuntime --> PA["Workspace A / installation 1
isolated nsjail process"]
+ PluginRuntime --> PB["Workspace B / installation 2
isolated nsjail process"]
+ BoxRuntime --> BA["Workspace A
one persistent global logical session"]
+ BoxRuntime --> BB["Workspace B
one persistent global logical session"]
+ Core --> PG["Shared PostgreSQL business schema
RLS + pgvector"]
+```
+
+| 档位 | 内部部署形态 | 新 Workspace 静态成本 | 启用条件 |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------ |
+| M0. 单副本 MVP | 一个 Core、一个共享 Plugin Runtime、一个共享 Box Runtime、一个 PostgreSQL business database;插件按启用状态运行,托管 sandbox 按首次使用与 entitlement 创建 | 只新增 Workspace 业务行 | 当前已确认目标 |
+| M1. 同逻辑实例内部横向扩展 | Core、Plugin Runtime、Box Runtime 按容量增加 replica;运行所有权由内部 lease 和 generation fence 决定;PostgreSQL 可增加共享 shard | 不创建 Workspace 专属部署 | 出现容量或可用性证据后 |
+| M2. Dedicated 资源档位 | 特定 workload 使用独享 worker pool、sandbox class 或 PostgreSQL shard,但沿用相同身份、协议、schema 和控制面 | 仅由购买 dedicated 的客户承担 | 合规、数据驻留或超大负载 |
+
+M1 是 M0 的透明扩容,M2 是相同架构下的资源等级;两者都不是新的 LangBot 实例、Cell 或 CloudInstance。
+外部 API 只认识稳定的 `instance_uuid` 和 `workspace_uuid`,不认识 replica、worker、pool 或 shard。
+
+Plugin Runtime 与 Core 在 M0 使用独立容器和 security context,Core 不能继承 Plugin Runtime 所需的
+nsjail/cgroup 权限。当前 Runtime 在进程生命周期内绑定首次认证的 `runtime_id`,因此 M0 必须把 Core 与
+Plugin Runtime 放在同一 rollout/restart unit 中协调重启;在实现受认证 takeover 或 owner lease/fencing 前,
+不能单独滚动 Core 并让它接管仍存活的 Runtime。Box Runtime 同样使用独立进程身份和安全配置,
+不与 Plugin Runtime 合并成一个高权限进程。
+
+## 3. D-001:Plugin Runtime 多租户控制面
+
+状态:`IMPLEMENTED — Cloud egress/disk-quota pending; restart-storm fault injection pending`
+
+### 3.1 已实现的基础
+
+- Plugin Runtime 控制连接只绑定稳定实例身份;一个逻辑共享控制面通过完整 installation binding 管理多个 Workspace,M0 由一个 Supervisor replica 承担。
+- 每个运行中的 installation 使用独立 nsjail worker;enabled-resident 是 desired semantics。代码只读,home/tmp/data 私有,shared profile 不读取 artifact `.env`。
+- 实例级 `PluginWorkerPolicy` 由 Core 的 `data/config.yaml` 下发,支持原生环境变量覆写;manifest 不能覆盖。
+- `installation_uuid`、`artifact_digest` 和 `runtime_revision` 已持久化并进入 desired-state、注册、Host API 和 generation/revision fence。
+- 已验证 `.lbpkg` 先进入 Workspace-scoped durable binary storage;Runtime 本地缓存丢失后可由 Core replay。
+- 相同 digest 的代码和 Runtime 准备的只读依赖环境可以共享,但 worker、运行时写入、配置和数据不合并;
+ dependency preparation 在启动 worker 前完成,失败会进入明确的 installation failed 状态。
+- Cloud shared profile 强制 Linux nsjail;`plugin.worker.require_hard_limits=true` 时 cgroup v2 delegation 不可用会启动失败。
+
+### 3.2 已确认的不变量
+
+1. 每个运行中的 plugin installation 独占一个 worker process tree,任何时刻都不能与其他 installation 共用;
+ 停用或删除的 installation 可以没有进程。
+2. 插件进程只绑定一个
+ `(instance_uuid, workspace_uuid, execution_generation, installation_uuid, runtime_revision, artifact_digest)`,且运行期间不可重绑。
+3. 插件不能通过 payload、Host API 参数、环境变量或重连选择 Workspace。
+4. 插件进程的 home、tmp、可写数据、secret、进程视图和配额必须按 installation 隔离。
+5. 只有 `artifact_digest` 相同且完整性已验证的代码文件和依赖环境可以只读共享;
+ 同名同版本但 digest 不同的 artifact 不能共享。配置、持久数据和运行进程不能共享。
+6. generation、installation revision 或 capability 被撤销后,旧进程必须失去 Host API 和副作用权限。
+7. Supervisor 不在自身解释器中加载第三方插件代码。
+
+### 3.3 首期执行模型
+
+- 整个 SaaS 实例共享一个可信 Plugin Runtime 逻辑控制面,M0 运行一个 Supervisor replica;新 Workspace 不创建专属 Runtime、连接、卷或进程。
+- Supervisor 的控制连接只绑定稳定 `instance_uuid` 和短期 Runtime identity,不绑定某个 Workspace。
+ 每条 installation desired-state 命令都携带并验证完整的 installation binding;每个 worker action context 在注册后永久绑定该 tuple。
+- 安装并启用插件后,Supervisor 在自己的 Runtime 容器内直接启动一个 nsjail 子进程;
+ 不再为每个插件创建 nested container、Pod、sidecar 或租户级 Runtime service。
+- desired semantics 要求 enabled installation 保持 resident,不做 idle eviction;停用、删除、revision/generation 变化或 entitlement 撤销时停止并按需重建。
+ Supervisor 通过 completion callback、带 jitter 的有界指数 backoff 恢复意外退出的 worker。所有 restart launch 共用实例级并发槽;
+ 在配置的失败窗口达到阈值后打开 Runtime 级 circuit breaker,冷却后只允许一个 half-open probe。
+ probe 必须完成初始化并持续稳定一个窗口后才能恢复其他 installation;未在 30 秒内 ready 的子进程会被取消回收。
+ 生产候选环境仍需执行跨租户系统性故障注入,证明熔断、恢复和告警符合预期。
+- 子进程使用一次性 registration capability 向 Supervisor 注册;capability 由可信 desired state 派生并绑定完整 installation tuple,
+ 不是插件直接建立 Core Host connection,也不能只绑定 author/name/path。Supervisor/Core 据此注入 tenant context,
+ 丢弃插件 payload 中自带的 scope 字段。
+- Supervisor 的进程表、nsjail root/tmp 和 artifact cache 都是可重建运行态;PostgreSQL 中的 installation desired state 才是权威业务状态。
+- M0 不增加 Runtime 专用数据库、Redis、Kafka、scheduler 或 artifact service;Core 重连后向 Supervisor replay desired state。
+
+### 3.4 nsjail 和文件边界
+
+首期目标目录模型:
+
+```text
+data/plugin-runtime/
+├── artifacts/sha256//code/ # digest 校验后只读共享
+├── environments/sha256// # 原子发布、只读共享依赖环境
+└── installations//
+ ├── home/ # 私有可写
+ ├── tmp/ # 私有可写、可清理
+ └── data/ # 私有持久数据
+```
+
+- artifact 只有在内容摘要和完整性校验一致时才允许共享,不能只凭 author/name/version 复用目录;
+ cache 可接受的签名/来源、撤销和 GC 规范属于后续发布规则,不改变本轮基于已验证 digest 的只读共享边界。
+- artifact 与按环境摘要构建的共享依赖环境以只读 mount 进入 nsjail;installation 的 home/tmp/data 使用独立可写 mount。
+ 环境摘要包含 artifact、requirements、Python ABI、Runtime 版本和 installer schema。依赖只能从已验证 artifact 的 PEP 508 声明构建,
+ index/trusted-host 只由实例配置控制;构建在独立 nsjail 的临时路径中完成并在成功后原子发布,失败或并发安装不能留下可见半成品。
+- 插件 cwd 可以是其私有 mount namespace 内的只读 `/plugin`,不要求为每个 installation 复制代码;
+ 必须私有的是 home/tmp/data 等所有可写路径。
+- nsjail 必须启用 mount、PID、IPC、UTS 和 private `/proc` 等必要 namespace,插件不能枚举或 signal 其他插件及 Runtime 进程,
+ 不能读取 Runtime 文件系统、宿主机路径、其他 installation 目录或平台 metadata endpoint。
+- 公开 SaaS 禁止从插件 artifact 自动加载 `.env`。secret 只能由可信控制面按 installation 注入,且不能进入共享 artifact/cache。
+- 插件需要外网时使用受控 egress;不得通过共享 host network 访问 Core loopback、Box Runtime、数据库或其他内部服务。
+- Cloud 部署必须提供可用的 cgroup v2 delegation 和所需 namespace 权限;如果硬 CPU/内存/PID 限制不可用,
+ Plugin Runtime readiness 必须失败,不能只记录告警后降级为普通子进程。
+
+### 3.5 统一资源上限与配置
+
+首期资源规格完全由 LangBot 实例配置决定,manifest 不能声明、放宽或覆盖资源。以下数值是建议默认值,
+最终仍由同一实例的 `data/config.yaml` 统一配置:
+
+```yaml
+plugin:
+ worker:
+ max_cpus: 1.0
+ max_memory_mb: 512
+ max_pids: 128
+ max_open_files: 256
+ max_file_size_mb: 512
+ require_hard_limits: true # Cloud; OSS defaults false
+```
+
+配置文件路径为 `data/config.yaml`,沿用现有原生环境变量覆写:
+
+- `PLUGIN__WORKER__MAX_CPUS`
+- `PLUGIN__WORKER__MAX_MEMORY_MB`
+- `PLUGIN__WORKER__MAX_PIDS`
+- `PLUGIN__WORKER__MAX_OPEN_FILES`
+- `PLUGIN__WORKER__MAX_FILE_SIZE_MB`
+- `PLUGIN__WORKER__MAX_CONCURRENT_RESTARTS`
+- `PLUGIN__WORKER__RESTART_FAILURE_THRESHOLD`
+- `PLUGIN__WORKER__RESTART_FAILURE_WINDOW_SECONDS`
+- `PLUGIN__WORKER__RESTART_CIRCUIT_OPEN_SECONDS`
+- `PLUGIN__WORKER__REQUIRE_HARD_LIMITS`
+
+Core 启动时校验配置并通过现有 `SET_RUNTIME_CONFIG` 下发不可变 `PluginWorkerPolicy`。
+Runtime 不读取另一份环境变量配置,避免两个配置源不一致。CPU、内存和 PID 使用 cgroup 硬限制,
+open files/file size 使用 rlimit。Cloud deployment profile 固定使用 nsjail,不能通过插件 manifest 或 SaaS 环境变量降级为普通进程。
+installation data 的总空间硬配额需要 filesystem project quota 或独立 quota volume,不能用目录扫描伪装成硬限制;
+该字段在选定可原子拒绝写入的存储机制前不进入首期配置。
+
+### 3.6 淘汰与暂缓方案
+
+| 状态 | 方案 | 结论 |
+| ---------- | -------------------------------------------------- | -------------------------------------------------------- |
+| 淘汰 | 每 Workspace 一个 Plugin Runtime | 部署、连接和固定内存随 Workspace 线性增长 |
+| 淘汰 | 一个插件进程服务多个 Workspace/installation | 全局状态、本地文件和依赖无法形成可信租户边界 |
+| 淘汰 | 同 Workspace 多插件合并到一个 worker | 与“每 installation 独立进程”冲突,扩大故障和权限边界 |
+| 淘汰 | manifest 自行声明 CPU、内存或更高限额 | 首期统一执行实例级最大值 |
+| MVP 不引入 | Runtime 专用数据库、Redis、Kafka 或独立 scheduler | 当前无容量证据,会增加组件和运维面 |
+| 后续演进 | 多 Supervisor replica、owner lease、dedicated pool | 保留接口,达到容量或可用性阈值后再决定具体存储与调度方式 |
+
+架构扩展项包括:Core/Supervisor 是否共置、artifact/venv cache 的签名/来源/撤销/GC 规范、installation data hard-quota provider、
+v1 connection 的兼容期限,以及进入多 replica 后的 lease TTL、fencing token 和 owner 转移顺序。
+这些不改变“每个运行中的 installation 一个隔离进程”的首期边界。
+
+### 3.7 验收条件
+
+- 两个 Workspace 安装 digest 相同且已验证的 artifact 时,共享目录仍为只读,进程、配置、data、home、tmp、日志和 Host API 完全隔离;
+ 同名同版本但 digest 不同的 artifact 绝不共享目录。
+- 插件不能读取其他 installation 文件、枚举或 signal 其他进程,也不能修改共享代码/依赖目录。
+- CPU、内存、PID、open files 和单文件上限在真实 nsjail/cgroup 环境中生效;超额只终止或拒绝对应 installation。
+- 修改 manifest 不能改变任何资源上限。
+- installation data 的总空间硬配额在写入边界原子拒绝超额,并证明目录扫描不是生产 enforcement。
+- 旧 generation/revision 的回调、消息、副作用和存储访问全部失败关闭。
+- Runtime 重启能从业务 desired state 恢复,不依赖本地进程表作为权威真相。
+- 意外退出的 enabled worker 由 completion callback 触发带有界 backoff 的自动恢复;连续失败只影响对应 installation,不能形成跨租户重启风暴。
+- requirements 中存在 Runtime 基础镜像未预装的包时,Supervisor 仍能先完成共享依赖环境准备再启动 worker;
+ 安装失败不会留下持续重启的半启动进程,也不会影响同 digest 已就绪环境的其他 installation。
+
+## 4. D-002:Box 多租户控制面和套餐边界
+
+状态:`IMPLEMENTED FAIL-CLOSED — production quota provider pending`
+
+### 4.1 已实现的基础
+
+- 共享 Box 控制连接可服务多个 Workspace;所有操作绑定 instance、Workspace 和 generation,Runtime namespace 由可信 context 派生。
+- 短期 `SandboxAdmissionGrant`、revision tombstone 和原子 session admission 强制每个合资格 Workspace 最多一个 `global` persistent session,managed process 固定为零。
+- Core 与 Runtime 使用认证 host-control challenge 校验同一个 durable volume,而不是比较路径字符串;不一致时启动和重连失败。
+- Cloud skill 只传逻辑名称;Runtime 从 Workspace-scoped store 解析只读包路径,Python env/cache 写入租户自己的 `/workspace/.skill-envs`。
+- ZIP 安装限制压缩输入、条目、单项、总解压量和压缩比,采用流式解压并拒绝 link、非普通文件、重复项和路径逃逸。
+- 附件 host path 使用 query UUID 和 dirfd/openat/O_NOFOLLOW;Cloud replica 启动不再全局清理其他请求目录,遍历和删除有 inode 预算。
+- grant-enforced readiness 强制 cgroup、namespace、mount、共享卷、Workspace hard quota、Skill hard quota、ephemeral storage 和 inode quota 全部被证明。
+ 普通 nsjail backend 对尚未实现的硬磁盘能力明确返回 false,因此当前 Cloud Box 会按设计拒绝启动,直到新部署提供真实 quota provider。
+
+### 4.2 首期套餐与 entitlement 模型
+
+- 闭源订阅管理/Control Plane 负责把套餐映射为版本化 entitlement;Core 和 Box Runtime 不硬编码 `plan == pro`。
+- 首期复用 Cloud Control Plane(可结合现有 Space 的订阅模块)承载闭源套餐、计费和 entitlement 投影,
+ 不再拆一个独立 billing/tenant microservice;开源 Core 只实现通用 capability 和数值限额。
+- 首期套餐投影为:Pro 的 `managed_sandbox_sessions = 1`,其他套餐为 `0`。建议 capability 形态:
+
+```json
+{
+ "features": {
+ "managed_sandbox": true,
+ "external_sandbox": false,
+ "mcp_stdio": false
+ },
+ "limits": {
+ "managed_sandbox_sessions": 1
+ }
+}
+```
+
+- `box.enabled` 只表示当前 LangBot 实例是否部署了 Box Runtime,不能替代 Workspace entitlement。
+- 工具发现层根据 entitlement 隐藏/禁用托管 sandbox。Core 校验 Control Plane 的 entitlement 后,
+ 通过受认证控制连接向 Box Runtime 下发短期 `SandboxAdmissionGrant`,绑定
+ `instance_uuid + workspace_uuid + execution_generation + entitlement_revision + expires_at + max_sessions + max_managed_processes`。
+ Runtime 只验证和执行该内部 grant,不理解 Pro 等套餐名称,也不相信业务调用方提交的 plan、session ID 或 host path。
+- entitlement 缺失、过期或无法验证时失败关闭。并发创建必须用原子 admission 保证同一 Workspace 永远不超过一个 managed session。
+- entitlement 被撤销后停止 managed process 并关闭逻辑 session;Workspace 数据保留/删除策略随未来 Workspace 释放机制一并决定。
+
+### 4.3 Cloud nsjail 执行模型
+
+- 整个逻辑 SaaS 实例共享一个 Box Runtime 逻辑控制面,M0 运行一个 Runtime replica;不创建每 Workspace Box service、worker pool、PVC、bucket、scheduler、Redis 或 Box 数据库。
+- Cloud 显式固定 `box.backend: nsjail`。sandbox 直接作为 Box Runtime 容器内的 nsjail 子进程运行,
+ 不创建 nested Docker container、独立 Pod、microVM 或 warm pool,也不挂宿主机 `docker.sock`。
+- 符合 entitlement 的 Workspace 首次使用时懒创建一个逻辑 session,内部固定 ID 为 `global`,并强制 `persistent=True`;
+ 外部调用方不能选择或覆盖 session ID、persistence、host path 或 backend。
+- “全局 sandbox 一直存活”在当前机制中的精确定义是:每个合资格 Workspace 最多一个稳定的 `global` 逻辑 session,
+ 它不被 TTL reaper 回收,其 `/workspace` 持久保存;普通命令仍按需启动并退出 nsjail 进程,不能承诺一个空闲 OS 进程永久驻留。
+- Box Runtime 重启后,旧进程、attach token、root/tmp/home 和内存 session 状态失效;下一次使用时以相同 Workspace namespace 懒重建
+ `global` session。持久 `/workspace` 必须继续存在,旧 generation 权限必须失败关闭。
+- 共享 Box Runtime 采用单 owner 的 M0 实现;未来多 replica 才引入 session owner lease 和跨 replica 路由,
+ 但 session handle 永远不包含 replica 地址。
+- Cloud 首期强制 `network=off`,调用方和 WebUI 不能覆盖。当前 `network=on` 会关闭 nsjail 的独立 network namespace,
+ 不能用于共享 SaaS。未来如需联网,必须先实现每 session 独立 netns 和受控 egress,再单独开放。
+- Cloud 首期禁止 `START_MANAGED_PROCESS`,`SandboxAdmissionGrant.max_managed_processes` 固定为 `0`;
+ 普通 exec 在同一 Workspace 的 `global` session 内串行执行。未来开放 resident process 前必须增加数量和聚合 CPU/内存上限。
+
+### 4.4 文件与资源边界
+
+- 文件机制沿用当前 nsjail 方案:Box-owned durable volume 上的 Workspace 目录只 bind mount 到对应租户的 `/workspace`;
+ 不在首期新增对象存储双向同步服务或文件服务。
+- `/workspace` 的持久性来自独立 durable host path,而不是 `persistent=True`;后者只禁止 TTL/普通 shutdown 回收逻辑 session。
+ Box Runtime 容器必须挂载持久卷,Workspace 数据不能只放在容器可写层。root/tmp/home 可以在 Runtime 重启时丢失。
+- Cloud MVP 要求 Core 与 Box Runtime 以相同路径挂载同一持久卷并沿用直接文件读写;现有 exec/base64 fallback 的单文件上限
+ 低于正常附件上限,不能当作等价 Cloud 文件机制。无法共享路径时必须先扩展传输协议,否则 deployment readiness 失败。
+- 现有执行前后目录扫描只能提供软检查,不能阻止单次命令写满共享卷。生产 Cloud 的 Workspace 总空间上限必须由
+ Box-owned volume 的 filesystem project quota/subvolume quota 在写入点原子执行;Box Runtime 负责设置和验证,不能因 Core 看不到路径而跳过。
+- Cloud 的 nsjail CPU、内存、PID、单文件和总空间限制使用运维配置统一设置;套餐只决定 session 数量,
+ 不允许 Workspace 放宽 sandbox 上限。
+- Box Runtime 必须在 cgroup v2 hard limit、namespace 和 mount 条件满足后才通过 readiness;不能在共享 SaaS 中告警后降级运行。
+- 同 Workspace 的 `global` session 可以复用持久 `/workspace`,但不同 Workspace 即使使用相同文件名、进程名或逻辑 session ID,
+ 物理 namespace、路径、进程和 capability 也必须完全隔离;Cloud 首期没有可暴露端口或共享网络 namespace。
+
+### 4.5 非 Pro 和未来外部 E2B
+
+- 非 Pro Workspace 在首期没有 Cloud managed sandbox,直接调用内部 API 也必须被拒绝。
+- 未来允许 Workspace 在 WebUI 配置自己购买的远程 E2B endpoint/template/secret;它属于 tenant-owned external sandbox,
+ 不消耗 Cloud 的 `managed_sandbox_sessions` 配额,也不能读取其他 Workspace 的凭证。
+- 当前 Box Runtime 只有实例级全局 backend 和 E2B credential,WebUI 也没有 Workspace 级配置,因此 BYOK E2B 明确不在首期实现。
+- 未来实现时在共享 Box Runtime 内增加按可信 Workspace context 选择 backend/provider 的 registry,
+ 仍不创建租户专属 Box 控制面或新协议。
+
+### 4.6 淘汰与暂缓方案
+
+| 状态 | 方案 | 结论 |
+| ---------- | ------------------------------------------------ | -------------------------------------------------------- |
+| 淘汰 | 每 Workspace 一个 Box service | 组件和空闲成本随 Workspace 线性增长 |
+| 淘汰 | 多 Workspace 共享一个活 sandbox/session | 不能承载不可信代码 |
+| 淘汰为 MVP | Docker、独立 Pod、microVM 或 warm pool | Cloud v2 首期固定使用 Runtime 容器内 nsjail |
+| 淘汰为 MVP | 非 Pro 使用 Cloud managed sandbox | 首期数值 entitlement 为 0 |
+| 后续演进 | 多 Box Runtime replica、dedicated pool、BYOK E2B | 保留 provider/ownership 接口,有真实容量或产品需求后实现 |
+
+### 4.7 验收条件
+
+- Pro entitlement 首次使用时懒创建一个 persistent `global` session;重复和并发请求都不能产生第二个 session。
+- 非 Pro、entitlement 缺失/过期及伪造 plan 的 API 直调全部失败关闭。
+- TTL 不回收 persistent session;Runtime 重启后进程和临时目录失效,但 `/workspace` 保留并能在下一次使用时安全重建。
+- 两个 Workspace 的文件、进程、session、attach token 和 generation 完全隔离;network/managed-process 请求在首期失败关闭。
+- Core 与 Box Runtime 通过随机 marker challenge 证明同一共享持久卷;只配置相同路径字符串不算通过。
+- Workspace、Skill store、ephemeral root/tmp/home 的 byte quota 与 inode quota 在写入点真实生效;现有目录扫描不被当作硬配额。
+- cgroup 或任一硬存储能力不可用时 Cloud Box Runtime readiness 失败;普通 nsjail 因此不会被误当成 production-ready provider。
+
+## 5. D-003:SaaS PostgreSQL 与 pgvector
+
+状态:`PARTIALLY IMPLEMENTED — shared schema/pgvector complete; SaaS transaction and deployment gates remain`
+
+当前分支已实现 PostgreSQL shared schema、transaction-local scope、FORCE RLS、Cloud runtime 非 DDL 模式、
+同业务数据库 pgvector、显式向量维度和 tenant-scoped vector 主键。一次性 migrator 使用独立凭据、advisory lock,
+负责建立并校验 runtime role 的最小权限,并完成全量 schema 验证;
+普通业务写入贯穿 commit 的 generation-aware fence、与外部副作用同事务的 outbox,以及 generation cutover 后稳定的 durable object 引用尚未实现。
+这些 Core 事务原语与生产 Job、凭据发放、备份和回滚流程,以及 runtime credential 的跨 database 连接隔离证明一起,
+都是 Cloud v2 的 SaaS activation gate。
+
+### 5.1 已确认的数据库边界
+
+- PostgreSQL 是 SaaS 的业务数据库,不把它扩展成通用 Runtime coordinator、Box session directory 或新控制面数据库。
+- M0 使用一个 PostgreSQL business database/shared schema 承载全部 Workspace。创建 Workspace 不创建 database、schema、role 或专属连接池。
+- 首版 migrator URL 和 runtime URL 必须归一化到同一 host、port 和 database,但必须使用不同 role。
+ 未来如果 migrator 使用 direct endpoint、runtime 使用 pooler endpoint,只能在两个端点都验证同一个数据库内部 cluster identity 后放宽 host/port 相等。
+ 该 identity 由 migrator 所有并固定,runtime role 只能读取,不能创建、修改或伪造。
+- 首版唯一业务 schema 固定为 `public`。migrator 和 runtime 连接都必须满足
+ `current_schema() = 'public'` 且 `current_schemas(false) = ARRAY['public']`;禁止 runtime role 级和 business database 级 `search_path` 覆写。
+- migrator 和 runtime session 的安全值固定为 `session_replication_role=origin`、`row_security=on`、
+ `lo_compat_privileges=off`。runtime role 或当前 business database 作用域内只要存在任意 `pg_db_role_setting` 持久化设置就失败关闭,
+ 即使该设置当前看似等于安全值也不接受;tenant context 只能通过事务内 `SET LOCAL` 建立。
+- 业务行显式携带 `workspace_uuid`;Repository/Service 的应用层 scope 是第一道边界,PostgreSQL RLS 是第二道边界。
+- runtime role 的直接 ACL 固定为:business database 的 `CONNECT`、`public` 的 `USAGE`、全部 allowlisted business table 的
+ `SELECT/INSERT/UPDATE/DELETE`、`alembic_version` 的只读 `SELECT`,以及业务表自有 sequence 的 `USAGE/SELECT`。
+ 不授予 database/schema `CREATE`、table `TRUNCATE/REFERENCES/TRIGGER`、sequence `UPDATE`、其他对象权限或任何 `WITH GRANT OPTION`。
+- runtime role 必须是 `LOGIN`,但不得具有 superuser、`BYPASSRLS`、`CREATEDB`、`CREATEROLE` 或 replication 属性;
+ 不得在 role membership 中以 granted role、member 或 grantor 任一方向出现;不得拥有 database、schema、table、view、sequence、routine 或 extension,
+ 不得持有 column ACL,也不得使用、创建或拥有其他非系统 schema。
+- business database 必须安装 `vector`,且 extension catalog 只允许 `plpgsql` 和 `vector`;不得存在 FDW、foreign server 或 user mapping。
+ runtime role 和 `PUBLIC` 都不得有显式 routine ACL 或 parameter `SET/ALTER SYSTEM` ACL;runtime role 不得有效执行任何
+ `SECURITY DEFINER` routine,包括被 allowlisted extension 收编的 routine。普通非 `SECURITY DEFINER` 内建函数的隐式执行权限不在此禁令内。
+- PostgreSQL 新 database 默认向 `PUBLIC` 提供的 `TEMP` 是首版在专用业务 database 上明确接受的兼容性决定,
+ 不是 migrator 对 runtime role 的直接 grant;首版不得据此把业务 database 与不受信任工作负载混用。
+ migrator 在释放 advisory lock 前建立并校验上述精确 allowlist;Cloud runtime 每次启动都必须重新完成 schema、身份、有效权限和 catalog 负向校验,发现 drift 立即失败关闭。
+- PostgreSQL role 是 cluster-wide identity,当前 database 内的 catalog audit 不能证明同一 credential 无法连接 cluster 中的其他 database。
+ SaaS 生产环境必须使用仅向该 credential 暴露目标 business database 的专用 PostgreSQL cluster/endpoint,
+ 或通过已验证的 HBA/proxy policy 证明该 credential 只能连接目标 business database;这项部署隔离仍是未完成的 activation gate。
+- 关键租户表使用 `FORCE ROW LEVEL SECURITY`;migration/repair/audit 使用独立受控 migrator role。
+- 每个租户事务通过 `SET LOCAL` 设置 tenant context,并由统一 `TenantUnitOfWork` 保证设置 context 和业务查询使用同一事务/连接。
+ 禁止使用连接级 session variable 或 `search_path`,避免连接池、PgBouncer、异常回滚和后台任务串租户。
+- 一个 `TenantUnitOfWork` 只能访问一个 Workspace。业务写入与对应 business outbox 在同一事务中提交;
+ 写入可以校验由执行层传入的 generation/fencing token,但 Runtime owner、lease 和 Box session directory 不由业务 PostgreSQL 承担。
+- SaaS schema、extension 和 policy 只由 release migration job 创建;应用启动角色不执行 `CREATE EXTENSION`、`create_all` 或自动 migration。
+- OSS 继续默认 SQLite,并保留自托管 PostgreSQL 选项;Cloud RLS 约束不让 OSS 强制依赖 PostgreSQL。
+
+### 5.2 pgvector 首期方案
+
+- SaaS 使用 pgvector 作为默认向量数据库,并与业务表使用同一个 PostgreSQL cluster/database;
+ vector schema 可以使用独立 adapter、受控 role 和有上限的 pool,但不新增 Chroma、Milvus 或独立向量数据库服务。
+- OSS 默认仍是 SQLite + Chroma,用户可以显式选择 pgvector;Cloud 配置 pgvector 失败时必须启动失败,不能静默回退到 Chroma。
+- 向量表必须显式保存 `workspace_uuid` 和 `knowledge_base_uuid`,并至少以
+ `(workspace_uuid, knowledge_base_uuid, vector_id)` 建立唯一键/主键和查询条件;服务端生成的 collection name/hash 不是安全边界。
+- pgvector adapter 复用相同的 tenant-context/RLS 契约,每次向量操作在自己的事务中执行 `SET LOCAL`;
+ 是否复用普通业务 UoW、role 或 connection pool 由实现决定,但 adapter 不能丢弃 tenant metadata。
+- `vector(1536)` 不能继续作为无条件硬编码。首期使用无 typmod 的 `vector` 列和显式 `embedding_dimension`,
+ 以 `CHECK (vector_dims(embedding) = embedding_dimension)` 校验;release migration 为允许的维度创建带 dimension predicate 的 expression/partial ANN index。
+ 知识库/model 元数据必须选择已启用维度,写入和查询 mismatch 或未启用维度时失败关闭,不能截断、补齐、退化为无界扫描或换后端。
+- `vector` extension、表、索引和 RLS 由 release migration 创建。应用进程不在启动时执行 DDL。
+- 0013 如需搬迁 legacy pgvector 数据,migrator 作为源表 owner 先记录每个受保护源表的 `ENABLE/FORCE RLS` 状态,
+ 仅在同一 migration transaction 内临时暂停 RLS,并在 `finally` 中精确恢复各表原状态。
+ 该流程不依赖 superuser 或 `BYPASSRLS`,也不允许在迁移事务外留下已禁用的 RLS。
+
+### 5.3 候选拓扑与未来演进
+
+| 状态 | 方案 | 结论 |
+| -------- | ----------------------------------------- | --------------------------------------------------------------------------- |
+| 首期决定 | P0. shared database/shared schema | 一个 pool、一套 migration;应用 scope + RLS |
+| 后续演进 | P1. 多 shared database shard | 每个 shard 仍承载多个 Workspace,并使用相同 schema;有容量/地域证据后再设计 |
+| 后续例外 | P2. dedicated shard | 只作为合规、驻留或超大 workload 的资源等级,不建立第二套代码路径 |
+| 淘汰 | schema/database per Workspace | catalog、pool、migration、备份成本随 Workspace 线性增长 |
+| 淘汰 | database/schema per replica/Cell/Instance | 把业务数据拓扑错误绑定到计算副本或已删除的产品实体 |
+
+M0 不提前增加始终返回 `primary` 的 resolver、shard router 或 shard binding。
+P1 的 resolver、映射、在线迁移、连接池预算、shard-affine replica 和 dedicated shard 细节等到出现容量、地域或合规需求时再设计。
+在此之前,direct endpoint 与 pooler endpoint 分离只能通过数据库内部、runtime 不可伪造的 cluster identity 开启,不使用 DNS 名、数据库名或配置声明代替。
+
+### 5.4 备份与生命周期边界
+
+- PostgreSQL PITR 是 database/cluster 级恢复手段,不等同于单 Workspace 恢复。
+- Workspace 创建、释放、export、delete、单 Workspace restore 和在线迁移机制本轮暂缓,后续单独决策;
+ 首期不以尚未设计的 export 能力作为数据库架构验收条件。
+- 除关系业务数据和 pgvector 的向量/检索字段外,大对象、插件 artifact 和 sandbox 文件仍存放在对象存储或 Runtime 持久卷;
+ PostgreSQL 保存相应业务元数据和稳定引用。
+- tenant-visible usage/billing 业务行可以进入 PostgreSQL;基础设施 log/metric/trace 不进入业务数据库。
+ 高增长业务表在有数据量证据后再决定 retention、时间分区或分析存储。
+
+### 5.5 验收条件
+
+- 故意遗漏应用层 Workspace filter 时,RLS 仍阻止跨租户读写。
+- 连接池/事务池复用、异常回滚、并发请求和后台任务不会残留 tenant context;如部署 PgBouncer,也必须覆盖 transaction pooling。
+- migration 对 shared schema 只执行一次,不产生 Workspace 级 schema drift;应用启动角色不能执行 DDL。
+- 首版拒绝 host、port 或 database 不同的 migrator/runtime URL,并拒绝相同 role;migrator/runtime 都只解析到 `public`,
+ migrator 在迁移后完成精确 table/sequence/`alembic_version` ACL grant 和正反向 role 校验,runtime 每次启动重新校验。
+- runtime role 没有任一方向的 role membership、`WITH GRANT OPTION`、`search_path` 覆写、对象所有权、其他 schema 访问或非业务对象权限;
+ 专用业务 database 上可继承 PostgreSQL 默认 `PUBLIC TEMP`,但 runtime role 没有直接 `TEMP` ACL。
+- migrator/runtime session GUC 保持 `session_replication_role=origin`、`row_security=on`、`lo_compat_privileges=off`,
+ runtime role/当前 database 没有任何 `pg_db_role_setting`;extension 仅为 `plpgsql/vector` 且 runtime 不拥有 extension,
+ database 中没有 FDW/server/user mapping、runtime 或 `PUBLIC` 显式 routine/parameter ACL、runtime-owned routine 或 runtime 可执行的 `SECURITY DEFINER` routine。
+- 生产 deployment 证明 cluster-wide runtime credential 只能连接目标 business database;专用 cluster/endpoint 或 HBA/proxy 隔离未经验证前不得启用 SaaS。
+- legacy pgvector 搬迁在非 superuser、非 `BYPASSRLS` 的 table-owner migrator 下可成功,成功、异常和重试路径都精确恢复所有源表的 RLS/FORCE 状态。
+- 业务写入和对应 business outbox 在同一事务内具备可证明的提交顺序;外部 generation/fencing token 校验失败时不产生写入。
+- pgvector 使用真实 PostgreSQL 集成测试覆盖:两个 Workspace 使用相同 `vector_id`、猜测其他 Workspace ID、
+ 故意遗漏 scope、连接复用、CRUD 和后台任务,全部不能越权。
+- embedding dimension 不匹配或 pgvector extension 不可用时失败关闭,不回退到其他向量后端。
+- 新建 Workspace 只新增目录与业务行,不创建 database、schema、role 或专属连接池。
+
+## 6. D-004:stdio MCP 独立开关
+
+状态:`IMPLEMENTED`
+
+### 6.1 已修复的原问题
+
+- 修复前,stdio MCP 的启用条件只检查 transport 为 `stdio` 且 Box available,没有独立 feature gate。
+- 修复前,所有 stdio MCP 使用固定的 `mcp-shared` 逻辑 session,并强制 `persistent=True`。
+- 在该旧逻辑下,如果多租户 Cloud 只通过 `box.enabled` 开放能力,每个配置 stdio MCP 的 Workspace 都会额外保留一个 persistent sandbox,
+ 绕过“每 Workspace 最多一个 managed `global` sandbox”的成本和套餐边界。
+
+### 6.2 首期决定
+
+新增独立实例配置:
+
+```yaml
+mcp:
+ stdio:
+ enabled: true
+```
+
+- OSS 默认 `true`,保持当前本地部署兼容;Cloud v2 通过 `MCP__STDIO__ENABLED=false` 强制关闭。
+- 该开关与 `box.enabled`、`managed_sandbox` entitlement 和 sandbox session 数量相互独立,不能从任一条件推导。
+- 后续如开放给特定套餐,可在实例开关之上再叠加 Workspace capability;实例开关为 `false` 时任何 entitlement 都不能绕过。
+- HTTP/SSE/其他远程 MCP transport 不受此开关影响。
+
+### 6.3 强制检查点
+
+开关必须同时覆盖:
+
+1. MCP create;
+2. MCP update 到 stdio;
+3. transient connection test;
+4. Core 启动时加载已有 stdio 配置;
+5. RuntimeMCPSession/loader 的最终执行门禁;
+6. WebUI transport selector 和错误提示。
+
+不能只在 WebUI 隐藏选项。Cloud 配置关闭时,已有 stdio 记录保留但不自动启动,并返回明确的 feature-disabled 错误;
+最终 gate 必须位于 Box 分支和 legacy host-stdio 分支之前,不能误报为 `box_unavailable`,
+也不得创建 `mcp-shared` session 或 stdio 子进程。
+
+### 6.4 验收条件
+
+- Cloud 即使 `box.enabled=true` 且 Workspace 拥有一个 managed sandbox,也无法 create/update/test/start 任何 stdio MCP。
+- 直接调用 API、重放旧配置和启动 bootstrap 都失败关闭,且不会产生 `mcp-shared` session、nsjail 进程或额外配额占用。
+- OSS 默认行为保持兼容;HTTP/SSE MCP 正常工作。
+
+## 7. 五项决策之间的关系
+
+五项决策共同遵循:
+
+> 多租户共享可信控制面、连接池、只读 artifact 和基础容量;租户独占不可信执行进程、sandbox、secret、可写文件和数据作用域。
+
+- Plugin Runtime 通过“共享 Supervisor + 每 installation 独立 nsjail 进程”降低控制面数量,同时保留进程级租户隔离。
+- Box 通过“共享 Runtime + 每个合资格 Workspace 一个持久逻辑 session + one-shot nsjail exec”避免每租户部署服务和空闲容器。
+- stdio MCP 独立关闭,防止从 Box availability 隐式产生第二套 persistent sandbox。
+- PostgreSQL 和 pgvector 共享数据库组件,但使用显式 tenant key、应用层 scope 和 RLS 防止共享存储变成共享权限。
+- 订阅管理只在闭源 Control Plane 维护套餐与计费规则,并向开源 Core 投影签名/版本化 entitlement;
+ Core/Runtime 执行通用 capability 和数值限额,不复制套餐名称或计费逻辑。
+- 目录同步只在启动或恢复时读取全量快照;常态变更按 Workspace 聚合为签名增量,新增租户不会使每次目录事件退化为全实例重投影。
+
+## 8. 当前不做分布式时仍保留的能力
+
+1. 所有运行时协议继续携带稳定 `instance_uuid`、`workspace_uuid` 和 execution generation;不能依赖进程地址表达身份。
+2. Core、Plugin Runtime 和 Box Runtime 的本地进程表不能成为 durable desired state 或撤销状态的唯一真相。
+3. 创建、重试、回调、outbox 和 worker 注册使用稳定 idempotency key;重复投递不能产生第二个 owner 或副作用。
+4. Plugin installation 和 Box session 使用稳定 owner abstraction;启用第二个 replica 前再实现带 expiry、CAS 和 fencing token 的 lease。
+ 具体 lease store 后续决定,不预设复用业务 PostgreSQL,更不因此新增 Runtime/Box 数据库。
+5. Repository/UoW 不允许无边界跨 Workspace 事务;`workspace_uuid` 从第一天就是内部路由与分片候选键。
+6. schema migration、任务扫描、监控聚合和运维接口不能假设永远只有一个 Core 进程。
+7. 外部 API 不暴露 replica、worker 或 shard 标识;未来扩容不改变 Workspace URL、UUID 或客户端协议。
+8. 只有出现容量、可用性、地域或合规需求时才增加 replica/shard;预留协议不等于现在部署额外组件。
+9. 每个 Core replica 保存自己的事件消费 cursor,因为 entitlement cache 是进程本地状态;PostgreSQL 中的 projection high-water mark
+ 、snapshot coverage 和 inbox 仍由所有 replica 共享,用于幂等投影和冲突检测。事件页携带签名 high-water,副本追平前不能续期
+ ready;不能让一个 replica 的共享 cursor 使其他 replica 跳过本地 cache 刷新。
+
+## 9. 本轮明确不做的事情
+
+- 不合并 plugin installation 进程,即使插件和版本完全相同;只允许共享摘要校验后的只读代码/依赖文件。
+- 不允许插件 manifest 声明或覆盖 CPU、内存、PID、文件或存储上限。
+- 不为每个 Workspace 创建 Plugin Runtime、Box service、database、schema、role、bucket、PVC 或消息队列。
+- 不在 Cloud v2 首期使用 Docker sandbox、microVM、warm pool 或非 Pro managed sandbox。
+- 不在首期实现 Workspace 级 BYOK E2B WebUI 配置。
+- 不在 Cloud v2 首期支持 stdio MCP,也不让 Box availability 隐式开启它。
+- 不把业务 PostgreSQL 用作未决定的 Runtime/Box 通用协调数据库,不在缺少容量证据时引入 Redis、Kafka 或新 scheduler。
+- 不在本轮实现 Workspace export、释放、单租户恢复或在线迁移;具体生命周期另行决策。
+- 不实现多个 CloudInstance、Workspace Placement 或 Cell Router;未来分布式只作为单逻辑实例内部的副本和分片能力。
+- 不修改旧 Space 部署模型;Cloud v2 继续按绿地方案设计。
diff --git a/docs/multi-tenant/runtime-resource-audit-2026-07-28.md b/docs/multi-tenant/runtime-resource-audit-2026-07-28.md
new file mode 100644
index 000000000..cbf9fc8f2
--- /dev/null
+++ b/docs/multi-tenant/runtime-resource-audit-2026-07-28.md
@@ -0,0 +1,348 @@
+# LangBot Cloud Runtime 资源安全审查
+
+日期:2026-07-28 至 2026-07-29
+
+审查分支:
+
+- LangBot:`feat/multi-tenants`,审查起点 `32abbb636f4455e965141d8d209b359dbfbb5aae`
+- Plugin SDK:`feat/multi-tenants`,审查起点 `0cddf3c2bea5939c67b71e488a719e9903c28d17`
+
+## 结论
+
+本轮已覆盖 LangBot Core、Plugin Runtime 和 Box Runtime 的主要常驻对象、后台任务、队列、网络客户端、进程生命周期及数据库连接池。本轮定位到的攻击者可控或历史累积状态均已补充容量、超时、淘汰或确定性清理边界;修正后的高基数探针没有观察到随历史请求继续增长的活跃缓存。按本轮“代码审查 + 本地可重复测试”的验收口径,审查已经完成,未发现仍未处理的严重内存泄漏或 CPU 抢占路径。该结论不等于证明任意生产负载下不存在资源问题。
+
+最终 Cloud 拓扑和生产环境不是本轮完成条件。代码级审查、跨仓全量测试和仓库 Dockerfile 构建的 Linux/cgroup v2 探针已经通过,但当前状态仍不能单独作为 Cloud 生产激活批准。完整的环境侧剩余清单见
+[Cloud v2 仍待验证事项](./cloud-v2-pending-verification.md)。其中与本轮资源审查直接相关、上线前还必须完成的项目包括:
+
+1. 在最终 Cloud 部署权限和 cgroup 拓扑下重复 nsjail、namespace 和 delegated cgroup v2 的 CPU、内存、swap、PID、文件句柄验证。本轮一次性 Linux 容器已经证明代码路径可工作,但普通容器和仅 `--privileged` 的 private cgroup namespace 都不满足条件。
+2. 为 Cloud Box 提供并验证硬文件系统 quota provider。普通 nsjail bind mount 不能证明总字节数和 inode 硬配额,当前严格 readiness 按设计会失败关闭。
+3. 使用最终生产配置分布继续做容量测试,并据此确定单实例 Workspace placement 上限。本轮真实 PostgreSQL 16 + RLS 启动测试已经覆盖 1,000 个各带 Provider、三类 Model、Bot、Pipeline、KnowledgeBase、MCP 和 Plugin setting 的 Workspace,启动加载耗时和 SQL 次数保持线性;5,000 Workspace 的合成三代替换探针也证明旧运行时会释放。仓库已新增可同时采集 Core/Plugin/Box HTTP、进程树和 cgroup v2 的 24 小时门禁工具,并在受 CPU、memory、swap、PID 硬限制的 Linux 容器中完成短时自检;但最终生产候选拓扑的 24 小时运行仍未执行。测试中的 fake adapter/requester/Plugin handler 仍不能替代真实平台 SDK、外部连接池和插件进程的容量数据;合法活跃租户本身仍会线性占用内存。
+SDK 已先行发布到分支提交 `1d65ed301a6afc52150a998043f73cd6032c8162`,本提交集中的 LangBot
+`pyproject.toml` 和 `uv.lock` 已精确钉住该提交。最终镜像仍需按待验证清单记录并核对实际安装版本。
+
+## 覆盖范围
+
+### LangBot
+
+- 启动、停机、全局任务管理和运行时配置。
+- PostgreSQL、Tenant UoW、RLS、迁移和共享 pgvector。
+- HTTP、MCP、WebSocket、上传下载、S3/本地存储、维护任务和异步用户任务。
+- QueryPool、Pipeline Controller、会话/对话、限流、第三方 Agent/LLM runner 和同步 SDK 桥接。
+- PlatformManager 以及 DingTalk、QQ、Lark、WeCom、WeComCS、WeChatPad、LINE、Kook、Satori、OpenClaw Weixin、Telegram、Discord、Matrix、HTTP/WebSocket 等适配器。
+- Plugin Runtime connector、插件包校验、Marketplace 下载、pip 安装输出和 desired-state reconcile。
+- Box connector、admission、session/process 生命周期和 RPC 文件。
+- RAG、向量后端、Skill、Storage、Telemetry 和日志缓存。
+
+### Plugin SDK
+
+- stdio/WebSocket transport、请求 waiter、action task 和文件传输。
+- Runtime control handler、Workspace/generation fence 和 EventContext。
+- 插件 artifact、Marketplace、pip 依赖安装、共享依赖环境、installation desired state、Supervisor 和 worker launcher。
+- nsjail 参数、cgroup/rlimit、进程注册 capability 和 Runtime shutdown。
+- Box admission、generation fence、session/process/reaper、RPC/relay WebSocket 和 nsjail backend。
+
+## 主要修复
+
+### 确定性生命周期
+
+- 修复 `Application.shutdown()` 使用 `contextlib.suppress` 却未导入 `contextlib` 的问题。原行为会在实际资源关闭分支直接 `NameError`,阻断后续 Plugin、Box、HTTP 和数据库释放。
+- `make_app()` 在任一启动 stage 或初始化失败时会关闭尚未返回给 `main()` 的半构建 Application;Telemetry、Box、Tool、Platform、Vector 和 HTTP manager 在初始化前即挂到 Application,避免初始化中途失败后清理器无法发现已经创建的连接、会话、子进程或后台任务。
+- MCP streamable-HTTP session manager 现在随 Application shutdown 显式退出。
+- MCP loader 按 `(instance, workspace, generation)` 管理 host task 和 session;任务完成会从注册表移除,代次推进会取消旧 host task 并关闭旧 session,reload/shutdown 会先清空现有运行时,避免 completed task 和旧代连接永久驻留。
+- Platform bot reload/remove/shutdown 统一串行化,旧 bot、代理、adapter 任务和进程会先停止再从注册表移除。
+- Model provider requester 新增异步关闭契约;provider reload/remove、Workspace generation 替换、全量 reload 和 Application shutdown 都会确定性关闭旧 requester,允许第三方 requester 安全持有自己的 HTTP client 或连接池。
+- Plugin Runtime、Box Runtime、stdio transport、adapter 连接和共享 HTTP client 均补齐 close/cancel/await。
+- HTTPX 有界流在超限异常或消费者取消时会立即关闭底层响应流;原来的 response hook
+ 只在正常读完后由 HTTPX 自动关闭,持久客户端反复收到超大响应时可能积累未释放连接。
+ 已消费响应在超限分支也会先 `aclose()` 再传播错误。
+- `Application.dispose()` 只允许一个可追踪 shutdown task;重复的信号、窗口关闭或调用方清理不会铺开多个并行停机流程。
+- Lark、微信、钉钉、企业微信和 QQ Official 的凭证交换后台任务统一进入 Application TaskManager,受全局/单 Workspace admission 约束并随应用停机取消;容量满时关闭尚未调度的 coroutine 并返回 429,不留下游离 task。
+- `TaskCapacityError` 已下沉到无 Application/controller 依赖的纯错误模块。原来的 HTTP 过载异常路径会在特定冷启动导入顺序下触发 TaskManager/controller 循环导入,把应返回的 429 变成框架 500。
+- S3 storage provider 在初始化失败或 Application shutdown 时关闭 botocore HTTP connection pool;Storage manager 在 provider 初始化前即挂到 Application,避免 bucket probe 失败后遗留 client。
+- 修复 Coze runner 每次请求创建 `aiohttp.ClientSession` 却不关闭的问题;现在 runner 的 `aclose()` 会确定性关闭底层 API client。
+- LINE SDK client 现在随 adapter 停止关闭;Plugin Runtime shutdown 回调只创建一个可追踪、可等待的后台任务,重复回调不会累积清理 task。
+- SDK `lbp publish` 现在用上下文管理器关闭插件包上传文件;原实现的成功、API 错误和 HTTP 错误返回路径都会遗留文件句柄。
+- Plugin worker、Box Docker/CLI backend、Box nsjail backend 和 nsjail 依赖安装在调用方取消时会 terminate/kill、读取管道并 `wait()` 回收子进程;Windows worker 的原生进程路径也进入相同的 `finally` 清理契约,避免取消安装、停机或超时后留下孤儿进程。
+- Box 服务入口现在从 Runtime initialize、aiohttp app/runner setup、端口 bind 到主循环共用一个外层清理边界;建站期间的非 `OSError` 也会关闭 Runtime 和 reaper。WebSocket 控制模式端口绑定失败会退出并交给编排器重启,不再在没有任何可用 RPC/health 端口时永久等待;stdio 模式仍允许仅 relay 绑定失败后继续控制通道。
+- Plugin artifact 解压、installation staging/activation/rollback/delete、共享依赖环境和 nsjail session 目录的关键文件系统变更均移出事件循环。已经开始的原子变更在取消时会先等待线程结束,再回滚临时目录、目标目录或旧 supervisor,不会让后台线程继续修改一个调用方已经认为清理完毕的路径。
+- Core 和 SDK 的阻塞 executor 新增独立的有界清理入口。普通工作在容量耗尽时仍快速拒绝;已经拥有资源的 close/unlink/rmtree/进程回收则等待有限 worker 槽位并保证原子操作结束后再传播取消,避免过载恰好导致清理任务被拒绝。SeekDB、Milvus、S3、LINE、WeChatPad、MCP staging 和 TBox 临时文件等关键路径已接入。
+
+### 有界队列、缓存和历史状态
+
+- QueryPool 同时限制全局与单 Workspace 的 queued/running query;调度后不再被过载淘汰;历史 scope counter 有上限。
+- Session、Conversation、WebSocket connection/proxy/message、rate-limit identity、task record/log、telemetry task、vector handle 和 adapter 私有队列均有容量或 LRU/TTL。
+- SessionManager 现在维护 Workspace 二级索引和带 revision 校验的最小过期堆。新会话只扫描目标 Workspace 的有界会话集,TTL 回收只消费已过期堆前缀,全局 idle 淘汰使用最小堆;高频命中产生的旧堆项按活跃会话的有界倍数压缩。原实现会在每个攻击者可制造的新 launcher id 上扫描并排序实例全部会话。
+- SDK 的 EventContext 和依赖准备锁使用 weak reference;generation、admission、installation、capability 和 completed-process 状态有上限。
+- Plugin restart circuit 打开期间只有 `max_concurrent_restarts` 个 supervisor
+ 能持有冷却计时器/状态等待,其他 installation 睡眠在同一个 semaphore FIFO;
+ probe 状态变更在调用者取消时仍会完成,避免 half-open 永久占用。
+- Box nsjail 启动时只扫描一次 `/proc`,并流式删除遗留 session 目录;不再为每个
+ 遗留目录重复扫描全部进程或先把全部目录物化到内存。
+- Box Skill discovery、目录列表和列表正文分别限制扫描 entry、package、返回 entry
+ 与累计文本字节;BFS 使用 deque,拒绝 inode 洪泛导致的 O(N²) 或无界列表。
+- Core message aggregation 使用 `(instance, workspace, generation)` O(1) scope
+ counter 做准入,不再为每个新 launcher 扫描全实例 buffer。
+- Cloud remote MCP 的 idle execution fence 不再由每个 session 每 5 秒查询
+ Workspace/ExecutionState;签名目录投影事务提交后将失效 scope 合并进一个有界
+ cleanup worker。实际工具/资源调用前后仍保留数据库强校验。
+- 空 Workspace 不再预分配 Model generation scope、Plugin installation set 或 Box generation event;只有 Workspace 实际拥有对应运行时资源或等待任务时才创建这些对象。
+- Runtime RPC 文件同时限制单文件字节数和单连接未消费文件数量,连接关闭时清理连接拥有的临时文件。
+- Box Runtime 维护实例与 Workspace 到活跃 session 的二级索引;创建、删除、过期、撤销和 shutdown 共用同一清理路径,避免每个租户 RPC 都扫描实例中的全部 session。
+- Box Runtime 另外只索引“可过期 session”和“持有 managed process 的 session”。Cloud 的持久 `global` sandbox 不进入 TTL 索引,managed process 被禁用时进程索引为空;session 创建、周期 reaper、状态和 `/healthz` 因此不会随全部持久租户数线性扫描。测试把总 session 字典替换为禁止迭代的映射,仍能完成第二个持久 session 创建、reap、status 和 health。
+- Core MCP loader 同样维护 Workspace/generation 到 session、host task 的二级索引;请求、代次回收和动态配置不再扫描实例中的全部租户 MCP session,已完成 task 的 done callback 会同步移除所有索引。
+- Box admission 过期回收使用带 revision/generation 校验的最小堆,只访问已到期记录;重复续期产生的旧堆项会被忽略,堆大小超过活跃 grant 的有界倍数时压缩,不再在每次 RPC 上全表扫描所有租户 grant。
+- 旧 QQ message ID/object cache 和 stdio MCP Workspace copy lock 不再随历史请求无限增长。
+- LLM/Agent runner 的单次生成结果默认限制为 1 MiB,流式传输限制单事件 1 MiB、单请求累计 16 MiB,并限制最多 100,000 个流式事件,避免上游异常响应无限占用内存或 CPU。
+- Marketplace JSON 限制为 1 MiB、插件包限制为 64 MiB;pip stdout/stderr 各最多保留 1 MiB,超出部分继续 drain 但不驻留内存。
+- Plugin Runtime stdio/WebSocket 协议除 16 MiB 消息字节上限外,新增最多 4,096 个入站和出站碎片的对象数量上限;大消息的 UTF-8 编码、分片、拼接、JSON 编解码和 Pydantic 验证均在线程执行。WebSocket receive 异常使用稳定的 `ConnectionClosed` 类型,不再因库顶层未导出 `exceptions` 属性而在错误路径二次失败。
+- HTTPX response hook 和 aiohttp 有界读取统一把第三方响应限制为 10 MiB;JSON 解析及诊断文本转换在线程执行,错误正文最多保留 4 KiB,避免大 JSON 在共享事件循环集中解析。
+- 图片、data URI 和平台媒体默认限制为 10 MiB,Base64 在解码前先校验编码长度;Plugin binary storage 默认单值 10 MiB,并设置不可由错误配置绕过的 64 MiB 绝对上限。
+- Skill 文本单文件限制为 1 MiB,Plugin UI 文件限制为 4 MiB,host edit 文件限制为 1 MiB;Box Skill ZIP、插件 artifact 和 GitHub Skill archive 同时限制条目数、单文件、解压总量和压缩比。
+- SDK E2B 文件同步限制为最多 2,048 个目录项、1,024 个文件、单文件 10 MiB、总计 50 MiB;同步文件 IO 从事件循环移到线程。
+- Dify 待提交表单、用户 Space OAuth state 和 Cloud launch JTI replay cache 改为带 revision 校验的最小过期堆;Space credits 使用按时间有序的 LRU/TTL 队列。原实现会在每次攻击者可触发的请求上扫描整个历史缓存并在满容量时再次线性寻找最旧项;现在过期回收为摊销 `O(log N)` 或仅消费已过期前缀,旧堆项会忽略并按活跃状态的有界倍数压缩。
+- Cloud launch JTI cache 达到 4,096 个仍有效 token 时失败关闭,不再为了接纳新 token 淘汰仍有效的 replay 记录;否则攻击者可以在容量满后重放被提前遗忘的合法签名。
+- Entitlement resolver 现在跟随 Cloud directory 的权威 Workspace 活跃集合。全量目录投影会丢弃已 fenced/removed Workspace 的历史 entitlement snapshot,delta 批量更新不会对每个变化重复扫描;provider 请求进行中发生目录撤销时,返回前的第二次 active fence 会阻止旧结果重新写回缓存。
+- Cloud directory 的签名响应、Workspace、membership 和实例 active Workspace
+ 均新增可配置操作上限与绝对上限。闭源适配器按流读取响应并在 JSON/JWS 解析前
+ 拒绝超过 32 MiB 默认值的解压后正文;Manifest/entitlement/event endpoint 再
+ 分别限制为 256 KiB、256 KiB 和 2 MiB。entitlement 刷新最多并发并驻留 16 个
+ 原始响应,逐批验证成小型 snapshot 后释放;Core 在目录投影行锁保护的事务内检查最终
+ active 数量,超限时回滚 Workspace、Account、membership、inbox 和 cursor,不会
+ 截断权威数据或让并发副本各自越过最后一个容量槽。
+- Space 全量目录只投影 active Workspace,历史 archived Workspace 只在最多 100 个
+ 目标的签名 delta 中作为 tombstone 返回。注册创建新个人 Workspace 前通过
+ PostgreSQL transaction advisory lock 串行执行全局 active 数量准入;达到上限
+ 返回 503,避免多个 Space 副本同时观察到最后一个空位。
+- Monitoring 分页、offset、CSV export 和 session/message detail 均在 service
+ 边界执行实例配置上限与不可放大的绝对上限;detail 的完整统计改为 SQL aggregate,
+ 只物化有界的 tool/LLM/error 明细并显式返回 `detail_truncated`。默认分页 1,000、
+ export 10,000、detail 2,000,绝对上限分别为 5,000、50,000、10,000。
+- Token statistics 的时间序列不再把筛选范围内的全部 LLM call 拉回 Python 分桶;
+ PostgreSQL 使用 `date_trunc`、SQLite 使用 `strftime` 在数据库中聚合,并只返回
+ 最近 1,000 个时间桶(绝对上限 10,000)。模型分组复用分页上限并在 SQL 中按 token
+ 排序、限制;两类结果都返回显式的 `*_truncated` 标志。
+- Monitoring 过期数据每表每轮默认最多删除 4 个批次、绝对最多 100 个批次;本地/S3
+ 过期上传文件候选和每轮删除默认最多 1,000、绝对最多 10,000。单个历史数据量异常的
+ Workspace 不再能让一次维护循环无限物化候选或持续清空全部 backlog。
+- Workspace webhook 数量默认限制为 16、绝对限制为 64;管理查询和运行时 fan-out
+ 都只物化有界结果。实例同时发送的 webhook 请求默认限制为 16、绝对限制为 128;
+ 满载时直接跳过未获准目的地,不创建一批等待 semaphore 的 task。取消调用时会取消并
+ await 已创建的所有请求任务,归还实例槽位。
+- Local/S3 Storage 的对象读取在实际 IO 中只读取 `limit + 1` 字节,S3 body 在成功、
+ 超限和异常分支都会关闭;默认单对象 10 MiB、绝对上限 64 MiB。所有 scoped load
+ 以及 WebSocket attachment 都经过同一边界,写入也不能产生当前实例无法安全读取的对象。
+- Valkey Search 的批量删除改为固定页流式搜索、删除并累计计数,不再把全部匹配 key
+ 保留在 Python 列表;每次删除后从 offset 0 继续,避免结果集缩短造成跳项,并设置
+ 1,000 轮绝对终止条件。
+
+### CPU 和事件循环保护
+
+- 修复旧 QQ `repeat_seed('')` 空输入无限循环。
+- ZIP 校验/重打包、PIL、Base64、AES、JSON 解析、fsync probe、插件 artifact/依赖文件、Skill、S3、本地存储和维护目录扫描从事件循环移到线程。
+- 公开 Slack、QQ Official、HTTP Bot、公众号、WeCom/WeComCS 回调体显式限制为 1 MiB;JSON/XML 解码移出共享事件循环。QQ、DingTalk、Satori、WeCom AI 和 WeChatPad 网关帧同样设置 1 MiB 上限或在解码前拒绝超限消息;KOOK zlib 数据使用 10 MiB 解压后硬上限,阻断小压缩包制造的大内存解压。
+- HTTP Bot 的幂等键和 outbound session 现在均在写入前执行硬容量 admission;满额时只按固定 64 项预算检查最旧记录,不能先超限后整体清空,也不再在每条回复上扫描和排序全部 session。已有 session 继续 O(1) 访问,新 session 在没有可安全回收的空闲记录时失败关闭。
+- Dashboard、Embed 和 Plugin Runtime 的协议 JSON 编解码在线程执行;Dashboard/Embed 在接收端提交 terminal error 后为发送端保留有界 drain 窗口并使用内部 sentinel 唤醒,不会因“任一方向结束即取消”在撤权错误帧发出前关闭连接。
+- 租户配置的敏感词、内容忽略和群响应正则统一使用声明为直接依赖的 `regex` 引擎:最多 64 个 pattern、单 pattern 1,024 字符、输入 1 MiB、单次总匹配 CPU 预算 50 ms,并在线程中执行。超时、非法正则和替换放大均失败关闭;灾难性 `(a+)+$` 回归在 1 ms 测试预算内被中断。
+- 原生 `read/write/edit/glob/grep` 文件工具移出事件循环并继承 Workspace 阻塞预算。目录列举、递归 walk、grep 文件/总字符、单行、pattern、结果和 regex CPU 均有硬上限;glob 只用固定大小最小堆保留最新 100 项,不再先把全部命中路径驻留内存。Box 内执行的 glob/grep 脚本同样限制命中集合、扫描量和正则时间。
+- Dify、DingTalk、QQ、WeCom 等客户端复用连接并在生命周期结束时关闭,响应体和下载有字节上限。
+- DashScope、TBox 等同步第三方 SDK 的调用和生成器迭代改为在线程执行;单个同步生成器最多消费 100,000 个事件。
+- Dashboard 和 Embed WebSocket 改为任一收发 task 结束即取消并等待另一方向,避免发送端退出后接收 task 永久阻塞;两方向 task 同时继承从认证结果或 RuntimeBot 得到的可信 Workspace 阻塞预算。
+- Plugin installation 生命周期全局串行化;不同租户的依赖 pip/nsjail 准备不会在安装高峰并发抢占 CPU。
+- Plugin installation 的意外退出除了每 installation 的 jittered exponential backoff,还经过 Runtime 全局 restart launch 并发槽和失败窗口熔断。
+ 熔断冷却后只允许一个 half-open probe;probe 必须完成初始化并持续稳定后才恢复其他 installation。未在 30 秒内 ready 的 worker
+ 会被取消回收。健康指标输出 active launch、窗口失败数、circuit 状态和累计打开次数,24 小时门禁把 circuit 打开或尾段启动槽不归零判为失败。
+- S3 同步 SDK 使用线程执行,并通过实例级 semaphore 限制并发;默认 `storage.s3.max_concurrency=16`,可通过实例配置和环境变量覆写。
+- Box 子进程 stderr 以 64 KiB 块读取,日志最多每秒输出 4 个摘录并汇总抑制数量,避免无换行或刷屏输出制造无界缓冲与日志放大。
+- Plugin worker 日志单行最多保留 64 KiB;Box managed-process stdout relay 以固定 64 KiB 块读取,不再依赖换行符,避免超长无换行输出触发 `StreamReader` limit 或堵塞子进程。
+- Box generation fence 的代次更新改为只访问目标 Workspace 的 event 和 active-task 二级索引。原实现每次更新都会遍历全部 Workspace 的 fence/task 记录,10,000 个 Workspace 的第二阶段更新会退化为 O(N²) 并在 40 秒后仍未完成;修正后包括其他 SDK 高基数负载和本轮协议 offload 在内的当前完整双阶段探针耗时 `11.270s`。
+- Box session 枚举、旧 generation 回收和 admission 计数均通过 Workspace 索引执行;admission 过期回收通过最小堆执行,不再在每次 RPC 上产生 O(实例总 session/grant 数) 的扫描。
+- Model、Pipeline、RAG 和 Platform manager 均维护 Workspace 到运行时 key 的二级索引。Workspace generation 更新只清理目标 Workspace 的缓存和运行时,不再扫描实例内所有租户的 provider/model、pipeline、knowledge runtime 或 bot;回归测试使用禁止全局迭代的映射验证该边界。
+- Cloud heartbeat 直接读取已加载且有容量边界的 Pipeline、MCP、KnowledgeBase 和 Bot registry 计数,不再为每个活跃 Workspace 依次打开 Tenant UoW、执行四类 COUNT 查询;这消除了租户数增长后每日周期性形成的串行 SQL/CPU 尖峰。OSS 模式仍保留数据库统计语义。
+- 邀请、Monitoring 和 Storage 的三个周期清理 task 合并为一个
+ `resource-maintenance` 调度器。调度器先等待首个 interval,不与启动加载争抢资源;
+ 同一到期周期只执行一次 active Workspace discovery,然后按 Workspace 串行运行
+ 有界 job,单 Workspace 失败不跳过其他 Workspace。默认相同的一小时周期由此从
+ 三次全租户发现和三个同时唤醒的任务收敛为一次发现和一个任务。
+- Cloud 启动阶段先生成一份经过部署适配器和目录投影校验的 Workspace binding 快照,Model、Platform、Pipeline、RAG 和 Plugin 初始化共用该快照,初始化完成后立即释放;避免启动期间为每个 manager 重复执行整批租户发现和投影校验。
+- Platform、Pipeline 和 RAG 的资源加载在使用已验证启动快照时不再为每个 Bot/Pipeline/KnowledgeBase 重新查询同一个 execution binding;常规请求和动态更新路径仍保留数据库 generation fence。
+- MCP 初始 host 和 shutdown burst 由实例级 semaphore/批次限制;默认 `mcp.lifecycle_concurrency=16`,支持 `MCP__LIFECYCLE_CONCURRENCY` 覆写并硬性限制最大 128。初始加载不再先为每个 server 创建一个等待 semaphore 的 task,而是由一个可取消 dispatcher 每批最多物化 `lifecycle_concurrency` 个子 task;同时去掉了 ORM server/config 的双份临时列表,避免大量租户启动时集中占用 CPU、内存、socket 和文件句柄。
+- Core、Plugin Runtime、Box Runtime 和独立 Plugin worker 的默认 `asyncio.to_thread()` executor 统一改为硬有界线程池。默认最多同时运行 8 个阻塞调用、排队 128 个,达到容量后立即抛出 admission 错误,不再使用 Python 默认 `ThreadPoolExecutor` 的无界工作队列保留任意数量的请求对象、Future 和闭包。每个可信 Workspace 的 running + queued 默认再限制为 4,并强制配置值不超过 worker 数的一半,避免单个租户先提交一整批同步工作占满全部 worker/FIFO 队列。Core 使用 `system.blocking_executor.max_workers/max_pending/max_inflight_per_scope`,原生支持对应的 `SYSTEM__BLOCKING_EXECUTOR__*` 覆写;SDK 进程使用 `LANGBOT_BLOCKING_EXECUTOR_MAX_*`,并分别限制全局最大值为 64/4096。
+- Core、Plugin Runtime 和 Box Runtime 各自运行固定 1 秒间隔、仅保留最近 120 个样本的 event-loop lag monitor;健康快照输出 current/recent max/recent p95/进程期最大延迟和累计样本数。Plugin Runtime 的两个 WebSocket 端口现在都在免认证 `/healthz` 返回同一份无凭据、无租户标识的聚合 JSON,Box `/readyz` 同时附带资源快照。24 小时门禁默认拒绝缺失或停止的 monitor、超过 1 秒的 recent max、超过 250 ms 的尾段 recent p95 及 sample counter 回退。
+- Workspace 阻塞预算由服务端认证后的 `RequestContext`、公开 bot 的 RuntimeBot、公开对象 key 中经 binding fence 验证的 Workspace、Platform/TaskManager 的 ExecutionContext,以及 SDK 入站 ActionContext 建立,不接受调用方伪造的租户 header。公开 webhook、公开对象下载、Dashboard/Embed WebSocket、普通 HTTP handler、Platform adapter 和 detached tenant task 均已覆盖。容量拒绝在 Core HTTP 路径返回稳定的 429,health/debug counter 分开报告 global 与 scope rejection。
+- Argon2 密码 hash/verify 只允许一个实例级在途操作,额外并发立即返回容量错误而不是在 asyncio semaphore 中无限积累等待请求;该 CPU/内存密集工作同时使用独立的 `system:authentication` 阻塞作用域。Cloud 本身仍禁用本地密码登录。
+- WeCom 扩展 API 的无限客户端超时改为 120 秒;平台 webhook 的 AES、媒体 Base64 与同步 SDK 调用均移出共享事件循环。
+- 长文本转图片限制为 100,000 字符、256 行、800 万 RGBA 像素和 10 MiB 输出;
+ 超限时回退到 forward message。数字边界查找从重复 `count/find/sort` 改成线性扫描,
+ PIL image 使用显式关闭,压缩步长为零时也能终止。
+- Core 在每次 quota-enforced Box exec 前后遍历 Workspace 时使用非递归 DFS,并在
+ 超过字节 quota 或默认 100,000/绝对 1,000,000 个目录项后立即停止;目录项洪泛
+ 失败关闭,不再重复完整扫描 inode bomb。远程 outbox fallback 同时限制扫描项、
+ 文件数、单文件和总字节,Python project manifest 使用分块 hash 并限制单文件 10 MiB。
+
+### 插件和 Box 资源隔离
+
+- Plugin worker 数量受 `max_workers`、`max_total_cpus / max_cpus` 和 `max_total_memory_mb / max_memory_mb` 的最小值约束。
+- Shared profile 强制 Linux 和 nsjail;Cloud 强制 `plugin.worker.require_hard_limits=true`;cgroup v2 delegation 不可用时拒绝启动。
+- 每个 worker 下发 CPU、memory、swap、PID cgroup 限制,以及 process、open-file、file-size rlimit;插件 manifest 不能提高限制。
+- Box nsjail 的 cgroup v2 路径现在同时设置 `memory.max` 和 `memory.swap.max=0`。修复前,48 MiB 沙盒可以把强制提交的 128 MiB 页面换出并正常退出,形成宿主 swap 抢占;修复后同一探针以 exit 137 被 cgroup 杀死。
+- 仓库 Docker Compose/Kubernetes 示例显式下发 Core、Plugin Runtime 和 Box Runtime 的 blocking executor 上限;Kubernetes Box readiness probe 从仅报告进程存活的 `/healthz` 改为 `/readyz`,使 backend 或 managed-mode 隔离检查失败时不会把 Pod 加入就绪流量。
+- 相同 digest 的已验证代码和依赖环境可只读共享,每个 installation 的 home/tmp/data 和进程独立。
+- SDK 在发布共享依赖环境前最多校验 100,000 个目录项和 2 GiB 常规文件元数据总量;
+ 超限的 staging tree 会被原子清理而不会进入 worker。`requirements.txt` 和插件
+ `manifest.yaml` 都使用 `limit + 1` 有界读取,manifest 额外限制为 1 MiB。
+- Box session、managed process、completed process、admission record 和 RPC 文件均有实例级上限;Cloud entitlement 仍限制每个合资格 Workspace 一个 `global` session、零 managed process。
+- Box Runtime 对上述实例级配置再增加不可放大的硬上限:session 5,000、managed
+ process 1,024、completed process 10,000、admission record 250,000、RPC 单文件
+ 100 MiB、completed retention 86,400 秒。初始化与远程 INIT 对错误类型、负数和
+ 超上限均失败关闭,错误动态更新不会留下部分生效的 limit。
+
+### PostgreSQL
+
+- Cloud 强制 PostgreSQL 业务库、共享 pgvector 和允许的固定向量维度。
+- pgvector Cloud 模式复用业务数据库的同一个 AsyncEngine,不创建第二个连接池。
+- `database.postgresql` 新增并校验 `pool_size`、`max_overflow`、`pool_timeout_seconds`、`pool_recycle_seconds`;默认最大连接数为 `10 + 10`。
+- `pool_size + max_overflow` 的绝对上限为 100,timeout/recycle 也有绝对上限;
+ Cloud runtime 的 asyncpg 连接默认设置 60 秒 statement timeout、5 秒 lock
+ timeout 和 60 秒 idle-in-transaction timeout,并分别限制最大 300/60/300 秒。
+ 一次性 release migration 不继承这些短 runtime timeout。
+- `/healthz` 输出 pool 配置容量、checked-in/out、overflow、pool admission timeout
+ 累计数和 SQL timeout 配置;目录同时输出 active/max 与最近批次
+ Workspace/membership 数,供生产 soak 和告警核对。
+- Application shutdown 显式 dispose 业务引擎;standalone pgvector 仅关闭自己拥有的引擎。
+- PersistenceManager 提供统一异步 shutdown;Cloud 常驻进程的启动失败、正常停机和一次性 release migration 的成功/异常路径都会释放数据库引擎。真实 PG catalog 测试还覆盖了“入口已经关闭后测试再次复用 manager 会重开 pool”的第二生命周期,严格资源告警模式下无 asyncpg socket/transport 遗留。
+
+## 本轮采用的默认决策
+
+- 优先 fail closed 或淘汰最老的 idle cache,不允许攻击者控制的历史 key 无限驻留。
+- 插件依赖准备选择实例级串行化,以稳定 CPU/磁盘峰值;代价是批量安装耗时增加。
+- PostgreSQL 使用一个显式有界共享连接池;未拆分 pgvector pool。
+- 单实例目录的默认 active/full-snapshot Workspace 上限均为 1,000,membership
+ 上限为 20,000,签名响应上限为 32 MiB;绝对上限分别为 5,000、100,000 和
+ 64 MiB。Space 和 Core 必须配置为同一操作上限,生产值只能根据 V-08 容量曲线
+ 向下调整或在重跑全部门禁后提高。
+- 第三方 runner 采用 1 MiB 单结果、16 MiB 单流总量和 100,000 个同步/异步事件的统一实例级安全上限;超限请求失败关闭。
+- S3 默认允许 16 个并发阻塞调用,最大配置值 128;在没有独立 worker service 的前提下限制线程池排队和上游连接压力。
+- MCP 生命周期默认并发 16、最大 128;该限制统一约束实例启动时的 session host 峰值和 shutdown 批次,不允许租户配置单独放大。
+- Core 与 SDK 各进程的通用阻塞 executor 默认使用 8 个 worker、128 个 pending 槽位、每 Workspace 4 个在途槽位;它是实例/进程级共享背压,不由 Workspace 或插件 manifest 调高,单 Workspace 配置硬性不得超过 worker 的一半。生产值应按容器 CPU 和上游阻塞时延校准,不能把 pending 当吞吐配置无限放大。
+- 插件包下载上限 64 MiB,pip stdout/stderr 保留上限各 1 MiB;这不会限制安装进程实际输出,只限制父进程内存中的诊断副本。
+- 通用远程响应和媒体默认上限 10 MiB;错误诊断正文只保留 4 KiB。Plugin binary storage 默认 10 MiB、绝对上限 64 MiB;Skill 文本、Plugin UI 和 host edit 分别限制为 1 MiB、4 MiB 和 1 MiB。
+- Storage scoped object 默认读写上限 10 MiB、代码绝对上限 64 MiB;Webhook 默认每
+ Workspace 16 个、实例 16 个同时出站请求,代码绝对上限分别为 64 和 128。Box
+ Workspace quota 扫描默认最多访问 100,000 个目录项、绝对最多 1,000,000 个。
+- SDK 共享依赖环境在发布前最多接受 100,000 个条目、2 GiB 常规文件元数据总量;
+ artifact manifest 与 requirements 各最多 1 MiB。这些是 Runtime 控制面在启动
+ worker 前的保护,不替代最终文件系统的 byte/inode 硬配额。
+- Monitoring 查询上限由 `monitoring.query_limits` 配置并支持原生环境变量覆写,但始终
+ 受代码绝对上限约束;cleanup 的每表批次数和 Storage 每轮文件数同样采用实例配置加
+ 绝对上限。时间序列默认/绝对上限为 1,000/10,000 个数据库聚合桶,模型分组复用分页
+ 上限。提高这些值必须计入 V-08/V-09 的数据库 CPU 与 Core RSS 容量曲线。
+- Managed-process relay 保留 stdout 的原始换行,并按 64 KiB WebSocket frame 分块;不再承诺“一行对应一个 frame”。这是为无换行输出提供确定内存边界所需的协议收敛。
+- 本轮没有把 Pipeline、Model、KnowledgeBase 等合法租户资源改成 lazy runtime。该改动会改变启动和请求语义,留到 Workspace placement/释放机制一起设计。
+- 本轮没有为普通 nsjail 声称伪硬盘配额;严格 Cloud readiness 保持失败关闭。
+
+## 验证结果
+
+| 验证项 | 结果 |
+| --- | --- |
+| LangBot Ruff + `git diff --check` | 通过 |
+| Plugin SDK Ruff + `git diff --check` | 通过 |
+| LangBot 全量测试(含 unit/integration/Box/E2E) | `2855 passed, 33 skipped` |
+| Plugin SDK 全量测试 | `1328 passed` |
+| Space Go 全量测试与闭源 Cloud Adapter 测试 | Go `go test ./...` 通过;Adapter `40 passed` |
+| Space PostgreSQL 16 Cloud v2 目录与并发容量准入 | 通过;两个注册并发争用最后一个槽位时 `1 success / 1 capacity rejection / 1 active Workspace` |
+| Core PostgreSQL 16 Cloud runtime server timeout | 真实连接从 `pg_settings` 读回 `60000ms / 5000ms / 60000ms` 的 statement/lock/idle-transaction timeout,并显式 dispose |
+| 真实 PostgreSQL 16 + pgvector 迁移/RLS/发布测试(严格资源告警) | `22 passed` |
+| 真实 PostgreSQL 16 + RLS populated Cloud 启动容量 | 500 Workspace `6.178s / CPU 3.026s`;当前 1,000 Workspace 复跑 `12.109s / CPU 5.967s` |
+| 较早 Core Dockerfile Linux 镜像构建与 `regex` 导入 | 通过,image SHA `8893a14053df`;该镜像使用旧 SDK pin,已失效,最终候选必须重建 |
+| `ResourceWarning` + `PytestUnraisableExceptionWarning` 全量门禁 | Core 与 SDK 均通过,并已固化到 pytest 配置 |
+| Plugin SDK Box 专项测试(含全局扫描回归保护) | `669 passed` |
+| Docker Compose 渲染、Compose/Kubernetes YAML 解析与 diff 检查 | 通过 |
+| Cloud soak 门禁解析/采样/判定单元测试 | `27 passed` |
+| Core/Plugin SDK event-loop monitor 专项测试 | 两仓各 `7 passed`,包含真实 50 ms scheduler stall |
+| Cloud soak Linux 硬限制短时自检 | 通过;CPU `0.5`、memory+swap `256 MiB`、PID `128` 均从 cgroup v2 读回,冷却尾段 verdict `pass` |
+| Core 双阶段历史 churn 资源探针(使用当前本地 SDK 分支) | audit 通过,`12.559s` |
+| Core 5,000 个 populated Workspace 三代容量探针(使用当前本地 SDK 分支) | 当前复跑通过,最大替换耗时比 `1.405` |
+| Plugin SDK 双阶段资源探针 | audit 通过,`11.270s` |
+
+两个仓库新增了可重复执行的历史 churn 探针,Core 另有 populated Workspace 三代替换探针:
+
+```bash
+# LangBot Core
+PYTHONPATH=../langbot-plugin-sdk/src uv run python scripts/runtime_resource_probe.py --scale audit --json
+
+# LangBot Core:5,000 个带代表性资源的 Workspace
+PYTHONPATH=../langbot-plugin-sdk/src uv run python scripts/workspace_runtime_capacity_probe.py --scale audit --json
+
+# LangBot Core:真实 PostgreSQL 16 + RLS populated Workspace 启动
+TEST_POSTGRES_URL=postgresql+asyncpg://... \
+LANGBOT_PG_CAPACITY_WORKSPACES=1000 \
+uv run pytest \
+ tests/integration/persistence/test_migrations_postgres.py::TestPostgreSQLTenantRuntime::test_populated_cloud_startup_is_linear_and_task_bounded \
+ -q -W error::ResourceWarning --log-cli-level=INFO
+
+# langbot-plugin-sdk
+uv run python scripts/runtime_resource_probe.py --scale audit --json
+```
+
+Core audit 每个阶段执行 10,000 个空 Workspace 的真实 Model/Plugin manager 加载与 reconcile、25,000 次 Query、2,500 次 session churn、10,000 个限流身份、5,000 个 task 和 2,500 次 WebSocket churn。第一、第二阶段的保留状态完全一致:
+
+- 20,000 个历史空 Workspace:Model scope/provider/LLM、Plugin Workspace set/installation 均为 `0`。
+- 50,000 个历史 Query:活跃 query cache `0`,历史 scope counter `100`。
+- 5,000 个会话身份:session cache `200`。
+- 20,000 个限流身份:rate-limit container `10,000`。
+- 10,000 个历史 task:task record `200`。
+- 5,000 次 WebSocket churn:conversation 与 stream index 均为 `200`。
+- event-loop task、线程和文件描述符保持 `1 / 1 / 6`;使用当前本地 SDK 分支的复跑中,第二阶段相对第一阶段 RSS 增长 `2,228,224 bytes`、tracemalloc current 增长 `344,669 bytes`,总耗时 `12.559s`。Session 淘汰改为 Workspace 索引和最小堆后,同一 audit 工作量相对此前 `16.150s` 明显下降。
+
+Populated Workspace audit 为 5,000 个 Workspace 各加载一个 Provider、LLM、Embedding、Rerank、Pipeline、Bot、KnowledgeBase 和 MCP session,然后全部推进两个 generation:
+
+- 三个阶段的活跃 provider/model、pipeline、bot、knowledge 和 MCP registry 均精确维持 `5,000`,不存在按历史 generation 增长。
+- 到第三阶段,前两代的 requester、Bot adapter 和 MCP session 各 `10,000` 个全部收到确定性关闭;weak reference 断言旧代对象可被回收。
+- event-loop task、线程和文件描述符保持 `1 / 1 / 6`;使用远端精确钉住 SDK 的当前复跑中,第三阶段相对第二阶段 RSS 增长 `1,245,184 bytes`,tracemalloc current 仅增长 `2,061 bytes`。
+- 初始/第一次替换/第二次替换分别耗时 `1.893s / 2.549s / 2.659s`,最大替换耗时比为 `1.405`,未随历史代次出现 CPU 退化。
+- macOS RSS sample 从初始的 `154,648,576` 增至第一阶段 `368,181,248`、第二阶段 `389,087,232` 和第三阶段 `390,332,416 bytes`;第二次替换只比第一次替换增加约 1.19 MiB,但“合法活跃租户资源的线性容量”仍必须作为 placement 容量输入。这里使用轻量 fake adapter/requester,不应把第一阶段约 204 MiB 增量外推为生产每租户成本。
+
+Plugin SDK audit 每个阶段执行 25,000 次 loopback RPC、5,000 次安装 binding 激活/撤销、10,000 个 Workspace generation 更新和 2,500 次带 Workspace 上下文的 Box session 创建/删除。第一、第二阶段的保留状态完全一致:
+
+- RPC waiter、stream queue、action task 和活跃 installation binding 均为 `0`。
+- installation watermark 为有界的 `5,000`;Workspace generation record 为有界的 `10,000`,没有等待者时 generation event 为 `0`。
+- generation active task/index、Box session、Box Workspace session index、creating/closing/background task 和 session lock 均为 `0`。
+- event-loop task 和文件描述符保持 `1 / 7`;当前复跑第二阶段相对第一阶段 RSS peak 增长 `2,637,824 bytes`、tracemalloc current 增长 `289,746 bytes`,总耗时 `11.270s`。耗时增加来自本轮把大协议消息的 JSON/Pydantic、UTF-8 编码、分片和拼接移入有界线程池;结构状态和第二阶段 tracemalloc 增量保持平稳。
+
+第二轮反向静态审查另外枚举了 Core 的 50 个显式 task 创建点和 204 个线程、阻塞调用及子进程调用点,以及 SDK 的 28 个显式 task 创建点和 62 个线程、阻塞调用及子进程调用点。第三轮独立复核继续从高基数定时器、目录遍历、准入全表扫描和取消竞态反推,新增关闭了 Plugin restart 冷却唤醒群、MCP idle 数据库轮询、nsjail orphan 的 O(session × process) 启动扫描、message aggregation 的 O(buffer) 准入及 Skill inode/文本列表边界。显式 task 均具有持有者、完成回调或 `finally` 回收路径;所有生产入口在第一次 `asyncio.to_thread()` 前安装有界默认 executor。Core、Plugin Runtime 和 Box 的公开 `/healthz`(Box `/readyz` 亦同)会输出各自的 aggregate runtime/resource counter 和 event-loop lag,供 soak 对比活跃量、pending、累计 capacity rejection 与调度延迟;不输出 debug key、控制 token、租户或插件身份。Plugin Runtime 的授权 debug info 复用同一资源快照,避免公开/私有指标语义漂移。
+
+真实 PostgreSQL populated 启动门禁会先通过 release migration 创建最新 schema,再用无 `BYPASSRLS` 的临时 Cloud Runtime 角色启动。每个 Workspace 都含九类代表性资源,测试会走实际的 instance discovery、tenant UoW、启动 binding 快照和 Model/Platform/Pipeline/RAG/MCP/Plugin 加载路径:
+
+- 500 Workspace:启动加载 `6.178s`,进程 CPU `3.026s`。
+- 当前 1,000 Workspace 复跑:启动加载 `12.109s`,进程 CPU `5.967s`;相对此前 500 Workspace 的墙钟比为 `1.960`。
+- `model_providers`、`llm_models`、`embedding_models`、`rerank_models`、`bots`、`legacy_pipelines`、`knowledge_bases`、`mcp_servers`、`plugin_settings` 九张表的 SELECT 次数均精确等于 Workspace 数,没有重复的全租户发现或超线性资源扫描。
+- MCP host dispatcher、host task 和临时 Runtime 角色/asyncpg 连接在测试结束后均清空;严格 `ResourceWarning` 模式通过。
+
+探针要求第二阶段的结构状态与第一阶段精确相等,并对第二阶段 RSS 与 tracemalloc 增长设置失败阈值。macOS 的 RSS 来源是 `getrusage` peak,因此这里验证的是峰值增量边界而非“当前 RSS 回落”;最终 Linux 24 小时 soak 仍需采集 current RSS/PSS 和 cgroup `memory.current`。
+
+LangBot 全量测试的 33 个 skip 中,22 个是默认全量运行未提供 PostgreSQL/pgvector 而跳过的集成用例,10 个是未提供 Valkey,另 1 个是可选环境的 collection skip;真实 PostgreSQL 相关路径已由上表单独运行覆盖。Plugin SDK 的 26 个 warning 为现有 Pydantic v2 deprecation 与 aiohttp AppKey 建议;没有失败、未关闭资源或资源上限降级。Core 当前全量产生 194 个既有第三方/兼容性 warning;`ResourceWarning` 和 `PytestUnraisableExceptionWarning` 仍由 pytest 配置提升为错误,本轮没有此类泄漏告警。
+
+Linux Runtime 探针使用上述镜像并只读挂载本地最新 SDK 源码:
+
+- 普通容器:nsjail binary 可执行,但 namespace、mount、network 与 cgroup v2 检查均为 `false`,严格 readiness 按预期失败关闭。
+- `--privileged` + private cgroup namespace:namespace、mount、network 通过,但 cgroup v2 delegation 为 `false`,仍按预期不能进入 Cloud ready。
+- 一次性容器内建立可写 delegated cgroup 子树后:Plugin 与 Box cgroup 探针均为 `true`,nsjail namespace、mount、network 和 cgroup v2 均通过;硬文件系统与 inode quota 继续报告 `false`。
+- `cpus=0.1` 的 1.0 秒 process-CPU busy loop 实际耗时 `9.13s`;`memory_mb=48` 下逐页提交 128 MiB 以 exit `137` 终止;`pids_limit=8` 下批量 fork 返回 `EAGAIN`。这些结果验证了 CPU、memory+swap 和 PID 的实际内核执行路径。
+- 新增 `scripts/cloud_runtime_soak.py` 后,在同一 Linux 镜像的独立容器中设置 `--cpus 0.5 --memory 256m --memory-swap 256m --pids-limit 128`,工具从目标 cgroup 读回 quota `50000/100000 usec`、memory `268435456 bytes`、swap `0 bytes` 和 PID `128`。最终复跑中,32 MiB 子负载退出后的 4 秒冷却尾段 `memory.current` 稳健增长和斜率均为 `0`,平均 CPU `0.00132 cores`,OOM、memory pressure、PID max 和 throttle delta 均为 `0`,最终 verdict 为 `pass`。这只是采集器/判定器自检,不替代最终 24 小时生产候选运行。
+- 本地实际启动 Plugin Runtime 后,控制端口与 debug 端口的公开 `/healthz` 均返回相同聚合 JSON,event-loop monitor 为 running,且正文不含 debug key。采集器显式绕过进程级 HTTP proxy 后,对控制端口执行 6 秒短时 endpoint gate:无失败,观测到的 recent max/p95 均为 `2.233 ms`,verdict 为 `pass`。
+- 本地实际启动 Box Runtime(未创建 sandbox session)后,`/healthz` 与 `/readyz` 均返回 event-loop、blocking executor、session/process/task 聚合快照;monitor 为 running、样本持续增长,两个端点观测到的 recent max 均为 `2.265 ms`。SIGINT 后 aiohttp、Runtime、reaper 与 monitor 走统一清理路径并正常退出。
+
+## 上线配置与监控门禁
+
+最终 24 小时命令、运行位置、阈值语义、负载矩阵和产物要求见 [LangBot Cloud 24 小时资源 Soak 门禁](./cloud-runtime-soak-gate.md)。该工具默认把任一健康失败、OOM/memory pressure、PID limit、CPU throttling 超阈值、blocking executor rejection、冷却尾段内存持续增长或空闲 CPU 过高判为失败;生产运行必须使用 `--require-hard-limits`。
+
+至少需要监控并告警:
+
+- Core/Plugin Runtime/Box Runtime 的 RSS、CPU throttling、OOM、PID 数和 event-loop lag。
+- 各进程 blocking executor 的 running、pending、inflight、active scopes、`global_rejected_total` 和 `scope_rejected_total`;pending 持续不归零或 rejection 增长都应告警。
+- QueryPool、WebSocket、session、task、plugin worker、Box session 的当前量、容量拒绝和淘汰计数。
+- Plugin crash/restart 频率、dependency prepare 耗时和失败率。
+- PostgreSQL pool checked-out/overflow/wait timeout、事务耗时和连接错误。
+- 临时文件、artifact、dependency environment、Box Workspace volume 的字节数和 inode。
+
+生产 soak 应覆盖租户突发登录、批量插件 reconcile、插件崩溃重启、WebSocket 断连、Box 并发执行、PG pool 饱和和应用 SIGTERM;持续运行至少 24 小时,并验证负载停止后 RSS、task、socket、文件和子进程数量回到稳定基线。
diff --git a/docs/multi-tenant/verification-report.md b/docs/multi-tenant/verification-report.md
new file mode 100644
index 000000000..c316b9eac
--- /dev/null
+++ b/docs/multi-tenant/verification-report.md
@@ -0,0 +1,271 @@
+# Cloud v2 multi-tenant verification report
+
+Date: 2026-07-24
+
+Status: `FOUNDATION AND CONTROL PLANE VERIFIED — PRODUCTION ACTIVATION REMAINS GATED`
+
+This report records the implementation and verification evidence for one
+logical LangBot instance serving multiple Workspace tenants. It covers the
+open-source Core, the shared Plugin/Box runtimes, the closed Space adapter, and
+the Space Cloud v2 modular-monolith control plane. It does not claim that the
+production Cloud deployment may enable `CLOUD_V2_ENABLED` yet.
+
+## Repository refs and scope
+
+- LangBot Core: branch `feat/multi-tenants`, commit
+ `e8a09b7537ef285a967f24add05fdb9bb557b97e`
+- langbot-plugin-sdk: branch `feat/multi-tenants`, head
+ `ca545d079ca1657a5d4efb4e31bfeafe1a374a46`
+- langbot-space: branch `feat/cloud-v2-control-plane`, head
+ `ce41ff370e94a405f70e2fbb2f99b0946e0e0387`
+- Closed Core adapter: `langbot-space/cloud-adapter`
+- SDK protocol/package version: `0.4.18`
+
+Core pins SDK commit `e7d946af4a6b1494fbe74627c1815ace19ac8991`;
+the SDK branch head adds CI-only follow-up. Cloud v2 is a greenfield
+multi-Workspace deployment and does not provision one Pod, database, queue, or
+Runtime per tenant. Legacy Space Pods remain a compatibility surface only.
+
+## Implemented product and security boundaries
+
+### Workspace and identity
+
+- SaaS has one stable `instance_uuid`; `workspace_uuid` is the tenant key.
+- Registering an Account creates its personal Workspace and owner Membership in
+ the same PostgreSQL transaction.
+- A Workspace may contain multiple users with owner, admin, and member roles.
+ Invitation acceptance is token-hash based, email-bound, single-use,
+ concurrency-safe, and member-limit checked while locked.
+- Community remains exactly one local Workspace while supporting multiple
+ Accounts and fixed RBAC roles. Installing the closed adapter is required to
+ inject the SaaS policy; configuration alone cannot activate it.
+- Disabled or deleted Accounts cannot use password/code/OAuth login, sessions,
+ refresh/access tokens, or personal access tokens.
+
+### Closed Space control plane
+
+- `CLOUD_V2_ENABLED` defaults to false. Invalid or incomplete configuration
+ fails startup, and disabled endpoints return 503.
+- Space owns Workspace/Membership/Invitation, versioned plans, subscriptions,
+ entitlements, usage events, directory outbox, and signed instance manifests.
+- Free and Pro are Workspace plans. Pro projects one managed global sandbox;
+ Free projects none. Both force stdio MCP off in the first release.
+- Subscription changes use Workspace advisory locking, expected entitlement
+ revision, one-live-order uniqueness, idempotent usage ingestion, period-end
+ downgrade, renewal, and lazy expiry settlement.
+- Account provisioning and quota projection into New API use PostgreSQL
+ transactional outboxes, replica-safe claims, retry/backoff, revision fencing,
+ and database-enforced identity/user/token ownership. No additional service is
+ introduced.
+- EPay and Stripe callbacks are bound to the locked order's provider identity,
+ amount, currency, channel/session, expiry, and provider transaction ID.
+ Replays are idempotent, EPay is CNY-only, credential rotation is supported by
+ encrypted per-order snapshots, and permanent fulfillment conflicts are
+ retained for reconciliation.
+- Directory snapshot and per-Workspace delta reads use repeatable-read,
+ read-only transactions with a high-water cursor. Core stores a replica-local
+ consumer cursor and shared PostgreSQL projection state, snapshot coverage,
+ and inbox rows.
+- The `/cloud` page selects a Workspace and shows its independent subscription,
+ entitlement, limits, and usage. When Cloud v2 is disabled or the backend does
+ not expose the feature field, the complete legacy Welcome/Pod UI is used.
+
+### PostgreSQL and pgvector
+
+- SaaS business data uses one PostgreSQL shared schema with application scope
+ plus forced RLS. Cloud directory writes are separated from local tenant
+ writes.
+- Projected Account and Membership revisions are monotonic. Tombstones remove
+ memberships, and stale revisions cannot resurrect them.
+- pgvector shares the business PostgreSQL database and remains
+ Workspace-scoped.
+- Space runs versioned SQL migrations before application seeds. A fresh
+ database, a partial pre-migration database, and an existing-baseline path
+ converge without startup-time `AutoMigrate`.
+
+### Shared Plugin Runtime
+
+- One instance-scoped trusted Supervisor serves multiple Workspaces.
+- Every enabled installation has its own nsjail process bound to
+ `(instance, workspace, execution generation, installation, runtime revision,
+ artifact digest)`.
+- Verified same-digest code and dependency files are mounted read-only and may
+ be shared; home, tmp, data, process namespace, registration capability, and
+ cgroup are private.
+- Instance configuration owns CPU, memory, PID, open-file, and file-size
+ limits. Plugin manifests cannot increase them.
+- Memory includes swap: nsjail receives `memory.max` and `swap.max=0`.
+- Unexpected worker exit is recovered by a completion callback with bounded
+ per-installation exponential backoff. Remove, reconcile, Runtime shutdown,
+ and container SIGTERM perform a graceful-to-SIGKILL bounded reap.
+
+### Shared Box Runtime and MCP
+
+- One shared Box control plane serves Workspace-bound logical sessions.
+- Cloud grants allow at most one persistent `global` session for an entitled
+ Workspace and no managed processes in the first release.
+- Cloud is fixed to nsjail and network-off. Core and Box prove the shared
+ durable Workspace mount with an authenticated marker challenge.
+- stdio MCP is independently gated and forced off for Cloud v2.
+- The current nsjail Box backend does not provide hard byte/inode quotas, so
+ Cloud readiness correctly fails closed instead of silently using a soft
+ directory scan.
+
+## Automated verification
+
+### LangBot Core
+
+```text
+uv run --no-sync pytest -q
+ 2590 passed, 32 skipped, 177 warnings
+
+real PostgreSQL migration, pgvector, and release-migrator suites
+ 21 passed, 11 warnings
+
+uv run --no-sync ruff check .
+uv lock --check
+git diff --check
+ passed
+```
+
+The full suite ran without the closed adapter installed, proving the open-source
+single-Workspace/multi-user path remains standalone. Focused closed-adapter,
+directory projection, runtime connector, Box cleanup, and configuration suites
+also passed with the adapter installed.
+
+### Plugin SDK and real Linux runtime
+
+```text
+SDK full suite
+ 1226 passed, 22 existing warnings
+
+Ruff check and format check
+git diff --check
+ passed
+```
+
+A privileged Linux test container with host cgroup namespace ran one shared
+Runtime and two Workspace installations:
+
+- both workers referenced the same artifact inode;
+- home, tmp, and data inodes were distinct;
+- each plugin saw only PID 1 in its private PID namespace;
+- a tampered binding and an unknown installation were rejected;
+- the control token was absent from worker environments;
+- cgroups were distinct with `memory.max=134217728`, `memory.swap.max=0`,
+ `pids.max=32`, and `cpu.max=500000 1000000`;
+- touching 256 MiB exited with code 137 without swap growth;
+- the 32nd fork failed with `EAGAIN`;
+- reconcile and container SIGTERM removed the worker cgroups.
+
+The same run started the Runtime from a non-root working directory, covering
+absolute nsjail mount-source normalization.
+
+### Space backend, adapter, and frontend
+
+```text
+MIGRATIONS_TEST_DSN=... MIGRATIONS_TEST_DSN_FRESH=... \
+ go test -count=1 ./...
+go vet ./...
+ passed against PostgreSQL 16
+
+fresh PostgreSQL app startup, partial-baseline migration,
+Cloud v2 migration rerun, and control-plane integration
+ passed
+
+closed adapter pytest and Ruff
+ passed
+
+pnpm exec tsc --noEmit
+pnpm check:i18n
+pnpm check:cloud-checkout-currency
+ passed; 7 checkout/currency cases
+```
+
+The PostgreSQL checks started from an empty database and verified all 34
+registered migrations in order, Cloud v2 Free/Pro seeds, legacy plan seeds,
+Cloud columns/indexes, payment callback constraints, New API outbox/ownership
+constraints, and repeatable reruns.
+
+## Cross-service and browser E2E
+
+### Signed Space-to-Core directory projection
+
+Using an isolated Space PostgreSQL database and a migrated Core PostgreSQL
+database:
+
+1. Space issued a signed manifest for the fixed LangBot instance.
+2. Space returned a directory snapshot at cursor 5 containing two Workspaces,
+ two projected Accounts, and owner/admin memberships.
+3. Core verified the signature, instance, release, validity window, and
+ capability before injecting the Cloud Workspace policy.
+4. Core stored both Workspaces, all active Memberships, both projected
+ Accounts, snapshot coverage, inbox entries, and cursor 5.
+5. Account-field-only projection revisions and Workspace directory revisions
+ remained independent, and cross-Workspace Account conflicts failed closed.
+
+### Real browser Cloud v2 flow
+
+A real local browser operated the Space frontend and backend:
+
+1. An existing legacy-Pod owner logged in and saw the automatically created
+ personal Workspace on Free.
+2. The page showed Free and Pro Workspace plans while retaining the legacy Pro
+ instance card, its Online state, URL, version, billing period, and actions.
+3. Annual Pro checkout through the configured EPay/Alipay rail was re-quoted
+ from the displayed USD plan price to `¥490.00 CNY`.
+4. The browser reached the EPay gateway with `money=490.00`; no USD amount was
+ sent through the CNY-only rail.
+5. A valid signed `TRADE_SUCCESS` callback returned `success`. An exact replay
+ also returned `success`, leaving one successful order and one Pro Workspace
+ subscription.
+6. Refreshing payment state cleared the pending indicator. The page then showed
+ the Pro annual period and managed-sandbox entitlement while the legacy Pod
+ remained Online.
+
+The browser run used the real Space UI and HTTP handlers. Its disposable local
+development harness added a same-origin Next.js rewrite only in the temporary
+worktree; that harness change was removed after the run.
+
+The legacy feature-flag branch was then covered by API/static checks and the
+production build: false or missing `cloud_v2_enabled` renders the original
+Welcome/Pod client; a failed web-config request renders an explicit retry
+instead of guessing a deployment mode.
+
+### Deliberate Core startup failure
+
+Core completed signed manifest verification and directory projection, then
+stopped at the Box readiness gate because the current nsjail backend cannot
+prove hard Workspace byte/inode quota enforcement. Connector shutdown and
+reconnect tasks were cleanly reaped; no event-loop or never-awaited coroutine
+warning remained.
+
+This is a successful fail-closed acceptance result, not a passing production
+Cloud boot.
+
+## Remaining production activation gates
+
+Cloud v2 must remain disabled until these gates are closed:
+
+- Box provides and proves hard byte and inode quotas for Workspace, Skill,
+ root, home, and tmp storage.
+- Plugin installation writable data receives an operator-owned hard total
+ disk quota.
+- Plugin and future networked Box workloads have tenant-safe egress/SSRF
+ policy.
+- Plugin Runtime adds jitter, global restart concurrency limiting, and a
+ Runtime-level circuit breaker, then passes systemic-failure injection.
+- M0 rolls Core and Plugin Runtime together until authenticated Runtime takeover
+ or an owner lease/fencing protocol exists.
+- Payment operations add scheduled reconciliation and alerting for stale
+ `processing` orders and persisted permanent fulfillment conflicts.
+- Cloud v2 subscription service periods are stored immutably and included in
+ recognized-revenue reporting.
+- Production migration Job, backup/rollback, PostgreSQL credential/network
+ boundaries, horizontal-replica fault injection, Workspace release/export,
+ deletion, and restore semantics are completed.
+
+These gates intentionally add no tenant-specific service. They are implemented
+inside the existing Space, Core, Plugin Runtime, Box Runtime, and PostgreSQL
+components to preserve the architecture goal: near-zero static cost for a new
+Workspace.
diff --git a/docs/multi-tenant/workspace-multi-user-architecture.md b/docs/multi-tenant/workspace-multi-user-architecture.md
new file mode 100644
index 000000000..b01388139
--- /dev/null
+++ b/docs/multi-tenant/workspace-multi-user-architecture.md
@@ -0,0 +1,941 @@
+# LangBot Workspace 多用户与 SaaS 多租户架构
+
+状态:`ARCHITECTURE BASELINE — isolation kernel implemented; SaaS activation gates remain`
+
+本文描述 Cloud v2 的目标架构和安全边界。详细的 Runtime、Box、PostgreSQL、pgvector 与 stdio MCP 决策以
+[pending-architecture-decisions.md](./pending-architecture-decisions.md) 为权威来源;已经落地的实现选择记录在
+[implementation-decisions.md](./implementation-decisions.md)。
+
+“隔离内核已实现”仅表示开源 Core/SDK 已具备多租户数据和运行时隔离所需的基础能力,
+不表示闭源控制面、计费、生产部署或 Cloud v2 已经可以上线。
+
+## 1. 架构决策摘要
+
+Cloud v2 采用以下模型:
+
+> SaaS 对外只有一个逻辑 LangBot 实例,全部 Workspace 都是该实例内的租户;
+> 开源 Core 提供完整隔离内核,闭源 Cloud Control Plane 管理 SaaS 目录、订阅、权益和计费。
+
+核心决策如下:
+
+1. `Workspace` 是数据、成员、权限、用量和不可信执行的租户边界,不是一个 Pod、namespace、数据库或独立 LangBot 部署。
+2. SaaS 注册 Account 时自动创建个人 Workspace;这只新增目录与业务记录,不创建租户专属服务、数据库、队列或 Runtime。
+3. OSS 每个 LangBot 实例只能存在一个 Workspace,但该 Workspace 可以有多个 Account、邀请和固定角色。
+4. SaaS 才允许一个 Account 拥有或加入多个 Workspace,并在 WebUI 中切换当前 Workspace。
+5. MVP 可以各运行一个 Core、Plugin Runtime 和 Box Runtime 进程;未来增加副本或 PostgreSQL shard 仍属于同一个逻辑实例的内部扩展,不改变产品模型和外部 API。
+6. 一个共享 Plugin Runtime 控制面管理所有 Workspace,但每个运行中的 plugin installation 独占一个 nsjail 进程;enabled-resident 是 desired semantics,只读代码和依赖可按已验证摘要共享。
+7. 一个共享 Box Runtime 管理所有 Workspace;首期符合 entitlement 的 Workspace 最多拥有一个持久 `global` 逻辑 sandbox,实际命令继续以 nsjail 子进程执行。
+8. SaaS 业务数据使用 PostgreSQL shared schema、应用层 scope 与 RLS 双重隔离;pgvector 位于同一个业务数据库并作为 SaaS 默认向量后端。
+9. stdio MCP 有独立实例开关,Cloud v2 首期强制关闭,不能由 Box availability 或套餐能力隐式开启。
+10. 闭源 Control Plane 可以作为模块化单体复用现有账户、支付和运营能力,但历史 Cloud 的租户专属部署模型不进入新架构。
+11. Workspace 创建、释放、export、单 Workspace restore 和在线迁移的具体流程仍待后续决策。
+
+本轮重构的最高目标是:
+
+> 共享可信控制面和基础设施池,隔离不可信执行单元;减少独立部署和常驻组件,使新增 Account 或空 Workspace 的静态成本接近零。
+
+减少组件数量不意味着合并安全边界。插件进程、sandbox、secret、可写文件和租户数据仍必须严格隔离。
+
+## 2. 范围与非目标
+
+### 2.1 本方案覆盖
+
+- OSS 单 Workspace 多用户、邀请和固定 RBAC。
+- SaaS 多 Workspace 账户、成员和 Workspace 切换模型。
+- HTTP、WebSocket、API Key、Bot、Webhook、后台任务和内部调用的可信 Workspace 上下文。
+- Bot、Pipeline、Provider、Knowledge、Plugin、MCP、RAG、Session、Storage 和 Monitoring 的租户隔离。
+- Plugin Runtime 与 Box Runtime 的共享控制面和进程级隔离。
+- SaaS PostgreSQL shared schema、RLS 与 pgvector 边界。
+- 开源 Core 与闭源 Control Plane 的职责、协议和故障边界。
+- 当前单副本运行和未来同一逻辑实例内横向扩展的兼容约束。
+- 分阶段实施、激活门禁和验收策略。
+
+### 2.2 本方案不覆盖
+
+- 兼容或原地升级历史 Cloud 的租户专属部署方案。
+- 为每个 Workspace 创建独立服务、数据库、schema、role、bucket、PVC、队列或 Runtime。
+- 当前阶段实现多副本调度、跨地域 active-active 或 PostgreSQL 在线分片迁移。
+- 第一版自定义角色、SAML、SCIM 或企业离线授权。
+- 第一版 Workspace 级 BYOK E2B WebUI 配置。
+- Cloud v2 首期 stdio MCP。
+- Workspace export、释放、单租户恢复和在线迁移的具体产品流程。
+
+历史客户数据、账户和财务记录如需迁移,应单独立项;旧部署拓扑不作为本架构的设计约束。
+
+## 3. 术语与不变量
+
+### 3.1 术语
+
+| 术语 | 定义 |
+| --- | --- |
+| Account | 登录主体。OSS 中是实例本地账户;SaaS 中是全局账户 |
+| Workspace | 逻辑 LangBot 实例内的租户,是资源、成员、权限、用量和不可信执行的首要边界 |
+| Membership | Account 与 Workspace 的关系,包含固定角色、状态和权限版本 |
+| Invitation | 邀请一个 Account 或邮箱加入 Workspace 的一次性凭证 |
+| Logical Instance | 对外唯一的 LangBot 服务与安全域,拥有稳定 `instance_uuid`,不等同于某个进程或 Pod |
+| Replica | Core、Plugin Runtime 或 Box Runtime 的短期内部运行副本,不是产品实体 |
+| Execution Generation | Workspace 执行所有权和撤销的单调代数,用于隔离旧任务、旧连接和故障转移 |
+| Billing Account | SaaS 付款主体,可以为一个或多个 Workspace 付费 |
+| Entitlement | Control Plane 签发、Core 与 Runtime 本地执行的功能和数值额度快照 |
+| Cloud Control Plane | 闭源 SaaS 控制面,管理全局身份、Workspace 目录、订阅、权益、计费和生命周期 |
+| LangBot Core | 开源数据面,执行 Bot、Pipeline、Plugin、MCP、RAG 等业务并实施最终授权与隔离 |
+
+当前代码中的 `placement_generation` 字段在迁移完成前保留兼容;其架构语义和目标命名均为
+`execution_generation`,不表达 Workspace 属于某个产品级部署单元。
+
+### 3.2 必须始终成立的不变量
+
+1. SaaS 只有一个稳定 `instance_uuid`;所有副本共享该身份。
+2. `replica_id`、`worker_id`、Pod 名称、进程地址和数据库连接地址都是短期运行信息,不能进入业务资源的永久主键或外部 URL。
+3. `workspace_uuid` 是租户数据、任务、缓存、文件、日志、用量和运行时隔离的稳定键,也是未来内部路由与分片的候选键。
+4. OSS 一个实例最多一个 Workspace;SaaS 才能激活多个 Workspace。
+5. 一个 Workspace 可以有多个 Account;一个 SaaS Account 可以加入多个 Workspace。
+6. 所有租户业务资源都具有非空 `workspace_uuid`,并使用 `(workspace_uuid, resource_uuid)` 定位。
+7. Workspace 选择器只是路由输入,不是授权凭证;服务端必须重新验证 Account、Membership、资源所有权和权限。
+8. API Key、Bot、Webhook、后台任务、Plugin 与 Box 调用从可信所有权或绑定派生 Workspace,不能信任调用方自报 scope。
+9. SaaS 缺少有效 Workspace 上下文时必须失败关闭,不能回退到第一个、最近或 OSS 默认 Workspace。
+10. Core 是资源访问、运行时授权和 entitlement 执行的最后一道边界;Control Plane 不同步代理每条消息或普通资源请求。
+11. 一个不可信插件进程只能属于一个 installation;一个 sandbox/session 只能属于一个 Workspace。
+12. execution generation 失效后,旧任务、连接、回调和副作用必须被拒绝。
+13. 本地进程表、缓存和临时目录都可重建,不能成为 desired state、撤销状态或业务数据的唯一真相。
+14. 创建空 Workspace 不启动插件 worker、sandbox 或租户专属常驻组件。
+15. 未来横向扩展不能改变 Workspace UUID、外部 API、权限模型或隔离语义。
+
+## 4. 产品与部署模型
+
+### 4.1 SaaS 逻辑拓扑
+
+```mermaid
+flowchart LR
+ User["Browser / API / Bot traffic"] --> Edge["SaaS Edge"]
+ User --> CP["Closed Cloud Control Plane
directory + subscription + billing"]
+ Edge --> Core["One logical LangBot instance
Core replica pool; MVP = 1"]
+ CP -->|"signed manifest, directory projection,
entitlement and desired state"| Core
+ Core -->|"usage outbox and observed state"| CP
+ Core --> PG["Shared PostgreSQL business database
RLS + pgvector"]
+ Core --> PluginRT["Shared Plugin Runtime
trusted supervisor"]
+ Core --> BoxRT["Shared Box Runtime
trusted supervisor"]
+ PluginRT --> PluginA["Workspace A installation
isolated nsjail process"]
+ PluginRT --> PluginB["Workspace B installation
isolated nsjail process"]
+ BoxRT --> SandboxA["Workspace A
persistent global logical sandbox"]
+ BoxRT --> SandboxB["Workspace B
persistent global logical sandbox"]
+ Core --> ObjectStore["Shared durable object storage
Workspace-scoped keys"]
+```
+
+这里的“一个逻辑实例”是一个服务、安全域和稳定身份,不是“永远只有一个 OS 进程”。
+MVP 不实现分布式,但从第一天保留内部扩展所需的身份、幂等、generation 和 owner 抽象。
+
+### 4.2 容量演进
+
+| 阶段 | 内部部署形态 | 新 Workspace 静态成本 | 启用条件 |
+| --- | --- | --- | --- |
+| M0 单副本 MVP | 一个 Core、一个共享 Plugin Runtime、一个共享 Box Runtime、一个 PostgreSQL business database | 只新增目录和业务行 | 当前目标 |
+| M1 同逻辑实例横向扩展 | 按容量增加 Core/Runtime 副本;使用 owner lease、fencing 和 generation;PostgreSQL 可增加 shared shard | 不创建 Workspace 专属部署 | 出现容量或可用性证据后 |
+| M2 Dedicated 资源等级 | 特定 workload 使用独享 worker pool、sandbox class 或 database shard,但沿用相同身份、协议和 schema | 仅购买该等级的客户承担 | 合规、驻留或超大负载需求 |
+
+M1 是 M0 的透明扩容,M2 是相同架构下的资源等级。外部 API 只认识稳定的
+`instance_uuid` 和 `workspace_uuid`,不认识 replica、worker、pool 或 shard。
+
+### 4.3 当前不做分布式时必须预留的能力
+
+1. 运行时协议携带稳定 `instance_uuid`、`workspace_uuid` 和 `execution_generation`,不依赖进程地址表达身份。
+2. Plugin installation 和 Box session 使用稳定 owner 抽象;启用第二个副本前再实现带 expiry、CAS 和 fencing token 的 lease。
+3. 创建、重试、回调、worker 注册和 outbox 使用稳定 idempotency key,重复投递不能产生第二个 owner 或副作用。
+4. Repository/UoW 不允许无边界跨 Workspace 事务;`workspace_uuid` 可直接作为未来 shard key。
+5. schema migration、后台任务扫描、监控聚合和运维接口不能假设永远只有一个 Core 进程。
+6. Runtime 重启通过 durable desired state reconciliation 恢复,不依赖原进程或本地 cache。
+7. 只有出现容量、可用性、地域或合规证据后才增加副本、lease store 或 shard router;预留协议不等于提前部署组件。
+
+### 4.4 组件边界
+
+- Core、Plugin Runtime 和 Box Runtime 必须保持独立进程身份、容器和 security context。M0 中 Core 与 Plugin Runtime
+ 需要处于同一 rollout/restart unit;在实现受认证 takeover 或 owner lease/fencing 前,Core 不能单独重启后接管仍存活的 Runtime。
+- Core 不能继承 nsjail、cgroup 或 mount namespace 所需的高权限。
+- Plugin Runtime 与 Box Runtime 不合并为一个高权限进程。
+- MVP 不新增 Runtime 专用数据库、Box 专用数据库、Kafka、Redis、租户级 scheduler 或 artifact service。
+- 可信 supervisor、数据库连接池、只读 artifact cache 和基础容量可以多租户共享。
+
+## 5. OSS 与 SaaS 产品行为
+
+### 5.1 能力矩阵
+
+| 能力 | OSS | SaaS |
+| --- | --- | --- |
+| Workspace 数量 | 实例固定一个 | Account 可拥有或加入多个,受 ProductPolicy 约束 |
+| Workspace 成员 | 多用户 | 多用户,受 entitlement 约束 |
+| 邀请成员 | 支持 | 支持 |
+| 固定 RBAC | 支持 | 支持 |
+| 自定义角色 | 不支持 | 后续商业能力 |
+| Workspace 创建 | 首次初始化创建唯一 Workspace | 注册自动创建个人 Workspace;后续创建受 ProductPolicy 约束 |
+| Workspace 切换 | 无需展示 | 支持 |
+| 订阅与计费 | 无远端依赖 | 闭源 Control Plane 管理 |
+| 租户隔离 | 完整实现 | 完整实现 |
+
+OSS edition policy 应表达为:
+
+```text
+workspace_limit = 1
+members_enabled = true
+invitations_enabled = true
+fixed_rbac_enabled = true
+multi_workspace_enabled = false
+```
+
+不能用 `member_limit = 1`、关闭邀请或移除 RBAC 来实现单租户限制。
+
+### 5.2 OSS 初始化和邀请
+
+首次初始化在一个事务中完成:
+
+1. 创建本地 Account。
+2. 创建实例唯一 Workspace。
+3. 创建 owner Membership。
+4. 创建默认 Pipeline、metadata 等 Workspace 初始资源。
+5. 标记实例初始化完成。
+
+初始化后默认关闭公开注册。后续用户由 owner/admin 创建一次性 Invitation,注册或登录后接受邀请并加入唯一 Workspace。
+OSS 后续注册不创建第二个 Workspace。未配置 SMTP 时,系统返回只展示一次的邀请链接供管理员通过可信渠道发送。
+
+### 5.3 SaaS 注册和邀请
+
+普通注册由 Control Plane 通过幂等工作流完成:
+
+1. 创建或确认全局 Account 与 AuthIdentity。
+2. 创建 personal Workspace 和 owner Membership。
+3. 创建初始 Subscription/Entitlement 投影。
+4. 完成 verified email、速率限制和基础风控。
+5. 将 Account、Workspace 和 Membership 投影到 Core。
+6. Core 达到要求的目录 revision 后返回可访问 route。
+
+注册只创建逻辑记录,不启动 Runtime 或租户专属基础设施。
+
+通过邀请注册的新用户也创建自己的 personal Workspace,同时加入受邀 Workspace;已注册用户接受邀请时只新增目标 Membership。
+个人 Workspace 与团队 Workspace 的付费关系必须由 ProductPolicy 明确,不允许代码根据名称或创建路径隐式推断。
+
+### 5.4 Invitation 安全规则
+
+- token 使用至少 256-bit 加密安全随机数,数据库只保存 hash。
+- token 具有 `expires_at`、`accepted_at`、`revoked_at`,只能使用一次。
+- Membership 创建与 token 消费在同一事务中提交。
+- Invitation 不能授予 owner;owner 转移使用独立流程。
+- SaaS 接受邀请时必须验证目标邮箱;OAuth 邮箱相同不能跳过 token 和显式确认。
+- Workspace 必须始终至少有一个 active owner;admin 不能移除或降级 owner。
+- 浏览器邀请链接把 secret 放在 URL fragment 中,页面读取后立即清除 fragment,并只短期保存在 `sessionStorage`。
+
+### 5.5 固定 RBAC
+
+Core 权威定义 `owner`、`admin`、`developer`、`operator` 和 `viewer` 固定角色。
+权限按能力划分,例如资源查看、资源管理、运行操作、成员管理、provider secret 管理、审计查看和数据导出。
+
+规则:
+
+- 普通资源可见性不自动授予 secret 可见性。
+- 跨 Workspace 猜测资源 UUID 返回 404,不泄露存在性。
+- 同 Workspace 资源存在但缺少权限时返回 403。
+- 最后一个 owner 不能被删除或降级。
+- 前端隐藏或禁用无权限入口只改善体验;后端仍必须执行所有授权检查。
+
+## 6. 开源与闭源职责边界
+
+### 6.1 LangBot Core OSS
+
+Core 负责:
+
+- 本地 Account、Workspace、Membership 和 OSS Invitation。
+- 固定 RBAC 与单 Workspace edition policy。
+- 业务资源及其 Workspace scope。
+- HTTP、WebSocket、后台任务和运行时请求上下文。
+- Plugin、MCP、RAG、Box、Session、Storage 和 Monitoring 隔离。
+- SaaS Account/Workspace/Membership 的版本化执行投影。
+- InstanceManifest、EntitlementSnapshot 和 Runtime 控制通道验证。
+- 通用 capability 与数值 quota enforcement。
+- UsageEvent/business outbox 和基础安全审计。
+
+Core 是 Bot、Pipeline、Model、Knowledge、Plugin installation、MCP configuration 和 Monitoring 数据的权威来源,
+也是每个业务和运行时请求的最终授权边界。
+
+### 6.2 Closed Cloud Control Plane
+
+Control Plane 负责:
+
+- SaaS 全局 Account、AuthIdentity、Session、OIDC 和后续 SSO。
+- SaaS Workspace、Membership 和 Invitation 的权威目录。
+- Workspace 创建、暂停、归档和删除工作流。
+- BillingAccount、Product、PlanVersion、Price、Subscription、Invoice、Refund 和 provider event。
+- Entitlement 计算、签名与版本。
+- Usage ledger、聚合、额度和欠费策略。
+- 实例 manifest、release、capacity、内部 desired state 和 observed state。
+- SaaS 运营后台、平台角色和高级审计。
+
+首期不把这些职责拆成多个租户、计费和调度微服务。推荐以一个独立于 Core 的闭源模块化单体承载,
+并通过模块边界复用已有账户、OAuth、支付、邮件和运营能力。历史 Cloud 的租户专属部署代码不复用。
+
+Control Plane 不保存 Bot、Pipeline、Model 或 Knowledge 等业务内容,也不代理普通消息执行。
+
+### 6.3 SaaS Adapter
+
+Core 中只保留薄的协议适配层:
+
+- 验证 InstanceManifest、Account token 和 JWKS。
+- 消费 DirectoryEvent 并写入本地投影。
+- 缓存并验证 EntitlementSnapshot。
+- 将 UsageEvent 写入 durable outbox。
+- 接收 execution desired state 并上报 observed state。
+
+适配层不得 monkey patch ORM、绕过 Core 权限检查或在普通资源请求中同步调用 Control Plane。
+
+### 6.4 Source of Truth
+
+| 数据 | OSS | SaaS |
+| --- | --- | --- |
+| Account、Workspace、Membership | Core 本地数据库 | Control Plane 权威,Core 保存版本化投影 |
+| Invitation | Core 本地数据库 | Control Plane 权威,不向 Core 投影 pending secret |
+| Bot、Pipeline、Model、KB、Plugin、MCP | Core | Core |
+| Subscription、Payment、Invoice、Usage ledger | 无远端依赖 | Control Plane |
+| Feature 和 quota | 本地 edition policy | Control Plane 签发,Core/Runtime 验证执行 |
+| Execution generation | OSS 固定本地值 | Control Plane desired state,Core 执行 |
+| 运行时授权 | Core | Core 根据本地投影和 entitlement 执行 |
+
+SaaS 不维护两套可写目录。Control Plane 是目录权威写模型;Core 只保存带 revision 的执行投影。
+
+## 7. 控制面协议
+
+### 7.1 InstanceManifest
+
+仅设置 `system.edition=cloud`、环境变量或前端 feature flag 不得启用 SaaS 多 Workspace。
+Cloud bootstrap 必须验证由预置根信任签名的 InstanceManifest,并据此安装闭源 Workspace policy。
+
+Manifest 至少绑定:
+
+```text
+iss, aud, sub, jti, iat, nbf, exp
+instance_uuid
+release
+capabilities
+tenant_isolation_version
+execution_generation
+delegated issuers and keyset revision
+```
+
+签名错误、audience 不匹配、过期、generation 回滚或信任链缺失时必须失败关闭,不能降级为 OSS 默认 Workspace。
+
+### 7.2 DirectoryEvent 与目录新鲜度
+
+Control Plane 通过 transactional outbox 发布 Account、Workspace 和 Membership 的版本化事件。
+Core 使用 inbox 按 `event_id` 去重,以 aggregate revision 拒绝旧写,并追踪连续应用水位。启动时读取一个 PostgreSQL
+`REPEATABLE READ` 事务内生成的签名全量 snapshot;运行时先消费携带当前 high-water 的签名事件页,再只请求该页涉及的 Workspace 签名增量。
+增量响应不携带新的事件 cursor,因此即使其内容已包含并发提交的后续 revision,也不能跳过尚未消费的事件。
+
+要求:
+
+- 事件和 batch 经过实例绑定的强认证与签名。
+- 重复、乱序、延迟、断流和全量 replay 都安全。
+- 删除使用 tombstone。
+- 新实例先导入带 high watermark 的 snapshot,再消费增量。
+- 常态目录更新成本与本页发生变化的 Workspace 数量相关,不得为每个 `directory.changed` 重新读取和投影全部 Workspace。
+- 每个 Core replica 独立保存进程内消费 cursor,以确保各自的 entitlement cache 都看到事件;共享 PostgreSQL 保存投影
+ high-water mark、全量 snapshot coverage 和 inbox。同一事件被多个 replica 消费时,第二个 replica 验证已有 receipt;
+ snapshot coverage 内缺少的 receipt 可以补写,coverage 之外缺失则失败关闭。只有本地 cursor 追平签名 high-water 后才续期 ready。
+- projection 未就绪或落后于授权 lease 要求时,交互与自动化请求按策略失败关闭。
+- SaaS pending Invitation、email 和 token hash 不进入 Core 投影。
+
+MVP 可采用一个共享、原子且可恢复的 Control Plane store;未来多副本不能继续使用进程内状态承担一次性 token 或目录水位。
+
+### 7.3 EntitlementSnapshot
+
+Entitlement 使用版本化签名快照,至少绑定:
+
+```text
+instance_uuid
+workspace_uuid
+plan_revision
+entitlement_revision
+status
+features
+limits
+nbf, exp, grace_until
+```
+
+Core 校验 issuer、audience、subject、instance、revision、时间和签名;旧 revision 不覆盖新快照。
+套餐名称和价格规则只存在于闭源 Control Plane,Core 与 Runtime 只理解通用 capability 和数值限额。
+
+Control Plane 故障时,已缓存且仍有效的快照可继续执行;过期后只能进入明确、有限的 grace 模式或失败关闭。
+
+### 7.4 UsageEvent 与 outbox
+
+用量事件 append-only、至少一次投递,Control Plane 按 `event_id` 去重。事件至少包含:
+
+```text
+event_id
+instance_uuid
+workspace_uuid
+execution_generation
+meter
+quantity_integer
+unit
+source
+occurred_at
+entitlement_revision
+schema_version
+```
+
+Core 不计算账单金额,也不在普通请求中同步扣费。业务写入与相应 business outbox 必须在同一事务中提交;
+generation-aware write fence 与 outbox 原子性尚是 SaaS 激活门禁。
+
+### 7.5 Desired state 与 observed state
+
+闭源控制面发布版本化的 release、capacity 和 execution desired state,Core/Runtime 幂等 reconcile 并上报 observed state。
+desired state 只描述同一逻辑实例内部的执行所有权和容量,不产生新的产品级实例或租户实体。
+
+Workspace 安全状态由 directory revision 决定,订阅状态由 entitlement revision 决定,执行撤销由
+execution generation 决定。三者取最严格有效状态,但任何通道都不能修改另一个通道的权威字段。
+
+## 8. 身份、鉴权与请求上下文
+
+### 8.1 上下文模型
+
+租户业务入口统一解析不可变的 `RequestContext`:
+
+```python
+@dataclass(frozen=True)
+class RequestContext:
+ instance_uuid: str
+ workspace_uuid: str
+ execution_generation: int
+ principal_type: str
+ principal_uuid: str
+ permissions: frozenset[str]
+ auth_method: str
+ entitlement_revision: int | None
+ request_id: str
+```
+
+不同入口的 Workspace 来源:
+
+| 入口 | Workspace 来源 |
+| --- | --- |
+| Browser Account token | `X-Workspace-Id` 只作候选;服务端校验 Membership |
+| API Key | key 记录绑定的 Workspace,忽略 caller selector |
+| Public Bot / Webhook | Bot 或 webhook route 的可信所有权 |
+| Background job | durable payload 中的完整 scope,执行前重新验证 generation |
+| Plugin Host API | 认证控制连接和 immutable action context |
+| Box operation | 已验证 entitlement、admission grant 和 Runtime namespace |
+| System operation | 显式、最小能力的 SystemContext,禁止隐式全局上下文 |
+
+禁止从模块全局变量、进程默认 Workspace、请求 payload 或“第一个 Workspace”推断 scope。
+
+### 8.2 Account token 与 Workspace discovery
+
+- 新 JWT 使用稳定 Account UUID 作为 `sub`,并绑定 issuer、当前 `instance_uuid` audience 和 expiry。
+- 账户级 Workspace discovery 是一个窄 bootstrap capability,只列出该 Account 的 active Membership,不能执行租户业务。
+- multi-Workspace 模式下,tenant route 缺少 selector 必须拒绝;OSS singleton 模式可由 policy 选择唯一 Workspace。
+- Account token 不直接证明任一 Workspace 权限;Membership 必须在服务端解析并验证状态与 revision。
+
+### 8.3 API Key、WebSocket 与长任务
+
+- API Key 只持久化 hash,raw secret 仅返回一次;记录绑定 Workspace、固定 scopes、状态、expiry 和 creator。
+- Dashboard WebSocket 在升级后认证,并在每条入站消息前重新验证 Account、Membership、权限、资源所有权和 generation。
+- 长时间 LLM、MCP、Plugin 或 Box 调用在产生副作用或接受结果前再次校验 execution generation。
+- 临时凭证交换绑定发起者、Workspace、instance 和 generation;其他 scope 查询返回与不存在相同的 404。
+
+### 8.4 错误语义
+
+| 场景 | 语义 |
+| --- | --- |
+| 未认证或 token 无效 | 401 |
+| 同 Workspace 资源存在但权限不足 | 403 |
+| 资源不存在或属于其他 Workspace | 404 |
+| edition / entitlement / quota 禁止 | 稳定领域错误码,不伪装为 500 |
+| execution generation 过期 | fail closed,并停止旧运行态 |
+| 未处理异常 | 稳定 `internal_error` + request ID;细节只进入服务端日志 |
+
+## 9. Core 数据模型
+
+### 9.1 Account、Workspace 与 Membership
+
+核心实体至少包含:
+
+```text
+Account
+ uuid
+ email_normalized
+ display_name
+ status
+ auth bindings
+
+Workspace
+ uuid
+ name
+ status
+ source: local | cloud_projection
+ directory_revision
+
+WorkspaceExecutionState
+ workspace_uuid
+ instance_uuid
+ execution_generation
+ status
+ write_fenced_at
+ revision
+
+WorkspaceMembership
+ workspace_uuid
+ account_uuid
+ role
+ status
+ directory_revision
+```
+
+约束:
+
+- Membership 对 `(workspace_uuid, account_uuid)` 唯一。
+- Workspace 的 source 不允许通过可变本地配置从 local 升级成 cloud projection。
+- Cloud projection 只有在 manifest、instance binding、目录 revision 和 execution state 均有效时才可路由。
+- OSS bootstrap 只创建或修复 local singleton Workspace。
+
+### 9.2 Invitation
+
+OSS Invitation 存在 Core 本地数据库;SaaS Invitation 只存在于闭源目录。
+
+```text
+WorkspaceInvitation
+ uuid
+ workspace_uuid
+ email_normalized
+ role
+ token_hash
+ expires_at
+ accepted_at
+ revoked_at
+ created_by
+```
+
+数据库约束必须保证同一 Workspace 与邮箱只有一个有效邀请,并保证 token hash 全局唯一。
+
+### 9.3 业务资源
+
+所有租户资源显式包含 `workspace_uuid`,包括但不限于:
+
+- Bot、Pipeline、Provider、Model、Knowledge Base 和 vector record。
+- Plugin installation、MCP configuration、API Key 和 webhook binding。
+- Query、Message、Session、Monitoring、Usage 和 AuditEvent。
+- Upload、ObjectRef、Skill、Runtime desired state 和 temporary credential session。
+
+唯一键、索引、缓存 key、object key、日志维度和幂等键都必须包含 Workspace scope。
+服务层不得暴露可绕过 Workspace 条件的普通 `get(id)`、`list()` 或 `delete(id)`。
+
+### 9.4 防御性约束
+
+- tenant table 的 `workspace_uuid` 非空并有外键。
+- SaaS PostgreSQL 关键表启用并强制 RLS。
+- 需要全局唯一的 opaque token 使用 hash 唯一索引,不依赖 Workspace 内唯一。
+- owner 保底、Membership revision、invitation one-shot 等规则同时由 service 和数据库事务保护。
+- 任何跨 Workspace 运维操作必须走显式受审计的 system capability,不得复用普通 repository。
+
+## 10. PostgreSQL、pgvector 与存储
+
+### 10.1 数据库边界
+
+- OSS 继续默认 SQLite,并可显式选择自托管 PostgreSQL。
+- SaaS 使用一个 PostgreSQL business database、一个 `public` shared schema 和共享连接池。
+- 创建 Workspace 不创建 database、schema、role 或专属连接池。
+- 每个 tenant transaction 使用 `SET LOCAL` 建立 scope,并由统一 TenantUnitOfWork 保证 context 与 SQL 使用同一事务和连接。
+- 应用层 Workspace scope 是第一道边界,`ENABLE` + `FORCE ROW LEVEL SECURITY` 是第二道边界。
+- runtime role 必须是非 owner、最小权限、无 superuser、无 `BYPASSRLS`、无 role membership 和跨 schema 权限。
+- schema、extension、policy 和 ACL 只由独立 release migrator 创建与验证;Cloud runtime 不执行 DDL。
+- PostgreSQL 仅承载业务数据和 pgvector,不成为 Plugin/Box 通用协调数据库、进程目录或新的控制面数据库。
+
+首期 migrator 和 runtime URL 必须连接同一个 host、port、database,但使用不同 role。
+生产部署还必须证明 runtime credential 无法连接 PostgreSQL 集群中的其他 database;专用 endpoint 或经验证的 HBA/proxy 隔离仍是激活门禁。
+
+### 10.2 Transaction 与后台任务
+
+- 一个 TenantUnitOfWork 只绑定一个 Workspace、一个 execution generation 和一个事务所有者任务。
+- 子任务不能继承并提交、回滚或关闭父任务的 tenant session。
+- 长时间 LLM 或网络等待不持有数据库连接;每次数据库 helper 打开短事务。
+- detached task 只在父事务提交后启动,并自行建立新 scope;父事务回滚时取消待启动任务。
+- generation-aware write fence 必须保持到 commit,并与 business outbox 原子提交;该能力完成前不得激活 SaaS 写流量。
+
+### 10.3 pgvector
+
+- SaaS 默认使用同一业务 PostgreSQL 中的 pgvector,不静默回退到 Chroma。
+- 向量身份至少为 `(workspace_uuid, knowledge_base_uuid, vector_id)`。
+- 向量操作使用相同 tenant context 与 RLS 契约。
+- embedding 维度显式存储和校验;不匹配时失败关闭,不截断、补齐或改用无界扫描。
+- extension、表、constraint 和 ANN index 由 release migration 创建。
+- OSS 默认仍可使用 SQLite + Chroma;选择 pgvector 时遵守相同 scope。
+
+### 10.4 Object storage
+
+- 大对象、plugin artifact、upload、knowledge 文件和 sandbox 文件不作为 PostgreSQL blob 存储。
+- durable object key 和 metadata 都包含 Workspace scope;临时 staging 可包含 generation,但稳定业务引用不能因未来 generation 切换而永久失效。
+- 现有 generation-scoped opaque key 在固定 generation 的 OSS 中安全,但 Cloud cutover 前必须实现稳定 final identity 或原子引用迁移。
+- public image 与 private document 使用不同 capability;不能把通用 upload key 当作公开读取凭证。
+
+## 11. Plugin Runtime
+
+### 11.1 共享 supervisor、独立 worker
+
+整个逻辑实例共享一个可信 Plugin Runtime 逻辑控制面;M0 由一个 supervisor replica 承担。新 Workspace 不创建专属 Runtime、连接、卷或进程。
+
+每个运行中的 plugin installation 独占一个 nsjail worker process tree;enabled-resident 是 desired semantics。worker 运行期间永久绑定:
+
+```text
+instance_uuid
+workspace_uuid
+execution_generation
+installation_uuid
+runtime_revision
+artifact_digest
+```
+
+插件不能通过 payload、Host API 参数、环境变量或重连改变该绑定。Supervisor 不在自身解释器中加载第三方插件代码。
+停用、删除、revision/generation 变化或 entitlement 撤销时,旧 worker 必须停止并失去 Host API 权限。
+
+### 11.2 文件和进程边界
+
+```text
+data/plugin-runtime/
+├── artifacts/sha256//code/ # 已验证、只读共享
+├── environments/sha256// # 原子发布、只读共享
+└── installations//
+ ├── home/ # 私有可写
+ ├── tmp/ # 私有可写
+ └── data/ # 私有持久数据
+```
+
+- 同插件同版本只有在 package digest 完全相同且完整性已验证时才共享只读代码。
+- dependency environment key 包含 artifact/requirements digest、Python ABI、Runtime version 和 installer schema。
+- installation 进程、配置、secret、home、tmp、data 和日志永不合并。
+- namespace、private `/proc`、mount、PID、IPC、UTS、cgroup 与 rlimit 阻止读取其他文件、枚举或 signal 其他进程。
+- Cloud 不从 artifact 自动加载 `.env`;secret 只由可信控制面按 installation 注入。
+- 插件 egress 必须阻止访问 Core loopback、Box Runtime、数据库和平台 metadata endpoint。
+
+### 11.3 统一资源上限
+
+资源限制只来自实例级 `data/config.yaml`,并支持现有环境变量覆写;plugin manifest 不能声明、放宽或覆盖。
+
+```yaml
+plugin:
+ worker:
+ max_cpus: 1.0
+ max_memory_mb: 512
+ max_pids: 128
+ max_open_files: 256
+ max_file_size_mb: 512
+ require_hard_limits: true
+```
+
+CPU、内存和 PID 使用 cgroup 硬限制,open files 和单文件大小使用 rlimit。
+Cloud deployment profile 强制 nsjail;硬限制不可用时 readiness 失败,不能降级为普通子进程。
+installation 总磁盘配额需要可原子拒绝写入的 quota provider,不能以目录扫描冒充硬限制。
+
+### 11.4 Desired state 与恢复
+
+- PostgreSQL 中的 installation desired state 与 durable binary storage 是权威状态。
+- Runtime 本地进程表、nsjail 目录、artifact/venv cache 都可重建。
+- Runtime 重连执行实例范围 full reconciliation,清理 stale worker 并恢复 enabled installation。
+- dependency preparation 失败记录在对应 installation,不启动半就绪 worker,也不阻塞其他 installation。
+- desired semantics 要求 enabled installation 常驻,不做 idle eviction;是否按负载回收以后再决定。
+- 当前 Supervisor 已在意外退出时通过 completion callback 和有界指数 backoff 恢复 enabled worker。
+ Cloud 激活前仍需加入 jitter、全局重启并发上限和 Runtime 级 circuit breaker,并验证系统性故障不会形成跨租户重启风暴。
+
+真实 Linux/nsjail/cgroup 与受控 egress 的 Cloud 部署验证尚未完成,是生产激活门禁。
+
+## 12. Box Runtime 与 stdio MCP
+
+### 12.1 共享 Box 控制面
+
+整个逻辑实例共享一个可信 Box Runtime 逻辑控制面;M0 由一个 Runtime replica 承担。Core 与 Runtime 控制通道绑定稳定 instance identity,
+每个 operation 绑定 `workspace_uuid`、`execution_generation`、session revision 和短期 admission grant。
+
+首期 entitlement 模型:
+
+```json
+{
+ "features": {
+ "managed_sandbox": true,
+ "external_sandbox": false
+ },
+ "limits": {
+ "managed_sandbox_sessions": 1
+ }
+}
+```
+
+闭源订阅模块把套餐映射为该通用 capability;Core 与 Runtime 不判断 `plan == pro`。
+预期 Pro 得到 `managed_sandbox_sessions = 1`,其他套餐为 `0`。
+
+### 12.2 Sandbox 模型
+
+- 合资格 Workspace 首次使用时懒创建一个持久 `global` 逻辑 session。
+- `global` 表示 Workspace 内默认逻辑 sandbox,不表示跨 Workspace 共享。
+- session TTL 不自动回收;Runtime 重启后进程和临时目录失效,但 `/workspace` 持久数据保留。
+- 每次普通命令在 Box Runtime 容器内启动一个 one-shot nsjail 子进程。
+- 首期禁止 managed background process 和 network,避免 session 被当成常驻共享主机。
+- Core 与 Runtime 通过认证 random-marker challenge 证明看到同一 durable volume,不能只比较路径字符串。
+- 文件同步、attachment 和 skill mount 沿用现有 nsjail 机制,但所有 host path 解析必须由可信 Workspace context 派生并防止 symlink/path escape。
+
+Cloud readiness 必须证明 cgroup、namespace、mount、Workspace/Skill/ephemeral byte quota 和 inode quota 均为硬限制。
+当前普通 nsjail backend 不具备全部硬磁盘能力,因此 Cloud Box 应失败关闭,直到绿地部署提供并验证真实 quota provider;
+不能把软目录扫描写成“生产已就绪”。
+
+### 12.3 外部 E2B
+
+非 Pro 用户后续可在 WebUI 配置 Workspace 自有的远程 E2B sandbox。该功能尚未实现,首期不纳入。
+未来 credential 必须属于 Workspace、加密存储且读取受 secret 权限保护,不消耗 Cloud managed sandbox 配额。
+
+### 12.4 stdio MCP 独立开关
+
+```yaml
+mcp:
+ stdio:
+ enabled: true
+```
+
+- OSS 默认 `true` 保持兼容。
+- Cloud v2 通过 `MCP__STDIO__ENABLED=false` 强制关闭。
+- 该 gate 独立于 `box.enabled`、managed sandbox entitlement 和 session quota。
+- gate 同时覆盖 create、update、test、bootstrap load 和最终 Runtime execution。
+- 已有 stdio 配置在 gate 关闭时保留但不启动,并返回明确的 feature-disabled 错误。
+- HTTP/SSE 等远程 MCP transport 不受影响。
+
+## 13. HTTP API 与 WebUI
+
+### 13.1 Core API
+
+OSS 与 SaaS 执行面共用通用 Workspace API:
+
+```text
+GET /api/v1/workspaces
+GET /api/v1/workspaces/{workspace_uuid}
+GET /api/v1/workspaces/{workspace_uuid}/members
+POST /api/v1/workspaces/{workspace_uuid}/invitations
+PATCH /api/v1/workspaces/{workspace_uuid}/members/{account_uuid}
+DELETE /api/v1/workspaces/{workspace_uuid}/members/{account_uuid}
+```
+
+Cloud policy 下,目录 mutation 由闭源 Control Plane 负责;Core 对本地创建、邀请和成员修改返回稳定的
+`control_plane_required`,只提供执行投影的安全读取。
+
+所有 tenant resource route 必须经过统一 decorator/middleware:
+
+1. 认证 principal。
+2. 解析可信 Workspace。
+3. 校验 Workspace/ExecutionState。
+4. 校验 Membership 或资源绑定。
+5. 校验 permission 和 entitlement。
+6. 创建 RequestContext 与 TenantUnitOfWork。
+
+### 13.2 SaaS Control Plane API
+
+SaaS 产品 API 包含:
+
+```text
+POST /cloud/workspaces
+GET /cloud/workspaces
+POST /cloud/workspaces/{workspace_uuid}/invitations
+POST /cloud/invitations/{token}/accept
+GET /cloud/workspaces/{workspace_uuid}/subscription
+POST /cloud/workspaces/{workspace_uuid}/checkout
+GET /cloud/workspaces/{workspace_uuid}/usage
+```
+
+这些 API 管理目录、产品和计费,不直接操作 Bot/Pipeline 等 Core 业务资源。
+
+### 13.3 WebUI
+
+OSS:
+
+- 首次注册进入唯一 Workspace。
+- owner/admin 可邀请成员并管理固定角色。
+- 不展示 Workspace 切换器和创建第二 Workspace 的入口。
+
+SaaS:
+
+- 登录先获取 Account 级 Workspace 列表,再显式选择当前 Workspace。
+- 当前 Workspace UUID 保存在受控客户端状态中;所有 tenant request 自动附带 selector。
+- 切换 Account 或 Workspace 时清理缓存、WebSocket、上传、表单、错误和 optimistic state,不能显示前一租户数据。
+- 页面 refresh、新 tab 和邀请跳转恢复同一个经过授权的 Workspace;失效 Membership 不回退到其他 Workspace。
+- UI 权限变化必须响应式更新,但 API 仍是最终授权边界。
+
+## 14. 故障、安全与降级
+
+### 14.1 Fail-closed 场景
+
+以下情况必须拒绝新的租户业务和副作用:
+
+- Cloud manifest 缺失、签名失败、audience 错误或回滚。
+- Account token、Membership、Workspace status 或 execution generation 无效。
+- 目录投影未就绪或落后于有效 lease 要求。
+- Entitlement 缺失、过期且不在明确 grace 范围内。
+- Runtime 控制通道认证失败或实例绑定不一致。
+- Plugin nsjail/cgroup hard limit 在 Cloud profile 下不可用。
+- Box 的任一硬存储或 namespace capability 无法证明。
+- PostgreSQL RLS、runtime role、schema、catalog 或 endpoint 隔离校验失败。
+- stdio MCP 在 Cloud profile 下被尝试启用。
+
+不能把上述错误静默降级为 OSS singleton、普通子进程、Chroma、软 quota 或 caller-supplied Workspace。
+
+### 14.2 撤销语义
+
+- Membership 删除或降权必须影响下一次 HTTP 请求,并使长连接在下一条消息前重新授权。
+- Workspace 暂停禁止新交互、自动化工作负载和新副作用;恢复只允许当前 generation。
+- entitlement 到期按 capability 明确停止新创建或新执行,不隐式删除已有数据。
+- generation 变化使旧 worker、session、callback、cached runtime object 和 outbox publisher 失效。
+- 控制面暂时不可达时,只能在有效签名快照和本地投影允许的范围内继续;过期后失败关闭。
+
+### 14.3 安全清单
+
+- 所有 identifier 使用不可猜 UUID,但不把随机性当成授权。
+- 所有 token/secret 只存 hash 或加密值,raw secret 一次展示。
+- 日志、trace、metric、cache 和 object key 都包含 Workspace 维度并过滤 secret。
+- Provider、Bot、Plugin、MCP 配置的 read response 递归遮蔽 credential。
+- Runtime control、debug、registration 和 attachment capability 分离,不能复用万能 secret。
+- untrusted code 不访问 Core loopback、数据库、其他 Runtime、宿主文件系统或 metadata endpoint。
+- bulk operation、后台扫描和 monitoring 聚合使用显式 tenant/system capability。
+- 所有跨 Workspace 运维操作记录 principal、reason、scope、request ID 和结果。
+
+## 15. 实现状态与 SaaS 激活门禁
+
+### 15.1 已实现的隔离内核
+
+当前分支已经实现或具备基础的部分包括:
+
+- OSS singleton Workspace、多 Account、Invitation 和固定 RBAC。
+- trusted RequestContext、Workspace-scoped repository 和资源所有权检查。
+- tenant-aware Plugin SDK protocol 与 Runtime installation binding。
+- shared Plugin Runtime / Box Runtime 控制协议和 execution generation fence。
+- stdio MCP 独立 gate。
+- PostgreSQL shared schema、transaction-local scope、FORCE RLS 与 pgvector adapter。
+- Cloud bootstrap 默认不可由普通配置激活,并对缺失安全能力失败关闭。
+
+这些是代码能力边界,不等于完成闭源 SaaS 产品或生产部署验收。
+
+### 15.2 尚未完成的激活门禁
+
+以下事项完成并取得真实环境证据前,不得宣称 Cloud v2 production-ready:
+
+1. 闭源 Control Plane 的全局目录、注册、邀请、订阅、计费、entitlement 签发和签名 manifest bootstrap;横向扩展前 OAuth exchange 与目录投影还必须使用原子共享存储。
+2. 普通业务写入贯穿 commit 的 generation-aware fence,以及与外部副作用同事务的 business outbox。
+3. generation cutover 后稳定的 durable object identity 或原子对象引用迁移。
+4. 所有 tenant-configurable outbound URL 的 SSRF 防护与 tenant-safe egress;Plugin Runtime 还需在真实 Linux/nsjail/cgroup v2 环境验证 namespace、资源限制和文件隔离。
+5. Plugin Runtime 已实现意外退出 worker 的 completion callback、有界 backoff 和自动恢复;Cloud 激活前增加全局重启风暴抑制并完成故障注入验证。
+6. Plugin installation data 的 production hard disk quota provider,能够在写入边界原子拒绝超额,不能以目录扫描代替。
+7. Box Runtime 的 production hard quota provider,包括 Workspace、Skill、root/tmp/home 的 byte 与 inode quota;真实部署还必须在启动和重连时通过共享卷 marker challenge。
+8. PostgreSQL runtime credential 的专用 endpoint 或 HBA/proxy 跨 database 隔离证明、生产 migration/rollback 流程,以及 legacy pgvector migration 失败后精确恢复 RLS/FORCE 并可安全重试的集成证据。
+9. 闭源目录事件、lease、snapshot、entitlement 和 usage/outbox 的重放、断流与灾难恢复验证。
+10. 真实浏览器多 Account/RBAC/邀请/刷新场景已完成;仍需生产 Runtime 重启、worker crash、断流、异常回滚和闭源 Control Plane 的 fault-injection 验收。
+
+### 15.3 有意暂缓的产品决策
+
+- Workspace 创建后的休眠、释放、删除和保留策略。
+- Workspace export 与单 Workspace restore。
+- 非 Pro Workspace 的 BYOK E2B WebUI。
+- 多副本 owner lease 的 store、TTL、fencing token 和转移顺序。
+- PostgreSQL shard resolver、在线迁移和 dedicated shard 产品规则。
+- artifact/cache 的签名来源、撤销、GC 和磁盘配额机制。
+- custom roles、SSO、SCIM 和企业合规能力。
+
+暂缓项不得被实现代码用隐式默认值提前固化。
+
+## 16. 实施顺序
+
+### Phase 0:契约和基线
+
+- 固定术语、RequestContext、角色矩阵、edition policy 和错误语义。
+- 建立升级备份、回滚和跨租户负向测试基线。
+
+### Phase 1:OSS tenancy kernel
+
+- Account、Workspace、Membership、Invitation。
+- singleton bootstrap、多用户邀请、RBAC 和前端权限。
+
+### Phase 2:数据与入口隔离
+
+- 为所有资源补充 Workspace scope。
+- HTTP、API Key、Bot、Webhook、WebSocket、后台任务和 storage 统一上下文。
+- SQLite migration recovery 与 PostgreSQL RLS 集成测试。
+
+### Phase 3:Runtime 与 SDK 隔离
+
+- Plugin installation binding、nsjail、资源上限和 artifact replay。
+- Box admission、session namespace、skill/attachment 文件边界。
+- MCP gate、RAG/vector 与 long-running generation revalidation。
+
+### Phase 4:闭源 SaaS 控制面
+
+- signed manifest bootstrap。
+- 全局目录、注册、邀请、Subscription、Entitlement 和 Usage ledger。
+- projection、lease、outbox、reconciliation 和运维后台。
+
+### Phase 5:生产部署激活
+
+- 真实 Linux Plugin/Box hard isolation。
+- PostgreSQL credential、migration、backup 和 rollback 验证。
+- 完整浏览器/API/Runtime E2E 和故障注入。
+- 所有激活门禁通过后才开启多 Workspace Cloud policy。
+
+### Phase 6:同逻辑实例内部扩展
+
+- 有容量证据后增加副本、owner lease 和 fencing。
+- 有地域、合规或规模证据后增加 shared/dedicated shard。
+- 保持外部身份、API 和 Workspace URL 不变。
+
+## 17. 测试与验收
+
+### 17.1 数据隔离
+
+- 两个 Workspace 使用相同 resource UUID、name、vector ID 和 cache key,不发生冲突或越权。
+- 故意遗漏应用层 Workspace filter 时,PostgreSQL RLS 仍阻止跨租户读写。
+- 连接池复用、异常回滚、子任务、后台任务和 transaction pooling 不残留 tenant context。
+- 跨 Workspace 猜测返回 404;同租户缺权限返回 403。
+
+### 17.2 产品行为
+
+- OSS 首个 Account 创建唯一 Workspace;第二个 Account 只能通过邀请加入;创建第二 Workspace 返回 edition error。
+- 邀请覆盖有效、已使用、撤销、过期、邮箱不匹配和并发接受。
+- owner/admin/developer/operator/viewer 的 API 和 WebUI 权限一致。
+- SaaS 普通注册和邀请注册都创建个人 Workspace,但不创建专属部署或 Runtime。
+
+### 17.3 Runtime
+
+- 两个 Workspace 安装同一已验证 artifact 时只共享只读 code/env,进程、secret、home/tmp/data、日志和 Host API 完全隔离。
+- cgroup、rlimit、namespace、egress 和 generation fence 在真实 Linux 环境生效。
+- Runtime restart/cache loss 通过 durable desired state 与 binary storage 恢复。
+- 两个 Workspace 的 Box session、files、process、skill、attachment 和 quota 完全隔离。
+- stdio MCP gate 对 UI、API、bootstrap 和最终 execution 同时生效。
+
+### 17.4 Control Plane 与故障
+
+- DirectoryEvent 重复、乱序、缺口、snapshot + replay 和过期 lease 均安全。
+- Entitlement 旧 revision、签名错误、过期和撤销均失败关闭。
+- UsageEvent 重放不重复计费;业务事务回滚不发送副作用。
+- Runtime、Core 或 Control Plane 重启不创建重复 Workspace、worker 或 sandbox。
+- manifest、数据库安全校验或 hard quota 缺失时实例保持不可激活,而不是静默降级。
+
+### 17.5 浏览器端到端
+
+真实浏览器至少覆盖:
+
+1. clean database 首位 owner 注册与 singleton Workspace bootstrap。
+2. owner 创建邀请,第二个用户注册/登录并接受。
+3. 角色在 viewer/operator/developer/admin 间变化时,导航、控制项和 API 结果同步变化。
+4. Account/Workspace 切换清空前一 scope 状态,refresh 和新 tab 恢复正确 Workspace。
+5. 第二 Workspace edition limit,以及 invitation used/revoked/expired/email mismatch 的可见错误。
+6. 直接 API 越权、伪造 selector 和跨租户 UUID 猜测不能绕过 UI。
+
+## 18. 最终结论
+
+Cloud v2 的产品模型只有一个逻辑 LangBot 实例和实例内多个 Workspace。
+当前选择单副本 MVP 是为了减少组件和新增租户成本,不是把单进程假设写进业务身份或协议。
+未来需要容量或高可用时,在同一逻辑实例内部增加 Core/Runtime 副本和 PostgreSQL shard,
+Workspace 的 UUID、权限、数据边界和外部 API 均保持不变。
+
+开源 Core 必须完整实现安全的 Workspace 隔离和 OSS 单 Workspace 多用户;闭源 Control Plane
+管理 SaaS 的全局目录、订阅、权益、计费和生命周期。共享可信控制面、连接池、只读 artifact 和数据库组件,
+同时让每个不可信插件进程、sandbox、secret、可写文件和 tenant transaction 保持独占边界,
+才能在不增加每租户部署的前提下最大化降低新增用户成本。
+
+在闭源控制面、事务 fence/outbox、真实 Runtime hard isolation、Box hard quota 和 PostgreSQL 生产隔离等门禁完成之前,
+本架构仍处于隔离内核阶段,不应被描述为可上线的 SaaS 多租户部署。
diff --git a/pyproject.toml b/pyproject.toml
index 53425aa01..d1e588fa8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,6 +39,7 @@ dependencies = [
"quart>=0.20.0",
"quart-cors>=0.8.0",
"requests>=2.33.0",
+ "regex>=2026.1.15",
"slack-sdk>=3.35.0",
"alembic>=1.15.0",
"sqlalchemy[asyncio]>=2.0.40",
@@ -70,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
- "langbot-plugin==0.4.17",
+ "langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@1d65ed301a6afc52150a998043f73cd6032c8162",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
diff --git a/pytest.ini b/pytest.ini
index a430a96e5..d86cbba9f 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -13,6 +13,12 @@ testpaths = tests
# Asyncio configuration
asyncio_mode = auto
+# Resource leaks are often reported during object finalization and wrapped by
+# pytest. Keep both forms fatal so --disable-warnings cannot hide them.
+filterwarnings =
+ error::ResourceWarning
+ error::pytest.PytestUnraisableExceptionWarning
+
# Output options
addopts =
-v
diff --git a/scripts/cloud_runtime_soak.py b/scripts/cloud_runtime_soak.py
new file mode 100644
index 000000000..527108d29
--- /dev/null
+++ b/scripts/cloud_runtime_soak.py
@@ -0,0 +1,1241 @@
+#!/usr/bin/env python3
+"""Run the final LangBot Cloud resource-stability acceptance gate.
+
+The short synthetic probes in this repository prove that selected registries
+plateau. This tool is for the production-candidate topology: it samples Core,
+Plugin Runtime and Box health endpoints together with their Linux process trees
+and cgroup v2 accounting, streams raw evidence to JSONL, and fails when the
+post-load tail shows a material leak or sustained CPU pressure.
+
+Examples:
+
+ uv run python scripts/cloud_runtime_soak.py \
+ --duration 24h --startup-grace 5m --cooldown 30m \
+ --endpoint core=http://langbot:5300/healthz \
+ --endpoint plugin=http://langbot-plugin-runtime:5400/healthz \
+ --endpoint box=http://langbot-box:5410/readyz \
+ --cgroup core=/sys/fs/cgroup/langbot \
+ --cgroup plugin=/sys/fs/cgroup/langbot-plugin-runtime \
+ --cgroup box=/sys/fs/cgroup/langbot-box \
+ --samples-file artifacts/cloud-soak-samples.jsonl \
+ --report-file artifacts/cloud-soak-report.json \
+ --workload uv run python tests/load/cloud_candidate_workload.py
+
+Run this from a node/sidecar that can read the target cgroups. A process target
+(``--pid name=PID``) is useful when cgroup paths are not directly mounted, but
+cgroup evidence is still required for the production gate because only cgroups
+report OOM, PID-limit and CPU-throttling events.
+"""
+
+from __future__ import annotations
+
+import argparse
+import concurrent.futures
+import contextlib
+import dataclasses
+import datetime as dt
+import json
+import math
+import os
+import re
+import signal
+import statistics
+import subprocess
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from collections import deque
+from collections.abc import Callable, Iterable, Mapping, Sequence
+from pathlib import Path
+from typing import Any, TextIO
+
+
+BYTES_PER_MIB = 1024 * 1024
+MAX_HTTP_BODY_BYTES = BYTES_PER_MIB
+MAX_FLATTENED_HTTP_METRICS = 256
+MAX_PROCESS_TREE_SIZE = 4096
+MAX_TARGETS = 32
+MAX_SAMPLER_THREADS = 8
+_DIRECT_HTTP_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+TARGET_NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$')
+COUNTER_SUFFIXES = (
+ '.memory.events.high',
+ '.memory.events.max',
+ '.memory.events.oom',
+ '.memory.events.oom_kill',
+ '.memory.events.oom_group_kill',
+ '.pids.events.max',
+)
+REJECTION_SUFFIXES = (
+ '.blocking_executor.global_rejected_total',
+ '.blocking_executor.scope_rejected_total',
+)
+RUNTIME_FAILURE_COUNTER_SUFFIXES = ('.restart_coordinator.circuit_open_total',)
+DRAIN_GAUGE_SUFFIXES = (
+ '.blocking_executor.pending',
+ '.restart_coordinator.active_launches',
+ '.restart_coordinator.gate_waiters',
+ '.restart_coordinator.half_open_probe_inflight',
+ '.restart_coordinator.open_remaining_seconds',
+ '.resources.runtimes.mcp_projection_retirements',
+ '.resources.runtimes.mcp_projection_reconcile_active',
+ '.resources.runtimes.message_aggregation_buffers',
+ '.resources.runtimes.message_aggregation_scopes',
+)
+TRANSIENT_GAUGE_SUFFIXES = (
+ '.resources.telemetry_tasks',
+ '.resources.query_pool.queued',
+ '.resources.runtimes.mcp_host_tasks',
+ '.resources.runtimes.mcp_dispatch_tasks',
+ '.resources.creating_session_tasks',
+ '.resources.closing_session_tasks',
+ '.resources.background_tasks',
+)
+CAPACITY_GAUGE_PAIRS = (
+ (
+ '.resources.directory.active_workspaces',
+ '.resources.directory.max_active_workspaces',
+ ),
+ (
+ '.resources.directory.last_batch_workspaces',
+ '.resources.directory.max_snapshot_workspaces',
+ ),
+ (
+ '.resources.directory.last_batch_memberships',
+ '.resources.directory.max_snapshot_memberships',
+ ),
+ (
+ '.resources.database_pool.checked_out',
+ '.resources.database_pool.configured_capacity',
+ ),
+)
+REQUIRED_CORE_CAPACITY_GAUGES = frozenset(suffix for pair in CAPACITY_GAUGE_PAIRS for suffix in pair)
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class Target:
+ name: str
+ kind: str
+ location: str
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class MetricSample:
+ monotonic_seconds: float
+ wall_time: str
+ metrics: dict[str, float]
+
+
+@dataclasses.dataclass(slots=True)
+class TargetState:
+ target: Target
+ samples: deque[MetricSample]
+ baseline_metrics: dict[str, float] | None = None
+ last_metrics: dict[str, float] | None = None
+ observed_max_metrics: dict[str, float] = dataclasses.field(default_factory=dict)
+ attempted_samples: int = 0
+ successful_samples: int = 0
+ failed_samples: int = 0
+ consecutive_failures: int = 0
+ max_consecutive_failures: int = 0
+ first_error: str | None = None
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class Thresholds:
+ max_memory_growth_bytes: int
+ max_memory_slope_bytes_per_hour: float
+ max_tail_cpu_cores: float
+ max_throttled_period_ratio: float
+ allow_rejections: bool
+ max_transient_gauge_growth: float
+ require_hard_limits: bool
+ max_event_loop_lag_ms: float
+ max_event_loop_p95_lag_ms: float
+ require_event_loop_metrics: bool
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class WorkloadResult:
+ executable: str | None
+ argument_count: int
+ started_at_seconds: float | None
+ completed_at_seconds: float | None
+ return_code: int | None
+ timed_out: bool
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class GateResult:
+ failures: tuple[str, ...]
+ warnings: tuple[str, ...]
+ targets: dict[str, dict[str, Any]]
+
+ @property
+ def passed(self) -> bool:
+ return not self.failures
+
+
+def parse_duration(value: str) -> float:
+ """Parse a positive duration such as 30s, 5m, 2h or 1d."""
+
+ match = re.fullmatch(r'\s*(\d+(?:\.\d+)?)\s*([smhd]?)\s*', value, re.IGNORECASE)
+ if match is None:
+ raise argparse.ArgumentTypeError(f'invalid duration: {value!r}')
+ amount = float(match.group(1))
+ if amount <= 0:
+ raise argparse.ArgumentTypeError('duration must be greater than zero')
+ multiplier = {
+ '': 1.0,
+ 's': 1.0,
+ 'm': 60.0,
+ 'h': 3600.0,
+ 'd': 86400.0,
+ }[match.group(2).lower()]
+ return amount * multiplier
+
+
+def _parse_named_values(values: Iterable[str], *, option: str) -> dict[str, str]:
+ parsed: dict[str, str] = {}
+ for value in values:
+ name, separator, location = value.partition('=')
+ name = name.strip()
+ location = location.strip()
+ if not separator or not TARGET_NAME_RE.fullmatch(name) or not location:
+ raise ValueError(f'{option} must use NAME=VALUE with a safe, non-empty name: {value!r}')
+ if name in parsed:
+ raise ValueError(f'duplicate target name {name!r} in {option}')
+ parsed[name] = location
+ return parsed
+
+
+def _safe_endpoint_url(value: str) -> str:
+ parsed = urllib.parse.urlsplit(value)
+ if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
+ raise ValueError(f'health endpoint must be an http(s) URL: {value!r}')
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
+ raise ValueError('health endpoint URLs must not contain credentials, query parameters or fragments')
+ return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path or '/', '', ''))
+
+
+def build_targets(
+ *,
+ endpoints: Iterable[str],
+ cgroups: Iterable[str],
+ pids: Iterable[str],
+) -> list[Target]:
+ targets: list[Target] = []
+ seen_names: set[str] = set()
+ for kind, option, values in (
+ ('endpoint', '--endpoint', endpoints),
+ ('cgroup', '--cgroup', cgroups),
+ ('process', '--pid', pids),
+ ):
+ for name, location in _parse_named_values(values, option=option).items():
+ qualified_name = f'{kind}:{name}'
+ if qualified_name in seen_names:
+ raise ValueError(f'duplicate target {qualified_name!r}')
+ seen_names.add(qualified_name)
+ if kind == 'endpoint':
+ location = _safe_endpoint_url(location)
+ elif kind == 'cgroup':
+ location = str(Path(location).resolve())
+ else:
+ try:
+ pid = int(location)
+ except ValueError as exc:
+ raise ValueError(f'{option} PID must be an integer: {location!r}') from exc
+ if pid <= 0:
+ raise ValueError(f'{option} PID must be greater than zero')
+ location = str(pid)
+ targets.append(Target(name=name, kind=kind, location=location))
+ return targets
+
+
+def _read_text(path: Path) -> str | None:
+ try:
+ return path.read_text(encoding='utf-8').strip()
+ except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
+ return None
+
+
+def _read_integer(path: Path) -> int | None:
+ value = _read_text(path)
+ if value is None or value == 'max':
+ return None
+ try:
+ return int(value)
+ except ValueError:
+ return None
+
+
+def _read_key_value_file(path: Path) -> dict[str, int]:
+ values: dict[str, int] = {}
+ text = _read_text(path)
+ if text is None:
+ return values
+ for line in text.splitlines():
+ fields = line.split()
+ if len(fields) != 2:
+ continue
+ try:
+ values[fields[0]] = int(fields[1])
+ except ValueError:
+ continue
+ return values
+
+
+def read_cgroup_snapshot(path: Path) -> dict[str, float]:
+ """Read bounded cgroup v2 resource accounting from *path*."""
+
+ if not path.is_dir():
+ raise RuntimeError(f'cgroup directory is unavailable: {path}')
+ metrics: dict[str, float] = {}
+ scalar_files = {
+ 'memory.current': 'memory.current_bytes',
+ 'memory.peak': 'memory.peak_bytes',
+ 'memory.swap.current': 'memory.swap.current_bytes',
+ 'memory.max': 'memory.max_bytes',
+ 'memory.swap.max': 'memory.swap.max_bytes',
+ 'pids.current': 'pids.current',
+ 'pids.max': 'pids.max',
+ }
+ for filename, metric_name in scalar_files.items():
+ value = _read_integer(path / filename)
+ if value is not None:
+ metrics[metric_name] = float(value)
+
+ for filename, prefix in (
+ ('cpu.stat', 'cpu'),
+ ('memory.events', 'memory.events'),
+ ('pids.events', 'pids.events'),
+ ):
+ for key, value in _read_key_value_file(path / filename).items():
+ metrics[f'{prefix}.{key}'] = float(value)
+
+ cpu_max = _read_text(path / 'cpu.max')
+ if cpu_max:
+ fields = cpu_max.split()
+ if len(fields) == 2:
+ if fields[0] != 'max':
+ with contextlib.suppress(ValueError):
+ metrics['cpu.quota_usec'] = float(int(fields[0]))
+ with contextlib.suppress(ValueError):
+ metrics['cpu.period_usec'] = float(int(fields[1]))
+ required_metrics = {
+ 'memory.current_bytes',
+ 'memory.events.oom',
+ 'cpu.usage_usec',
+ 'pids.current',
+ 'pids.events.max',
+ }
+ missing_metrics = sorted(required_metrics - metrics.keys())
+ if missing_metrics:
+ raise RuntimeError(f'cgroup v2 metrics missing from {path}: {", ".join(missing_metrics)}')
+ return metrics
+
+
+def _read_process_ids(proc_root: Path, root_pid: int) -> list[int]:
+ pending = [root_pid]
+ discovered: list[int] = []
+ seen: set[int] = set()
+ while pending and len(discovered) < MAX_PROCESS_TREE_SIZE:
+ pid = pending.pop()
+ if pid in seen:
+ continue
+ seen.add(pid)
+ if not (proc_root / str(pid)).is_dir():
+ continue
+ discovered.append(pid)
+ task_root = proc_root / str(pid) / 'task'
+ try:
+ task_ids = tuple(task_root.iterdir())
+ except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
+ task_ids = ()
+ for task_path in task_ids:
+ children = _read_text(task_path / 'children')
+ if not children:
+ continue
+ for child in children.split():
+ with contextlib.suppress(ValueError):
+ child_pid = int(child)
+ if child_pid not in seen:
+ pending.append(child_pid)
+ if pending:
+ raise RuntimeError(f'process tree exceeded {MAX_PROCESS_TREE_SIZE} processes')
+ return discovered
+
+
+def _read_process_status(path: Path) -> tuple[int, int]:
+ rss_bytes = 0
+ threads = 0
+ text = _read_text(path)
+ if text is None:
+ return rss_bytes, threads
+ for line in text.splitlines():
+ key, separator, raw_value = line.partition(':')
+ if not separator:
+ continue
+ fields = raw_value.split()
+ if not fields:
+ continue
+ if key == 'VmRSS':
+ with contextlib.suppress(ValueError):
+ rss_bytes = int(fields[0]) * 1024
+ elif key == 'Threads':
+ with contextlib.suppress(ValueError):
+ threads = int(fields[0])
+ return rss_bytes, threads
+
+
+def _read_process_cpu_seconds(path: Path, clock_ticks: int) -> float:
+ text = _read_text(path)
+ if text is None:
+ return 0.0
+ _, separator, remainder = text.rpartition(')')
+ if not separator:
+ return 0.0
+ fields = remainder.split()
+ if len(fields) <= 12:
+ return 0.0
+ try:
+ return (int(fields[11]) + int(fields[12])) / clock_ticks
+ except ValueError:
+ return 0.0
+
+
+def _count_open_fds(path: Path) -> int:
+ try:
+ with os.scandir(path) as entries:
+ return sum(1 for _ in entries)
+ except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
+ return 0
+
+
+def read_process_snapshot(
+ pid: int,
+ *,
+ proc_root: Path = Path('/proc'),
+ clock_ticks: int | None = None,
+) -> dict[str, float]:
+ """Aggregate RSS, CPU, threads and file descriptors for a process tree."""
+
+ process_ids = _read_process_ids(proc_root, pid)
+ if not process_ids:
+ raise RuntimeError(f'process {pid} is unavailable')
+ ticks = clock_ticks or int(os.sysconf('SC_CLK_TCK'))
+ rss_bytes = 0
+ threads = 0
+ open_fds = 0
+ cpu_seconds = 0.0
+ for process_id in process_ids:
+ process_root = proc_root / str(process_id)
+ process_rss, process_threads = _read_process_status(process_root / 'status')
+ rss_bytes += process_rss
+ threads += process_threads
+ open_fds += _count_open_fds(process_root / 'fd')
+ cpu_seconds += _read_process_cpu_seconds(process_root / 'stat', ticks)
+ return {
+ 'rss_bytes': float(rss_bytes),
+ 'cpu_seconds': cpu_seconds,
+ 'threads': float(threads),
+ 'open_fds': float(open_fds),
+ 'processes': float(len(process_ids)),
+ }
+
+
+def _flatten_numeric_json(
+ value: Any,
+ *,
+ prefix: str = 'body',
+ output: dict[str, float] | None = None,
+ depth: int = 0,
+) -> dict[str, float]:
+ if output is None:
+ output = {}
+ if depth > 8 or len(output) >= MAX_FLATTENED_HTTP_METRICS:
+ return output
+ if isinstance(value, bool):
+ output[prefix] = float(value)
+ elif isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value)):
+ output[prefix] = float(value)
+ elif isinstance(value, Mapping):
+ for key, nested_value in value.items():
+ if len(output) >= MAX_FLATTENED_HTTP_METRICS:
+ break
+ safe_key = str(key).replace('.', '_')[:128]
+ _flatten_numeric_json(
+ nested_value,
+ prefix=f'{prefix}.{safe_key}',
+ output=output,
+ depth=depth + 1,
+ )
+ return output
+
+
+def read_endpoint_snapshot(
+ url: str,
+ *,
+ timeout_seconds: float,
+ opener: Callable[..., Any] | None = None,
+) -> dict[str, float]:
+ """Fetch a bounded health response and flatten numeric resource gauges."""
+
+ request = urllib.request.Request(
+ url,
+ headers={
+ 'Accept': 'application/json, text/plain',
+ 'User-Agent': 'langbot-cloud-runtime-soak/1',
+ },
+ method='GET',
+ )
+ started_at = time.monotonic()
+ direct_opener = opener or _DIRECT_HTTP_OPENER.open
+ try:
+ response_context = direct_opener(request, timeout=timeout_seconds)
+ with response_context as response:
+ status = int(getattr(response, 'status', response.getcode()))
+ content_type = str(response.headers.get('Content-Type', ''))
+ body = response.read(MAX_HTTP_BODY_BYTES + 1)
+ except urllib.error.HTTPError as exc:
+ raise RuntimeError(f'health endpoint returned HTTP {exc.code}') from exc
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
+ raise RuntimeError(f'health endpoint request failed: {type(exc).__name__}') from exc
+ if len(body) > MAX_HTTP_BODY_BYTES:
+ raise RuntimeError(f'health endpoint response exceeded {MAX_HTTP_BODY_BYTES} bytes')
+ if not 200 <= status < 300:
+ raise RuntimeError(f'health endpoint returned HTTP {status}')
+
+ metrics = {
+ 'http.status': float(status),
+ 'http.latency_ms': (time.monotonic() - started_at) * 1000,
+ 'http.ok': 1.0,
+ }
+ if 'json' in content_type.lower() or body.lstrip().startswith((b'{', b'[')):
+ try:
+ payload = json.loads(body)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise RuntimeError('health endpoint returned invalid JSON') from exc
+ if isinstance(payload, Mapping):
+ if 'code' in payload and payload['code'] != 0:
+ raise RuntimeError(f'health endpoint reported code={payload["code"]!r}')
+ if payload.get('live') is False or payload.get('ready') is False:
+ raise RuntimeError('health endpoint reported not ready')
+ metrics.update(_flatten_numeric_json(payload))
+ return metrics
+
+
+def _sample_target(target: Target, *, http_timeout_seconds: float) -> dict[str, float]:
+ if target.kind == 'endpoint':
+ return read_endpoint_snapshot(target.location, timeout_seconds=http_timeout_seconds)
+ if target.kind == 'cgroup':
+ return read_cgroup_snapshot(Path(target.location))
+ if target.kind == 'process':
+ return read_process_snapshot(int(target.location))
+ raise AssertionError(f'unsupported target kind: {target.kind}')
+
+
+def _tail_metric_points(
+ samples: Sequence[MetricSample],
+ *,
+ analysis_start_seconds: float,
+ metric: str,
+) -> list[tuple[float, float]]:
+ return [
+ (sample.monotonic_seconds, sample.metrics[metric])
+ for sample in samples
+ if sample.monotonic_seconds >= analysis_start_seconds and metric in sample.metrics
+ ]
+
+
+def _robust_growth(points: Sequence[tuple[float, float]]) -> float:
+ width = max(1, min(len(points) // 5, 12))
+ initial = statistics.median(value for _, value in points[:width])
+ final = statistics.median(value for _, value in points[-width:])
+ return final - initial
+
+
+def _slope_per_hour(points: Sequence[tuple[float, float]]) -> float:
+ if len(points) < 2:
+ return 0.0
+ origin = points[0][0]
+ xs = [timestamp - origin for timestamp, _ in points]
+ ys = [value for _, value in points]
+ x_mean = statistics.fmean(xs)
+ y_mean = statistics.fmean(ys)
+ denominator = sum((value - x_mean) ** 2 for value in xs)
+ if denominator <= 0:
+ return 0.0
+ slope_per_second = (
+ sum((x_value - x_mean) * (y_value - y_mean) for x_value, y_value in zip(xs, ys, strict=True)) / denominator
+ )
+ return slope_per_second * 3600
+
+
+def _counter_delta(
+ first: MetricSample | Mapping[str, float],
+ last: MetricSample | Mapping[str, float],
+ metric: str,
+) -> float | None:
+ first_metrics = first.metrics if isinstance(first, MetricSample) else first
+ last_metrics = last.metrics if isinstance(last, MetricSample) else last
+ if metric not in first_metrics or metric not in last_metrics:
+ return None
+ return last_metrics[metric] - first_metrics[metric]
+
+
+def evaluate_gate(
+ states: Sequence[TargetState],
+ *,
+ analysis_start_seconds: float,
+ thresholds: Thresholds,
+) -> GateResult:
+ failures: list[str] = []
+ warnings: list[str] = []
+ summaries: dict[str, dict[str, Any]] = {}
+
+ for state in states:
+ target_id = f'{state.target.kind}:{state.target.name}'
+ target_summary: dict[str, Any] = {
+ 'attempted_samples': state.attempted_samples,
+ 'successful_samples': state.successful_samples,
+ 'failed_samples': state.failed_samples,
+ 'max_consecutive_failures': state.max_consecutive_failures,
+ 'first_error': state.first_error,
+ }
+ summaries[target_id] = target_summary
+ if state.failed_samples:
+ failures.append(
+ f'{target_id} had {state.failed_samples} failed samples '
+ f'(max consecutive {state.max_consecutive_failures}): {state.first_error}'
+ )
+ samples = list(state.samples)
+ tail_samples = [sample for sample in samples if sample.monotonic_seconds >= analysis_start_seconds]
+ target_summary['tail_samples'] = len(tail_samples)
+ if len(tail_samples) < 2:
+ failures.append(f'{target_id} has fewer than two successful tail samples')
+ continue
+
+ first = tail_samples[0]
+ last = tail_samples[-1]
+ tail_elapsed = last.monotonic_seconds - first.monotonic_seconds
+ target_summary['tail_elapsed_seconds'] = tail_elapsed
+ if tail_elapsed <= 0:
+ failures.append(f'{target_id} has no measurable tail interval')
+ continue
+
+ if state.target.kind == 'cgroup':
+ baseline_metrics = state.baseline_metrics or first.metrics
+ resource_limits = {
+ 'cpu_quota_usec': baseline_metrics.get('cpu.quota_usec'),
+ 'cpu_period_usec': baseline_metrics.get('cpu.period_usec'),
+ 'memory_max_bytes': baseline_metrics.get('memory.max_bytes'),
+ 'memory_swap_max_bytes': baseline_metrics.get('memory.swap.max_bytes'),
+ 'pids_max': baseline_metrics.get('pids.max'),
+ }
+ hard_limits = {
+ 'cpu': resource_limits['cpu_quota_usec'] is not None,
+ 'memory': resource_limits['memory_max_bytes'] is not None,
+ 'swap': resource_limits['memory_swap_max_bytes'] is not None,
+ 'pids': resource_limits['pids_max'] is not None,
+ }
+ target_summary['resource_limits'] = resource_limits
+ target_summary['hard_limits'] = hard_limits
+ if state.last_metrics is not None:
+ target_summary['memory.peak_bytes'] = state.last_metrics.get('memory.peak_bytes')
+ if thresholds.require_hard_limits:
+ missing_limits = sorted(name for name, enabled in hard_limits.items() if not enabled)
+ if missing_limits:
+ failures.append(f'{target_id} is missing hard cgroup limits: {", ".join(missing_limits)}')
+ for suffix in COUNTER_SUFFIXES:
+ metric = suffix.lstrip('.')
+ delta = _counter_delta(
+ state.baseline_metrics or first,
+ state.last_metrics or last,
+ metric,
+ )
+ if delta is not None:
+ target_summary[f'delta.{metric}'] = delta
+ if delta < 0:
+ failures.append(f'{target_id} monotonic counter {metric} reset; the cgroup may have restarted')
+ elif delta > 0:
+ failures.append(f'{target_id} increased {metric} by {delta:g}')
+
+ period_delta = _counter_delta(
+ state.baseline_metrics or first,
+ state.last_metrics or last,
+ 'cpu.nr_periods',
+ )
+ throttled_delta = _counter_delta(
+ state.baseline_metrics or first,
+ state.last_metrics or last,
+ 'cpu.nr_throttled',
+ )
+ if period_delta is not None and throttled_delta is not None and period_delta > 0:
+ throttled_ratio = max(throttled_delta, 0) / period_delta
+ target_summary['cpu.throttled_period_ratio'] = throttled_ratio
+ if throttled_ratio > thresholds.max_throttled_period_ratio:
+ failures.append(
+ f'{target_id} CPU throttled-period ratio {throttled_ratio:.3f} '
+ f'exceeded {thresholds.max_throttled_period_ratio:.3f}'
+ )
+ elif period_delta is not None and period_delta < 0:
+ failures.append(f'{target_id} monotonic CPU counters reset; the cgroup may have restarted')
+
+ memory_metric = {
+ 'cgroup': 'memory.current_bytes',
+ 'process': 'rss_bytes',
+ }.get(state.target.kind)
+ if memory_metric is not None:
+ memory_points = _tail_metric_points(
+ tail_samples,
+ analysis_start_seconds=analysis_start_seconds,
+ metric=memory_metric,
+ )
+ if len(memory_points) >= 2:
+ growth = _robust_growth(memory_points)
+ slope = _slope_per_hour(memory_points)
+ target_summary['memory.metric'] = memory_metric
+ target_summary['memory.robust_growth_bytes'] = growth
+ target_summary['memory.slope_bytes_per_hour'] = slope
+ if growth > thresholds.max_memory_growth_bytes and slope > thresholds.max_memory_slope_bytes_per_hour:
+ failures.append(
+ f'{target_id} {memory_metric} grew {growth / BYTES_PER_MIB:.2f} MiB '
+ f'at {slope / BYTES_PER_MIB:.2f} MiB/hour'
+ )
+ else:
+ warnings.append(f'{target_id} did not expose {memory_metric} throughout the tail')
+
+ cpu_metric = {
+ 'cgroup': 'cpu.usage_usec',
+ 'process': 'cpu_seconds',
+ }.get(state.target.kind)
+ if cpu_metric is not None:
+ cpu_delta = _counter_delta(first, last, cpu_metric)
+ if cpu_delta is not None:
+ cpu_seconds = cpu_delta / 1_000_000 if cpu_metric.endswith('_usec') else cpu_delta
+ average_cores = max(cpu_seconds, 0) / tail_elapsed
+ target_summary['cpu.average_cores'] = average_cores
+ if average_cores > thresholds.max_tail_cpu_cores:
+ failures.append(
+ f'{target_id} tail CPU averaged {average_cores:.3f} cores, '
+ f'above {thresholds.max_tail_cpu_cores:.3f}'
+ )
+
+ if state.target.kind == 'endpoint':
+ metric_keys = (
+ set(first.metrics)
+ | set(last.metrics)
+ | set(state.baseline_metrics or ())
+ | set(state.last_metrics or ())
+ )
+ if state.target.name == 'core':
+ for suffix in sorted(REQUIRED_CORE_CAPACITY_GAUGES):
+ matching_metrics = sorted(key for key in metric_keys if key.endswith(suffix))
+ if not matching_metrics:
+ failures.append(f'{target_id} did not expose required capacity gauge {suffix}')
+ continue
+ for metric in matching_metrics:
+ if any(metric not in sample.metrics for sample in tail_samples):
+ failures.append(
+ f'{target_id} did not expose required capacity gauge {metric} throughout the tail'
+ )
+ for suffix in REJECTION_SUFFIXES:
+ if thresholds.allow_rejections:
+ break
+ for metric in sorted(key for key in metric_keys if key.endswith(suffix)):
+ delta = _counter_delta(
+ state.baseline_metrics or first,
+ state.last_metrics or last,
+ metric,
+ )
+ if delta is not None and delta > 0:
+ failures.append(f'{target_id} increased {metric} by {delta:g}')
+ elif delta is not None and delta < 0:
+ failures.append(f'{target_id} monotonic counter {metric} reset; the runtime may have restarted')
+ for suffix in RUNTIME_FAILURE_COUNTER_SUFFIXES:
+ for metric in sorted(key for key in metric_keys if key.endswith(suffix)):
+ delta = _counter_delta(
+ state.baseline_metrics or first,
+ state.last_metrics or last,
+ metric,
+ )
+ if delta is not None and delta > 0:
+ failures.append(f'{target_id} increased {metric} by {delta:g}')
+ elif delta is not None and delta < 0:
+ failures.append(f'{target_id} monotonic counter {metric} reset; the runtime may have restarted')
+ for suffix in DRAIN_GAUGE_SUFFIXES:
+ for metric in sorted(key for key in metric_keys if key.endswith(suffix)):
+ values = [sample.metrics[metric] for sample in tail_samples if metric in sample.metrics]
+ if values and min(values) > 0:
+ failures.append(f'{target_id} kept {metric} above zero for the entire tail')
+ for suffix in TRANSIENT_GAUGE_SUFFIXES:
+ for metric in sorted(key for key in metric_keys if key.endswith(suffix)):
+ growth = _counter_delta(first, last, metric)
+ if growth is not None and growth > thresholds.max_transient_gauge_growth:
+ failures.append(f'{target_id} transient gauge {metric} grew by {growth:g} during the idle tail')
+ for current_suffix, capacity_suffix in CAPACITY_GAUGE_PAIRS:
+ for current_metric in sorted(key for key in metric_keys if key.endswith(current_suffix)):
+ capacity_metric = current_metric[: -len(current_suffix)] + capacity_suffix
+ observed = [
+ (sample.metrics[current_metric], sample.metrics.get(capacity_metric))
+ for sample in tail_samples
+ if current_metric in sample.metrics
+ ]
+ if any(capacity is None for _current, capacity in observed):
+ failures.append(f'{target_id} exposed {current_metric} without matching {capacity_metric}')
+ continue
+ if any(
+ current < 0 or capacity is None or capacity <= 0 or current > capacity
+ for current, capacity in observed
+ ):
+ failures.append(
+ f'{target_id} exceeded or invalidated capacity pair {current_metric} <= {capacity_metric}'
+ )
+
+ loop_running_metrics = sorted(key for key in metric_keys if key.endswith('.event_loop.running'))
+ if not loop_running_metrics:
+ if thresholds.require_event_loop_metrics:
+ failures.append(f'{target_id} did not expose event-loop health metrics')
+ else:
+ for metric in loop_running_metrics:
+ running_values = [sample.metrics[metric] for sample in tail_samples if metric in sample.metrics]
+ if not running_values or min(running_values) < 1:
+ failures.append(f'{target_id} event-loop monitor was not running throughout the tail')
+
+ recent_max_metrics = sorted(
+ key for key in state.observed_max_metrics if key.endswith('.event_loop.recent_max_lag_ms')
+ )
+ for metric in recent_max_metrics:
+ observed_max = state.observed_max_metrics[metric]
+ target_summary['event_loop.max_observed_recent_lag_ms'] = observed_max
+ if observed_max > thresholds.max_event_loop_lag_ms:
+ failures.append(
+ f'{target_id} event-loop lag reached '
+ f'{observed_max:.2f} ms, above '
+ f'{thresholds.max_event_loop_lag_ms:.2f} ms'
+ )
+
+ recent_p95_metrics = sorted(key for key in metric_keys if key.endswith('.event_loop.recent_p95_lag_ms'))
+ for metric in recent_p95_metrics:
+ p95_values = [sample.metrics[metric] for sample in tail_samples if metric in sample.metrics]
+ if not p95_values:
+ continue
+ maximum_p95 = max(p95_values)
+ target_summary['event_loop.max_tail_recent_p95_lag_ms'] = maximum_p95
+ if maximum_p95 > thresholds.max_event_loop_p95_lag_ms:
+ failures.append(
+ f'{target_id} event-loop recent p95 reached '
+ f'{maximum_p95:.2f} ms, above '
+ f'{thresholds.max_event_loop_p95_lag_ms:.2f} ms'
+ )
+
+ sample_total_metrics = sorted(key for key in metric_keys if key.endswith('.event_loop.samples_total'))
+ for metric in sample_total_metrics:
+ delta = _counter_delta(
+ state.baseline_metrics or first,
+ state.last_metrics or last,
+ metric,
+ )
+ if delta is not None and delta < 0:
+ failures.append(f'{target_id} event-loop sample counter reset; the runtime may have restarted')
+
+ return GateResult(
+ failures=tuple(dict.fromkeys(failures)),
+ warnings=tuple(dict.fromkeys(warnings)),
+ targets=summaries,
+ )
+
+
+def _utc_now() -> str:
+ return dt.datetime.now(dt.UTC).isoformat()
+
+
+def _open_output(path: Path | None) -> TextIO | None:
+ if path is None:
+ return None
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return path.open('w', encoding='utf-8')
+
+
+def _write_json_line(stream: TextIO | None, payload: Mapping[str, Any]) -> None:
+ if stream is None:
+ return
+ stream.write(json.dumps(payload, sort_keys=True, separators=(',', ':')) + '\n')
+ stream.flush()
+
+
+def _terminate_workload(process: subprocess.Popen[Any], *, grace_seconds: float = 10.0) -> None:
+ if process.poll() is not None:
+ return
+ if os.name == 'posix':
+ with contextlib.suppress(ProcessLookupError):
+ os.killpg(process.pid, signal.SIGTERM)
+ else:
+ process.terminate()
+ try:
+ process.wait(timeout=grace_seconds)
+ except subprocess.TimeoutExpired:
+ if os.name == 'posix':
+ with contextlib.suppress(ProcessLookupError):
+ os.killpg(process.pid, signal.SIGKILL)
+ else:
+ process.kill()
+ process.wait(timeout=grace_seconds)
+
+
+def run_soak(
+ *,
+ targets: Sequence[Target],
+ duration_seconds: float,
+ sample_interval_seconds: float,
+ startup_grace_seconds: float,
+ cooldown_seconds: float,
+ analysis_window_seconds: float,
+ http_timeout_seconds: float,
+ thresholds: Thresholds,
+ workload_command: Sequence[str] | None,
+ samples_stream: TextIO | None,
+) -> tuple[dict[str, Any], int]:
+ started_at_wall = _utc_now()
+ started_at = time.monotonic()
+ deadline = started_at + duration_seconds
+ max_tail_seconds = max(analysis_window_seconds, cooldown_seconds, sample_interval_seconds)
+ retained_samples = min(
+ max(4, math.ceil(max_tail_seconds / sample_interval_seconds) + 4),
+ 100_000,
+ )
+ states = [TargetState(target=target, samples=deque(maxlen=retained_samples)) for target in targets]
+ workload: subprocess.Popen[Any] | None = None
+ workload_started_at: float | None = None
+ workload_completed_at: float | None = None
+ workload_return_code: int | None = None
+ workload_timed_out = False
+ workload_start_error: str | None = None
+ interrupted = False
+ next_sample_at = started_at
+ previous_sigterm_handler: Any = None
+
+ def interrupt_on_sigterm(_signum: int, _frame: Any) -> None:
+ raise KeyboardInterrupt
+
+ with contextlib.suppress(ValueError):
+ previous_sigterm_handler = signal.signal(signal.SIGTERM, interrupt_on_sigterm)
+ sampler_pool = concurrent.futures.ThreadPoolExecutor(
+ max_workers=max(1, min(len(states), MAX_SAMPLER_THREADS)),
+ thread_name_prefix='langbot-soak-sampler',
+ )
+
+ try:
+ while True:
+ now = time.monotonic()
+ if workload is None and workload_command and now - started_at >= startup_grace_seconds:
+ workload_started_at = now
+ try:
+ workload = subprocess.Popen(
+ list(workload_command),
+ stdin=subprocess.DEVNULL,
+ stdout=sys.stderr,
+ stderr=sys.stderr,
+ start_new_session=True,
+ )
+ except OSError as exc:
+ workload_start_error = f'{type(exc).__name__}: {exc}'
+ break
+
+ if workload is not None and workload_return_code is None:
+ polled = workload.poll()
+ if polled is not None:
+ workload_return_code = polled
+ workload_completed_at = now
+
+ if now >= next_sample_at:
+ sample_wall_time = _utc_now()
+ sample_record: dict[str, Any] = {
+ 'schema_version': 1,
+ 'wall_time': sample_wall_time,
+ 'elapsed_seconds': now - started_at,
+ 'targets': {},
+ }
+ in_gate_window = now - started_at >= startup_grace_seconds
+ sample_futures = [
+ (
+ state,
+ sampler_pool.submit(
+ _sample_target,
+ state.target,
+ http_timeout_seconds=http_timeout_seconds,
+ ),
+ )
+ for state in states
+ ]
+ for state, sample_future in sample_futures:
+ state.attempted_samples += 1
+ target_id = f'{state.target.kind}:{state.target.name}'
+ try:
+ metrics = sample_future.result()
+ except Exception as exc:
+ error = f'{type(exc).__name__}: {exc}'
+ sample_record['targets'][target_id] = {'error': error}
+ if in_gate_window:
+ state.failed_samples += 1
+ state.consecutive_failures += 1
+ state.max_consecutive_failures = max(
+ state.max_consecutive_failures,
+ state.consecutive_failures,
+ )
+ if state.first_error is None:
+ state.first_error = error
+ else:
+ sample = MetricSample(
+ monotonic_seconds=now,
+ wall_time=sample_wall_time,
+ metrics=metrics,
+ )
+ state.samples.append(sample)
+ state.successful_samples += 1
+ state.consecutive_failures = 0
+ state.last_metrics = metrics
+ if in_gate_window and state.baseline_metrics is None:
+ state.baseline_metrics = dict(metrics)
+ if in_gate_window:
+ for metric, value in metrics.items():
+ previous_max = state.observed_max_metrics.get(metric)
+ if previous_max is None or value > previous_max:
+ state.observed_max_metrics[metric] = value
+ sample_record['targets'][target_id] = {'metrics': metrics}
+ _write_json_line(samples_stream, sample_record)
+ next_sample_at = max(
+ next_sample_at + sample_interval_seconds,
+ time.monotonic(),
+ )
+
+ now = time.monotonic()
+ if workload_command and workload_completed_at is not None:
+ if now >= min(deadline, workload_completed_at + cooldown_seconds):
+ break
+ elif now >= deadline:
+ if workload is not None and workload.poll() is None:
+ workload_timed_out = True
+ _terminate_workload(workload)
+ workload_return_code = workload.returncode
+ workload_completed_at = time.monotonic()
+ break
+
+ sleep_seconds = min(max(next_sample_at - now, 0.01), 0.25)
+ time.sleep(sleep_seconds)
+ except KeyboardInterrupt:
+ interrupted = True
+ finally:
+ if workload is not None and workload.poll() is None:
+ _terminate_workload(workload)
+ workload_return_code = workload.returncode
+ workload_completed_at = time.monotonic()
+ if previous_sigterm_handler is not None:
+ signal.signal(signal.SIGTERM, previous_sigterm_handler)
+ sampler_pool.shutdown(wait=True, cancel_futures=True)
+
+ completed_at = time.monotonic()
+ if workload_completed_at is not None:
+ analysis_start = workload_completed_at
+ else:
+ analysis_start = max(
+ started_at + startup_grace_seconds,
+ completed_at - analysis_window_seconds,
+ )
+ gate = evaluate_gate(
+ states,
+ analysis_start_seconds=analysis_start,
+ thresholds=thresholds,
+ )
+ failures = list(gate.failures)
+ warnings = list(gate.warnings)
+ if interrupted:
+ failures.append('soak was interrupted')
+ if workload_timed_out:
+ failures.append('workload exceeded the soak duration and was terminated')
+ if workload_command and workload is None:
+ if workload_start_error is not None:
+ failures.append(f'workload failed to start: {workload_start_error}')
+ else:
+ failures.append('workload did not start before the soak ended')
+ if workload_return_code not in (None, 0):
+ failures.append(f'workload exited with status {workload_return_code}')
+
+ workload_result = WorkloadResult(
+ executable=workload_command[0] if workload_command else None,
+ argument_count=max(len(workload_command) - 1, 0) if workload_command else 0,
+ started_at_seconds=(workload_started_at - started_at if workload_started_at is not None else None),
+ completed_at_seconds=(workload_completed_at - started_at if workload_completed_at is not None else None),
+ return_code=workload_return_code,
+ timed_out=workload_timed_out,
+ )
+ report = {
+ 'schema_version': 1,
+ 'verdict': 'pass' if not failures else 'fail',
+ 'started_at': started_at_wall,
+ 'completed_at': _utc_now(),
+ 'elapsed_seconds': completed_at - started_at,
+ 'analysis_start_seconds': analysis_start - started_at,
+ 'config': {
+ 'duration_seconds': duration_seconds,
+ 'sample_interval_seconds': sample_interval_seconds,
+ 'startup_grace_seconds': startup_grace_seconds,
+ 'cooldown_seconds': cooldown_seconds,
+ 'analysis_window_seconds': analysis_window_seconds,
+ 'http_timeout_seconds': http_timeout_seconds,
+ 'thresholds': dataclasses.asdict(thresholds),
+ 'targets': [
+ {
+ 'name': target.name,
+ 'kind': target.kind,
+ 'location': target.location,
+ }
+ for target in targets
+ ],
+ },
+ 'workload': dataclasses.asdict(workload_result),
+ 'failures': list(dict.fromkeys(failures)),
+ 'warnings': list(dict.fromkeys(warnings)),
+ 'targets': gate.targets,
+ }
+ return report, 0 if report['verdict'] == 'pass' else 1
+
+
+def _positive_float(value: str) -> float:
+ parsed = float(value)
+ if not math.isfinite(parsed) or parsed <= 0:
+ raise argparse.ArgumentTypeError('value must be a finite number greater than zero')
+ return parsed
+
+
+def _nonnegative_float(value: str) -> float:
+ parsed = float(value)
+ if not math.isfinite(parsed) or parsed < 0:
+ raise argparse.ArgumentTypeError('value must be a finite number greater than or equal to zero')
+ return parsed
+
+
+def _ratio(value: str) -> float:
+ parsed = float(value)
+ if not math.isfinite(parsed) or not 0 <= parsed <= 1:
+ raise argparse.ArgumentTypeError('ratio must be between zero and one')
+ return parsed
+
+
+def create_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description='Monitor LangBot Cloud process/cgroup resources and enforce a post-load stability gate.',
+ )
+ parser.add_argument('--endpoint', action='append', default=[], metavar='NAME=URL')
+ parser.add_argument('--cgroup', action='append', default=[], metavar='NAME=PATH')
+ parser.add_argument('--pid', action='append', default=[], metavar='NAME=PID')
+ parser.add_argument('--duration', type=parse_duration, default=parse_duration('24h'))
+ parser.add_argument('--sample-interval', type=parse_duration, default=parse_duration('15s'))
+ parser.add_argument('--startup-grace', type=parse_duration, default=parse_duration('5m'))
+ parser.add_argument('--cooldown', type=parse_duration, default=parse_duration('30m'))
+ parser.add_argument('--analysis-window', type=parse_duration, default=parse_duration('30m'))
+ parser.add_argument('--http-timeout', type=parse_duration, default=parse_duration('5s'))
+ parser.add_argument('--max-memory-growth-mib', type=_positive_float, default=64.0)
+ parser.add_argument('--max-memory-slope-mib-per-hour', type=_positive_float, default=32.0)
+ parser.add_argument('--max-tail-cpu-cores', type=_positive_float, default=0.5)
+ parser.add_argument('--max-throttled-period-ratio', type=_ratio, default=0.25)
+ parser.add_argument('--max-transient-gauge-growth', type=_nonnegative_float, default=0.0)
+ parser.add_argument(
+ '--allow-rejections',
+ action='store_true',
+ help='Do not fail when blocking-executor capacity rejection counters increase.',
+ )
+ parser.add_argument(
+ '--require-hard-limits',
+ action='store_true',
+ help='Require finite CPU, memory, swap and PID limits on every cgroup target.',
+ )
+ parser.add_argument(
+ '--max-event-loop-lag-ms',
+ type=_positive_float,
+ default=1000.0,
+ )
+ parser.add_argument(
+ '--max-event-loop-p95-lag-ms',
+ type=_positive_float,
+ default=250.0,
+ )
+ parser.add_argument(
+ '--allow-missing-event-loop-metrics',
+ action='store_true',
+ help='Permit endpoint targets that do not expose resources.event_loop.',
+ )
+ parser.add_argument('--samples-file', type=Path)
+ parser.add_argument('--report-file', type=Path)
+ parser.add_argument(
+ '--workload',
+ nargs=argparse.REMAINDER,
+ help='Command to start after startup grace; its completion begins the cooldown tail.',
+ )
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = create_parser()
+ args = parser.parse_args(argv)
+ try:
+ targets = build_targets(
+ endpoints=args.endpoint,
+ cgroups=args.cgroup,
+ pids=args.pid,
+ )
+ except ValueError as exc:
+ parser.error(str(exc))
+ if not targets:
+ parser.error('at least one --endpoint, --cgroup or --pid target is required')
+ if len(targets) > MAX_TARGETS:
+ parser.error(f'at most {MAX_TARGETS} targets may be monitored')
+ if args.startup_grace >= args.duration:
+ parser.error('--startup-grace must be shorter than --duration')
+ if args.sample_interval >= args.duration:
+ parser.error('--sample-interval must be shorter than --duration')
+ workload_command = tuple(args.workload or ())
+ if args.workload is not None and not workload_command:
+ parser.error('--workload requires a command')
+ thresholds = Thresholds(
+ max_memory_growth_bytes=int(args.max_memory_growth_mib * BYTES_PER_MIB),
+ max_memory_slope_bytes_per_hour=args.max_memory_slope_mib_per_hour * BYTES_PER_MIB,
+ max_tail_cpu_cores=args.max_tail_cpu_cores,
+ max_throttled_period_ratio=args.max_throttled_period_ratio,
+ allow_rejections=args.allow_rejections,
+ max_transient_gauge_growth=args.max_transient_gauge_growth,
+ require_hard_limits=args.require_hard_limits,
+ max_event_loop_lag_ms=args.max_event_loop_lag_ms,
+ max_event_loop_p95_lag_ms=args.max_event_loop_p95_lag_ms,
+ require_event_loop_metrics=not args.allow_missing_event_loop_metrics,
+ )
+ samples_stream = _open_output(args.samples_file)
+ try:
+ report, exit_code = run_soak(
+ targets=targets,
+ duration_seconds=args.duration,
+ sample_interval_seconds=args.sample_interval,
+ startup_grace_seconds=args.startup_grace,
+ cooldown_seconds=args.cooldown,
+ analysis_window_seconds=args.analysis_window,
+ http_timeout_seconds=args.http_timeout,
+ thresholds=thresholds,
+ workload_command=workload_command or None,
+ samples_stream=samples_stream,
+ )
+ finally:
+ if samples_stream is not None:
+ samples_stream.close()
+ rendered_report = json.dumps(report, indent=2, sort_keys=True)
+ if args.report_file is not None:
+ args.report_file.parent.mkdir(parents=True, exist_ok=True)
+ args.report_file.write_text(rendered_report + '\n', encoding='utf-8')
+ print(rendered_report)
+ return exit_code
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/scripts/runtime_resource_probe.py b/scripts/runtime_resource_probe.py
new file mode 100644
index 000000000..b7b61896e
--- /dev/null
+++ b/scripts/runtime_resource_probe.py
@@ -0,0 +1,466 @@
+#!/usr/bin/env python3
+"""Exercise long-lived Core registries and verify that they reach a plateau.
+
+This probe is intentionally separate from the default test suite because the
+audit profile creates tens of thousands of historical identities. It uses the
+real admission, eviction, and cleanup code while replacing external platform
+objects that are irrelevant to registry retention.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import gc
+import json
+import time
+import tracemalloc
+from dataclasses import asdict, dataclass
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import psutil
+
+from langbot.pkg.api.http.context import ExecutionContext
+
+# Import the Application graph before taskmgr. The production boot path has
+# this same ordering; importing taskmgr first exposes its historical cycle
+# through HTTP route annotations.
+from langbot.pkg.core import app as _core_app # noqa: F401
+from langbot.pkg.core.taskmgr import AsyncTaskManager
+from langbot.pkg.pipeline.pool import QueryPool
+from langbot.pkg.pipeline.ratelimit.algos.fixedwin import FixedWindowAlgo
+from langbot.pkg.plugin.connector import PluginRuntimeConnector
+from langbot.pkg.platform.sources.websocket_adapter import (
+ WebSocketMessage,
+ WebSocketSession,
+)
+from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
+from langbot.pkg.provider.session.sessionmgr import SessionManager
+from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
+
+
+@dataclass(frozen=True, slots=True)
+class ProbeScale:
+ query_churn_per_phase: int
+ session_churn_per_phase: int
+ rate_limit_churn_per_phase: int
+ task_churn_per_phase: int
+ websocket_churn_per_phase: int
+ empty_workspace_churn_per_phase: int
+
+
+SCALES = {
+ 'quick': ProbeScale(
+ query_churn_per_phase=2_500,
+ session_churn_per_phase=500,
+ rate_limit_churn_per_phase=10_000,
+ task_churn_per_phase=1_000,
+ websocket_churn_per_phase=500,
+ empty_workspace_churn_per_phase=1_000,
+ ),
+ 'audit': ProbeScale(
+ query_churn_per_phase=25_000,
+ session_churn_per_phase=2_500,
+ rate_limit_churn_per_phase=10_000,
+ task_churn_per_phase=5_000,
+ websocket_churn_per_phase=2_500,
+ empty_workspace_churn_per_phase=10_000,
+ ),
+}
+
+
+class _ProbeQuery:
+ """Small weak-referenceable stand-in for SDK Query construction."""
+
+ def __init__(self, **values):
+ self.__dict__.update(values)
+
+
+class _EmptyResult:
+ def all(self) -> list:
+ return []
+
+
+class _EmptyPluginRuntimeHandler:
+ async def reconcile_plugin_installations(self, _states: tuple) -> dict:
+ return {
+ 'applied': [],
+ 'removed': [],
+ 'missing_artifacts': [],
+ 'failed_installations': [],
+ }
+
+ def unregister_installation_binding(self, _binding) -> None:
+ raise AssertionError('An empty Workspace exposed an installation binding')
+
+
+@dataclass(frozen=True, slots=True)
+class ProcessSample:
+ rss_bytes: int
+ traced_current_bytes: int
+ traced_peak_bytes: int
+ asyncio_tasks: int
+ threads: int
+ open_fds: int | None
+
+
+def _sample_process() -> ProcessSample:
+ gc.collect()
+ process = psutil.Process()
+ try:
+ open_fds = process.num_fds()
+ except (AttributeError, psutil.Error):
+ open_fds = None
+ traced_current, traced_peak = tracemalloc.get_traced_memory()
+ return ProcessSample(
+ rss_bytes=process.memory_info().rss,
+ traced_current_bytes=traced_current,
+ traced_peak_bytes=traced_peak,
+ asyncio_tasks=len(asyncio.all_tasks()),
+ threads=process.num_threads(),
+ open_fds=open_fds,
+ )
+
+
+def _execution_context(index: int, *, query_uuid: str | None = None) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid='runtime-resource-probe',
+ workspace_uuid=f'workspace-{index}',
+ placement_generation=1,
+ bot_uuid='probe-bot',
+ pipeline_uuid='probe-pipeline',
+ query_uuid=query_uuid,
+ )
+
+
+class CoreRuntimeProbe:
+ """Own the same manager instances across two equal churn phases."""
+
+ def __init__(self) -> None:
+ self.query_pool = QueryPool(max_queries=100, max_queries_per_workspace=1)
+ app = SimpleNamespace(
+ event_loop=asyncio.get_running_loop(),
+ persistence_mgr=None,
+ instance_config=SimpleNamespace(
+ data={
+ 'concurrency': {'session': 1},
+ 'system': {
+ 'session_retention': {
+ 'idle_ttl_seconds': 86_400,
+ 'max_entries': 200,
+ 'max_entries_per_workspace': 200,
+ 'max_conversations_per_session': 20,
+ 'max_messages_per_conversation': 100,
+ },
+ 'task_retention': {
+ 'completed_limit': 200,
+ 'max_log_chars': 4_096,
+ 'max_active_user_tasks': 256,
+ 'max_active_user_tasks_per_workspace': 8,
+ },
+ },
+ }
+ ),
+ )
+ self.session_manager = SessionManager(app)
+ self.task_manager = AsyncTaskManager(app)
+ self.rate_limit = FixedWindowAlgo(SimpleNamespace())
+ self.websocket_session = WebSocketSession(
+ 'resource-probe',
+ max_conversations=200,
+ max_messages=100,
+ )
+ logger = SimpleNamespace(
+ debug=lambda *_args, **_kwargs: None,
+ info=lambda *_args, **_kwargs: None,
+ warning=lambda *_args, **_kwargs: None,
+ error=lambda *_args, **_kwargs: None,
+ )
+ self.empty_model_queries = 0
+
+ async def execute_empty(_statement):
+ self.empty_model_queries += 1
+ return _EmptyResult()
+
+ model_app = SimpleNamespace(
+ logger=logger,
+ persistence_mgr=SimpleNamespace(execute_async=execute_empty),
+ )
+ self.empty_model_manager = ModelManager(model_app)
+
+ async def runtime_disconnect_callback(_connector) -> None:
+ return None
+
+ plugin_app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
+ deployment=SimpleNamespace(mode='cloud'),
+ logger=logger,
+ )
+ self.empty_plugin_connector = PluginRuntimeConnector(
+ plugin_app,
+ runtime_disconnect_callback,
+ )
+ self.empty_plugin_connector.handler = _EmptyPluginRuntimeHandler()
+
+ async def validate_context(context):
+ return context
+
+ async def load_desired_states(_context):
+ return []
+
+ self.empty_plugin_connector._validate_execution_context = validate_context
+ self.empty_plugin_connector._load_workspace_desired_states = load_desired_states
+
+ async def initialize(self) -> None:
+ await self.rate_limit.initialize()
+
+ async def run_phase(self, scale: ProbeScale, phase: int) -> None:
+ offsets = {
+ 'query': (phase - 1) * scale.query_churn_per_phase,
+ 'session': (phase - 1) * scale.session_churn_per_phase,
+ 'rate': (phase - 1) * scale.rate_limit_churn_per_phase,
+ 'task': (phase - 1) * scale.task_churn_per_phase,
+ 'websocket': (phase - 1) * scale.websocket_churn_per_phase,
+ 'empty_workspace': ((phase - 1) * scale.empty_workspace_churn_per_phase),
+ }
+ await self._churn_queries(offsets['query'], scale.query_churn_per_phase)
+ await self._churn_sessions(offsets['session'], scale.session_churn_per_phase)
+ await self._churn_rate_limits(offsets['rate'], scale.rate_limit_churn_per_phase)
+ await self._churn_tasks(offsets['task'], scale.task_churn_per_phase)
+ self._churn_websocket_history(
+ offsets['websocket'],
+ scale.websocket_churn_per_phase,
+ )
+ await self._churn_empty_workspaces(
+ offsets['empty_workspace'],
+ scale.empty_workspace_churn_per_phase,
+ )
+ await asyncio.sleep(0)
+
+ async def _churn_queries(self, start: int, count: int) -> None:
+ def make_query(**values):
+ return _ProbeQuery(**values)
+
+ with patch(
+ 'langbot.pkg.pipeline.pool.pipeline_query.Query',
+ side_effect=make_query,
+ ):
+ for index in range(start, start + count):
+ context = _execution_context(index)
+ query = await self.query_pool.add_query(
+ bot_uuid='probe-bot',
+ launcher_type=LauncherTypes.PERSON,
+ launcher_id=f'launcher-{index}',
+ sender_id=f'sender-{index}',
+ message_event=SimpleNamespace(),
+ message_chain=SimpleNamespace(),
+ adapter=None,
+ pipeline_uuid='probe-pipeline',
+ execution_context=context,
+ )
+ removed = await self.query_pool.remove_query(query)
+ if not removed:
+ raise AssertionError('Query cleanup failed')
+
+ async def _churn_sessions(self, start: int, count: int) -> None:
+ for index in range(start, start + count):
+ workspace_index = index % 100
+ context = _execution_context(
+ workspace_index,
+ query_uuid=f'session-query-{index}',
+ )
+ query = SimpleNamespace(
+ launcher_type=LauncherTypes.PERSON,
+ launcher_id=f'launcher-{index}',
+ sender_id=f'sender-{index}',
+ bot_uuid='probe-bot',
+ pipeline_uuid='probe-pipeline',
+ query_uuid=context.query_uuid,
+ _execution_context=context,
+ )
+ await self.session_manager.get_session(query)
+
+ async def _churn_rate_limits(self, start: int, count: int) -> None:
+ for index in range(start, start + count):
+ context = _execution_context(
+ index % 1_000,
+ query_uuid=f'rate-query-{index}',
+ )
+ query = SimpleNamespace(
+ bot_uuid='probe-bot',
+ pipeline_uuid='probe-pipeline',
+ _execution_context=context,
+ pipeline_config={
+ 'safety': {
+ 'rate-limit': {
+ 'window-length': 60,
+ 'limitation': 100_000,
+ 'strategy': 'drop',
+ }
+ }
+ },
+ )
+ admitted = await self.rate_limit.require_access(
+ query,
+ LauncherTypes.PERSON,
+ f'rate-identity-{index}',
+ )
+ if not admitted:
+ raise AssertionError('Rate-limit registry rejected bounded churn')
+
+ async def _churn_tasks(self, start: int, count: int) -> None:
+ async def complete_immediately() -> None:
+ return None
+
+ for batch_start in range(start, start + count, 256):
+ batch_size = min(256, start + count - batch_start)
+ wrappers = [
+ self.task_manager.create_task(
+ complete_immediately(),
+ name=f'resource-probe-{batch_start + offset}',
+ )
+ for offset in range(batch_size)
+ ]
+ await asyncio.gather(*(wrapper.task for wrapper in wrappers))
+ await asyncio.sleep(0)
+
+ def _churn_websocket_history(self, start: int, count: int) -> None:
+ for index in range(start, start + count):
+ conversation_key = f'conversation-{index}'
+ response_id = f'response-{index}'
+ indexes = self.websocket_session.get_stream_message_indexes(conversation_key)
+ indexes[response_id] = 0
+ self.websocket_session.append_message(
+ conversation_key,
+ WebSocketMessage(
+ id=self.websocket_session.next_message_id(conversation_key),
+ role='assistant',
+ content='probe',
+ message_chain=[],
+ timestamp='1970-01-01T00:00:00+00:00',
+ is_final=True,
+ ),
+ )
+
+ async def _churn_empty_workspaces(self, start: int, count: int) -> None:
+ for index in range(start, start + count):
+ await self.empty_model_manager._load_workspace_models(_execution_context(index))
+ await self.empty_plugin_connector.reconcile_projected_workspaces(
+ _execution_context(index) for index in range(start, start + count)
+ )
+
+ def retained_state(self) -> dict[str, int]:
+ return {
+ 'query_cached': len(self.query_pool.cached_queries),
+ 'query_queued': len(self.query_pool.queries),
+ 'query_active_workspaces': len(self.query_pool.active_query_count_by_workspace),
+ 'query_scope_counters': len(self.query_pool.query_count_by_scope),
+ 'sessions': len(self.session_manager.session_list),
+ 'session_index': len(self.session_manager._session_index),
+ 'rate_limit_containers': len(self.rate_limit.containers),
+ 'task_records': len(self.task_manager.tasks),
+ 'websocket_conversations': len(self.websocket_session.message_lists),
+ 'websocket_stream_indexes': len(self.websocket_session.stream_message_indexes),
+ 'empty_model_scopes': len(self.empty_model_manager._scope_generations),
+ 'empty_model_providers': len(self.empty_model_manager.provider_dict),
+ 'empty_model_llms': len(self.empty_model_manager.llm_model_dict),
+ 'empty_plugin_workspace_sets': len(self.empty_plugin_connector._workspace_installations),
+ 'empty_plugin_installations': len(self.empty_plugin_connector._known_desired_states),
+ }
+
+ def assert_bounded(self) -> None:
+ state = self.retained_state()
+ expected_maximums = {
+ 'query_cached': 0,
+ 'query_queued': 0,
+ 'query_active_workspaces': 0,
+ 'query_scope_counters': 100,
+ 'sessions': 200,
+ 'session_index': 200,
+ 'rate_limit_containers': 10_000,
+ 'task_records': 200,
+ 'websocket_conversations': 200,
+ 'websocket_stream_indexes': 200,
+ 'empty_model_scopes': 0,
+ 'empty_model_providers': 0,
+ 'empty_model_llms': 0,
+ 'empty_plugin_workspace_sets': 0,
+ 'empty_plugin_installations': 0,
+ }
+ violations = {key: (state[key], maximum) for key, maximum in expected_maximums.items() if state[key] > maximum}
+ if violations:
+ raise AssertionError(f'Core retained-state limits failed: {violations}')
+
+
+async def _run(args: argparse.Namespace) -> dict:
+ scale = SCALES[args.scale]
+ tracemalloc.start()
+ started_at = time.monotonic()
+ probe = CoreRuntimeProbe()
+ await probe.initialize()
+
+ baseline = _sample_process()
+ await probe.run_phase(scale, 1)
+ probe.assert_bounded()
+ phase_one = _sample_process()
+ state_one = probe.retained_state()
+
+ await probe.run_phase(scale, 2)
+ probe.assert_bounded()
+ phase_two = _sample_process()
+ state_two = probe.retained_state()
+
+ if state_two != state_one:
+ raise AssertionError(f'Core retained state did not plateau: phase_one={state_one}, phase_two={state_two}')
+ traced_growth = phase_two.traced_current_bytes - phase_one.traced_current_bytes
+ rss_growth = phase_two.rss_bytes - phase_one.rss_bytes
+ max_traced_growth = int(args.max_traced_growth_mib * 1024 * 1024)
+ max_rss_growth = int(args.max_rss_growth_mib * 1024 * 1024)
+ if traced_growth > max_traced_growth:
+ raise AssertionError(f'Second-phase traced memory grew by {traced_growth} bytes (limit {max_traced_growth})')
+ if rss_growth > max_rss_growth:
+ raise AssertionError(f'Second-phase RSS grew by {rss_growth} bytes (limit {max_rss_growth})')
+
+ return {
+ 'component': 'langbot-core',
+ 'scale': args.scale,
+ 'work_per_phase': asdict(scale),
+ 'elapsed_seconds': round(time.monotonic() - started_at, 3),
+ 'samples': {
+ 'baseline': asdict(baseline),
+ 'phase_one': asdict(phase_one),
+ 'phase_two': asdict(phase_two),
+ },
+ 'second_phase_growth': {
+ 'rss_bytes': rss_growth,
+ 'traced_current_bytes': traced_growth,
+ },
+ 'retained_state': {
+ 'phase_one': state_one,
+ 'phase_two': state_two,
+ },
+ 'passed': True,
+ }
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--scale', choices=tuple(SCALES), default='quick')
+ parser.add_argument('--max-traced-growth-mib', type=float, default=8.0)
+ parser.add_argument('--max-rss-growth-mib', type=float, default=64.0)
+ parser.add_argument('--json', action='store_true', help='Print compact JSON')
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+ result = asyncio.run(_run(args))
+ if args.json:
+ print(json.dumps(result, sort_keys=True))
+ else:
+ print(json.dumps(result, indent=2, sort_keys=True))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/workspace_runtime_capacity_probe.py b/scripts/workspace_runtime_capacity_probe.py
new file mode 100644
index 000000000..4b41cc4ad
--- /dev/null
+++ b/scripts/workspace_runtime_capacity_probe.py
@@ -0,0 +1,561 @@
+#!/usr/bin/env python3
+"""Measure populated Workspace runtime replacement cost and retention.
+
+Unlike ``runtime_resource_probe.py``, which stresses historical request keys
+and empty tenants, this probe keeps one representative Provider, LLM,
+Embedding model, Rerank model, Pipeline, Bot, and Knowledge Base per Workspace.
+It then advances every Workspace to a new placement generation and verifies
+that old runtime objects are closed and collectible while active registry
+cardinality remains constant.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import gc
+import json
+import time
+import tracemalloc
+import weakref
+from dataclasses import asdict, dataclass
+from types import SimpleNamespace
+
+import psutil
+
+from langbot.pkg.api.http.context import ExecutionContext
+
+# Match the production import order; importing a leaf manager first exposes a
+# historical annotation cycle that the application graph resolves.
+from langbot.pkg.core import app as _core_app # noqa: F401
+from langbot.pkg.entity.persistence import bot as persistence_bot
+from langbot.pkg.entity.persistence import model as persistence_model
+from langbot.pkg.entity.persistence import pipeline as persistence_pipeline
+from langbot.pkg.entity.persistence import rag as persistence_rag
+from langbot.pkg.pipeline.pipelinemgr import PipelineManager
+from langbot.pkg.platform.botmgr import PlatformManager
+from langbot.pkg.provider.modelmgr import requester
+from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
+from langbot.pkg.provider.tools.loaders.mcp import MCPLoader
+from langbot.pkg.rag.knowledge.kbmgr import RAGManager
+from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
+
+
+@dataclass(frozen=True, slots=True)
+class ProbeScale:
+ workspaces: int
+
+
+SCALES = {
+ 'quick': ProbeScale(workspaces=250),
+ 'audit': ProbeScale(workspaces=5_000),
+}
+
+
+@dataclass(frozen=True, slots=True)
+class ProcessSample:
+ rss_bytes: int
+ traced_current_bytes: int
+ traced_peak_bytes: int
+ asyncio_tasks: int
+ threads: int
+ open_fds: int | None
+
+
+class _ProbeLogger:
+ def debug(self, *_args, **_kwargs) -> None:
+ return None
+
+ def info(self, *_args, **_kwargs) -> None:
+ return None
+
+ def warning(self, *_args, **_kwargs) -> None:
+ return None
+
+ def error(self, *_args, **_kwargs) -> None:
+ return None
+
+
+class _ProbeWorkspaceService:
+ instance_uuid = 'runtime-capacity-probe'
+
+ def __init__(self) -> None:
+ self.generations: dict[str, int] = {}
+ self.binding_lookups = 0
+
+ async def get_execution_binding(
+ self,
+ workspace_uuid: str,
+ *,
+ expected_generation: int | None = None,
+ ) -> WorkspaceExecutionBinding:
+ self.binding_lookups += 1
+ generation = self.generations[workspace_uuid]
+ if expected_generation is not None and expected_generation != generation:
+ raise AssertionError(f'stale probe generation {expected_generation} != {generation}')
+ return WorkspaceExecutionBinding(
+ instance_uuid=self.instance_uuid,
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ write_fenced=False,
+ state='active',
+ )
+
+
+class _ProbeRequester(requester.ProviderAPIRequester):
+ name = 'capacity-probe'
+ closed = 0
+
+ async def invoke_llm(
+ self,
+ query,
+ model,
+ messages,
+ funcs=None,
+ extra_args=None,
+ remove_think=False,
+ ):
+ return None
+
+ async def aclose(self) -> None:
+ type(self).closed += 1
+
+
+class _ProbeAdapter:
+ killed = 0
+
+ def __init__(self, _config, _logger) -> None:
+ self.listeners = []
+
+ def register_listener(self, event_type, listener) -> None:
+ self.listeners.append((event_type, listener))
+
+ async def kill(self) -> None:
+ type(self).killed += 1
+
+
+class _ProbeMCPSession:
+ closed = 0
+
+ def __init__(self, server_name: str) -> None:
+ self.server_name = server_name
+
+ async def shutdown(self) -> None:
+ type(self).closed += 1
+
+
+def _sample_process() -> ProcessSample:
+ gc.collect()
+ process = psutil.Process()
+ try:
+ open_fds = process.num_fds()
+ except (AttributeError, psutil.Error):
+ open_fds = None
+ traced_current, traced_peak = tracemalloc.get_traced_memory()
+ return ProcessSample(
+ rss_bytes=process.memory_info().rss,
+ traced_current_bytes=traced_current,
+ traced_peak_bytes=traced_peak,
+ asyncio_tasks=len(asyncio.all_tasks()),
+ threads=process.num_threads(),
+ open_fds=open_fds,
+ )
+
+
+class PopulatedWorkspaceProbe:
+ def __init__(self) -> None:
+ _ProbeRequester.closed = 0
+ _ProbeAdapter.killed = 0
+ _ProbeMCPSession.closed = 0
+ self.workspace_service = _ProbeWorkspaceService()
+ self.logger = _ProbeLogger()
+ self.app = SimpleNamespace(
+ logger=self.logger,
+ workspace_service=self.workspace_service,
+ persistence_mgr=SimpleNamespace(
+ mode=SimpleNamespace(value='cloud_runtime'),
+ ),
+ pipeline_config_meta_trigger={'name': 'trigger', 'stages': []},
+ pipeline_config_meta_safety={'name': 'safety', 'stages': []},
+ pipeline_config_meta_ai={'name': 'ai', 'stages': []},
+ pipeline_config_meta_output={'name': 'output', 'stages': []},
+ task_mgr=SimpleNamespace(
+ cancel_by_scope=lambda *_args, **_kwargs: None,
+ cancel_task=lambda *_args, **_kwargs: None,
+ ),
+ )
+ self.model_manager = ModelManager(self.app)
+ self.model_manager.requester_dict = {
+ _ProbeRequester.name: _ProbeRequester,
+ }
+ self.pipeline_manager = PipelineManager(self.app)
+ self.pipeline_manager.stage_dict = {}
+ self.rag_manager = RAGManager(self.app)
+ self.mcp_loader = MCPLoader(self.app)
+ self.platform_manager = PlatformManager(self.app)
+ self.platform_manager.adapter_dict = {
+ 'capacity-probe': _ProbeAdapter,
+ }
+ self.generation_refs: dict[
+ int,
+ list[weakref.ReferenceType],
+ ] = {}
+
+ def _context(
+ self,
+ workspace_uuid: str,
+ generation: int,
+ *,
+ bot_uuid: str | None = None,
+ pipeline_uuid: str | None = None,
+ ) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid=self.workspace_service.instance_uuid,
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ )
+
+ async def load_generation(self, workspaces: int, generation: int) -> None:
+ for index in range(workspaces):
+ workspace_uuid = f'workspace-{index}'
+ provider_uuid = f'provider-{index}'
+ llm_uuid = f'llm-{index}'
+ embedding_uuid = f'embedding-{index}'
+ rerank_uuid = f'rerank-{index}'
+ pipeline_uuid = f'pipeline-{index}'
+ bot_uuid = f'bot-{index}'
+ kb_uuid = f'knowledge-{index}'
+ mcp_server_name = f'mcp-{index}'
+ self.workspace_service.generations[workspace_uuid] = generation
+ context = self._context(workspace_uuid, generation)
+
+ runtime_provider = await self.model_manager.load_provider(
+ context,
+ persistence_model.ModelProvider(
+ uuid=provider_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Capacity Provider',
+ requester=_ProbeRequester.name,
+ base_url='https://capacity.invalid',
+ api_keys=['probe'],
+ ),
+ )
+ await self.model_manager.cache_provider(context, runtime_provider)
+
+ runtime_llm = await self.model_manager.load_llm_model_with_provider(
+ context,
+ persistence_model.LLMModel(
+ uuid=llm_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Capacity LLM',
+ provider_uuid=provider_uuid,
+ abilities=['func_call'],
+ extra_args={'temperature': 0.1},
+ ),
+ runtime_provider,
+ )
+ await self.model_manager.cache_llm_model(context, runtime_llm)
+ runtime_embedding = await self.model_manager.load_embedding_model_with_provider(
+ context,
+ persistence_model.EmbeddingModel(
+ uuid=embedding_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Capacity Embedding',
+ provider_uuid=provider_uuid,
+ extra_args={'dimensions': 1_024},
+ ),
+ runtime_provider,
+ )
+ await self.model_manager.cache_embedding_model(
+ context,
+ runtime_embedding,
+ )
+ runtime_rerank = await self.model_manager.load_rerank_model_with_provider(
+ context,
+ persistence_model.RerankModel(
+ uuid=rerank_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Capacity Rerank',
+ provider_uuid=provider_uuid,
+ extra_args={},
+ ),
+ runtime_provider,
+ )
+ await self.model_manager.cache_rerank_model(
+ context,
+ runtime_rerank,
+ )
+
+ pipeline_context = self._context(
+ workspace_uuid,
+ generation,
+ pipeline_uuid=pipeline_uuid,
+ )
+ await self.pipeline_manager.load_pipeline(
+ pipeline_context,
+ persistence_pipeline.LegacyPipeline(
+ uuid=pipeline_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Capacity Pipeline',
+ description='',
+ for_version='probe',
+ is_default=True,
+ stages=[],
+ config={},
+ extensions_preferences={},
+ ),
+ _binding_validated=True,
+ )
+ runtime_pipeline = self.pipeline_manager._pipelines_by_key[
+ (
+ self.workspace_service.instance_uuid,
+ workspace_uuid,
+ pipeline_uuid,
+ )
+ ]
+
+ runtime_kb = await self.rag_manager.load_knowledge_base(
+ context,
+ persistence_rag.KnowledgeBase(
+ uuid=kb_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Capacity Knowledge',
+ description='',
+ knowledge_engine_plugin_id=None,
+ collection_id=kb_uuid,
+ creation_settings={},
+ retrieval_settings={},
+ ),
+ _binding_validated=True,
+ )
+
+ await self.mcp_loader._assert_execution_active(context)
+ runtime_mcp = _ProbeMCPSession(mcp_server_name)
+ self.mcp_loader._register_session(
+ context,
+ mcp_server_name,
+ runtime_mcp,
+ )
+
+ bot_context = self._context(
+ workspace_uuid,
+ generation,
+ bot_uuid=bot_uuid,
+ )
+ runtime_bot = await self.platform_manager.load_bot(
+ bot_context,
+ persistence_bot.Bot(
+ uuid=bot_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Capacity Bot',
+ description='',
+ adapter='capacity-probe',
+ adapter_config={},
+ enable=True,
+ use_pipeline_uuid=pipeline_uuid,
+ pipeline_routing_rules=[],
+ ),
+ _binding_validated=True,
+ )
+
+ self.generation_refs.setdefault(generation, []).extend(
+ (
+ weakref.ref(runtime_provider),
+ weakref.ref(runtime_llm),
+ weakref.ref(runtime_embedding),
+ weakref.ref(runtime_rerank),
+ weakref.ref(runtime_pipeline),
+ weakref.ref(runtime_kb),
+ weakref.ref(runtime_mcp),
+ weakref.ref(runtime_bot),
+ )
+ )
+
+ await asyncio.sleep(0)
+
+ def retained_state(self) -> dict[str, int]:
+ return {
+ 'model_providers': len(self.model_manager.provider_dict),
+ 'llm_models': len(self.model_manager.llm_model_dict),
+ 'embedding_models': len(self.model_manager.embedding_model_dict),
+ 'rerank_models': len(self.model_manager.rerank_model_dict),
+ 'model_scopes': len(self.model_manager._scope_generations),
+ 'pipelines': len(self.pipeline_manager._pipelines_by_key),
+ 'pipeline_scopes': len(self.pipeline_manager._scope_generations),
+ 'knowledge_bases': len(self.rag_manager.knowledge_bases),
+ 'knowledge_scopes': len(self.rag_manager._scope_generations),
+ 'mcp_sessions': len(self.mcp_loader.sessions),
+ 'mcp_scopes': len(self.mcp_loader._scope_generations),
+ 'bots': len(self.platform_manager._bots_by_key),
+ 'bot_scopes': len(self.platform_manager._scope_generations),
+ 'requesters_closed': _ProbeRequester.closed,
+ 'adapters_killed': _ProbeAdapter.killed,
+ 'mcp_sessions_closed': _ProbeMCPSession.closed,
+ 'binding_lookups': self.workspace_service.binding_lookups,
+ }
+
+ def assert_generation_state(
+ self,
+ workspaces: int,
+ generation: int,
+ ) -> None:
+ state = self.retained_state()
+ cardinality_keys = (
+ 'model_providers',
+ 'llm_models',
+ 'embedding_models',
+ 'rerank_models',
+ 'model_scopes',
+ 'pipelines',
+ 'pipeline_scopes',
+ 'knowledge_bases',
+ 'knowledge_scopes',
+ 'mcp_sessions',
+ 'mcp_scopes',
+ 'bots',
+ 'bot_scopes',
+ )
+ invalid = {key: value for key in cardinality_keys if (value := state[key]) != workspaces}
+ if invalid:
+ raise AssertionError(f'populated Workspace cardinality mismatch: {invalid}')
+ expected_retired = (generation - 1) * workspaces
+ if state['requesters_closed'] != expected_retired:
+ raise AssertionError(f'retired requester count {state["requesters_closed"]} != {expected_retired}')
+ if state['adapters_killed'] != expected_retired:
+ raise AssertionError(f'retired adapter count {state["adapters_killed"]} != {expected_retired}')
+ if state['mcp_sessions_closed'] != expected_retired:
+ raise AssertionError(f'retired MCP session count {state["mcp_sessions_closed"]} != {expected_retired}')
+
+ def assert_generation_collected(self, generation: int) -> None:
+ gc.collect()
+ references = self.generation_refs.pop(generation)
+ retained = sum(reference() is not None for reference in references)
+ if retained:
+ raise AssertionError(f'{retained} generation-{generation} runtime objects remain reachable')
+
+
+async def _run(args: argparse.Namespace) -> dict:
+ scale = SCALES[args.scale]
+ tracemalloc.start()
+ probe = PopulatedWorkspaceProbe()
+ baseline = _sample_process()
+
+ phase_one_started = time.monotonic()
+ await probe.load_generation(scale.workspaces, 1)
+ phase_one_seconds = time.monotonic() - phase_one_started
+ probe.assert_generation_state(scale.workspaces, 1)
+ phase_one = _sample_process()
+ phase_one_state = probe.retained_state()
+
+ phase_two_started = time.monotonic()
+ await probe.load_generation(scale.workspaces, 2)
+ phase_two_seconds = time.monotonic() - phase_two_started
+ probe.assert_generation_state(scale.workspaces, 2)
+ probe.assert_generation_collected(1)
+ phase_two = _sample_process()
+ phase_two_state = probe.retained_state()
+
+ phase_three_started = time.monotonic()
+ await probe.load_generation(scale.workspaces, 3)
+ phase_three_seconds = time.monotonic() - phase_three_started
+ probe.assert_generation_state(scale.workspaces, 3)
+ probe.assert_generation_collected(2)
+ phase_three = _sample_process()
+ phase_three_state = probe.retained_state()
+
+ cardinality_keys = (
+ 'model_providers',
+ 'llm_models',
+ 'embedding_models',
+ 'rerank_models',
+ 'model_scopes',
+ 'pipelines',
+ 'pipeline_scopes',
+ 'knowledge_bases',
+ 'knowledge_scopes',
+ 'mcp_sessions',
+ 'mcp_scopes',
+ 'bots',
+ 'bot_scopes',
+ )
+ if any(
+ phase_two_state[key] != phase_one_state[key] or phase_three_state[key] != phase_one_state[key]
+ for key in cardinality_keys
+ ):
+ raise AssertionError(
+ 'populated Workspace registries did not plateau: '
+ f'phase_one={phase_one_state}, phase_two={phase_two_state}, '
+ f'phase_three={phase_three_state}'
+ )
+
+ traced_growth = phase_three.traced_current_bytes - phase_two.traced_current_bytes
+ rss_growth = phase_three.rss_bytes - phase_two.rss_bytes
+ max_traced_growth = int(args.max_traced_growth_mib * 1024 * 1024)
+ max_rss_growth = int(args.max_rss_growth_mib * 1024 * 1024)
+ if traced_growth > max_traced_growth:
+ raise AssertionError(f'replacement traced memory grew by {traced_growth} bytes (limit {max_traced_growth})')
+ if rss_growth > max_rss_growth:
+ raise AssertionError(f'replacement RSS grew by {rss_growth} bytes (limit {max_rss_growth})')
+ phase_ratio = max(
+ phase_two_seconds,
+ phase_three_seconds,
+ ) / max(phase_one_seconds, 0.000_001)
+ if phase_ratio > args.max_replacement_time_ratio:
+ raise AssertionError(f'replacement phase ratio {phase_ratio:.3f} exceeds {args.max_replacement_time_ratio:.3f}')
+
+ return {
+ 'component': 'langbot-populated-workspaces',
+ 'scale': args.scale,
+ 'workspaces': scale.workspaces,
+ 'passed': True,
+ 'phase_seconds': {
+ 'initial': round(phase_one_seconds, 3),
+ 'replacement_one': round(phase_two_seconds, 3),
+ 'replacement_two': round(phase_three_seconds, 3),
+ 'maximum_replacement_ratio': round(phase_ratio, 3),
+ },
+ 'samples': {
+ 'baseline': asdict(baseline),
+ 'phase_one': asdict(phase_one),
+ 'phase_two': asdict(phase_two),
+ 'phase_three': asdict(phase_three),
+ },
+ 'replacement_growth': {
+ 'rss_bytes': rss_growth,
+ 'traced_current_bytes': traced_growth,
+ },
+ 'retained_state': {
+ 'phase_one': phase_one_state,
+ 'phase_two': phase_two_state,
+ 'phase_three': phase_three_state,
+ },
+ }
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--scale', choices=tuple(SCALES), default='quick')
+ parser.add_argument('--max-traced-growth-mib', type=float, default=16.0)
+ parser.add_argument('--max-rss-growth-mib', type=float, default=64.0)
+ parser.add_argument(
+ '--max-replacement-time-ratio',
+ type=float,
+ default=3.0,
+ )
+ parser.add_argument('--json', action='store_true')
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+ result = asyncio.run(_run(args))
+ if args.json:
+ print(json.dumps(result, sort_keys=True))
+ else:
+ print(json.dumps(result, indent=2, sort_keys=True))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/skills/skills/langbot-deploy/SKILL.md b/skills/skills/langbot-deploy/SKILL.md
index 3a314fbfa..bfde2bd3e 100644
--- a/skills/skills/langbot-deploy/SKILL.md
+++ b/skills/skills/langbot-deploy/SKILL.md
@@ -27,6 +27,17 @@ The `all` / `box` profile starts three services:
- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to
spawn sandbox containers, so the **Box root host path and in-container path
must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`).
+ Its RPC and managed-process relay require a shared
+ `LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both
+ the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
+ never put it in `box.runtime.endpoint` or commit it to config.
+
+Every Compose deployment also needs one
+`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` shared by `langbot` and
+`langbot_plugin_runtime`. Generate it with `openssl rand -hex 32` and export it
+before `docker compose up`; the external Plugin Runtime fails closed when the
+token is empty or weak. Kubernetes uses the `langbot-plugin-runtime-control`
+Secret shown in `docker/kubernetes.yaml`.
With Box off, the dashboard/skills list stays visible (read-only) but sandbox
tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
diff --git a/skills/skills/langbot-dev/SKILL.md b/skills/skills/langbot-dev/SKILL.md
index 01ee06fd1..77a988ce8 100644
--- a/skills/skills/langbot-dev/SKILL.md
+++ b/skills/skills/langbot-dev/SKILL.md
@@ -65,10 +65,23 @@ Route auth is declared per-route via `AuthType` in
- `API_KEY` — `X-API-Key` or `Authorization: Bearer `.
- `USER_TOKEN_OR_API_KEY` — either.
-API keys are verified by `apikey_service.verify_api_key()`, which accepts:
-1. the **global key** from `config.yaml` `api.global_api_key` (no DB, no login,
- no `lbk_` prefix required), then
-2. **web-UI keys** (DB-stored, `lbk_` prefix).
+Authenticated routes receive an immutable `RequestContext` containing the
+principal, authorized Workspace membership, fixed-role permissions, instance,
+request id, and placement generation. A browser's `X-Workspace-Id` is only a
+selector and is always checked against the Account membership. Tenant services
+must accept this context (or an explicit trusted execution context) and fail
+closed when it is absent.
+
+API-key authentication accepts:
+
+1. the **global key** from `config.yaml` `api.global_api_key` only for a
+ community instance with exactly one local Workspace, then
+2. **web-UI keys** whose one-time `lbk_` secret is stored only as a hash and is
+ bound to one Workspace, explicit scopes, status, and optional expiry.
+
+An API key derives its Workspace from the key record and ignores a caller's
+Workspace selector. Public Bot/Webhook routes similarly derive Workspace from
+the opaque owning resource rather than a header.
Route groups self-register via `@group.group_class(name, path)` and are
discovered by `importutil.import_modules_in_pkg`.
diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md
index 94f25a3a4..fb1152fa4 100644
--- a/skills/skills/langbot-mcp-ops/SKILL.md
+++ b/skills/skills/langbot-mcp-ops/SKILL.md
@@ -29,13 +29,19 @@ Authorization: Bearer
Two kinds of key are accepted:
-1. **Web-UI key** — created in the web UI (sidebar → API Keys), prefixed `lbk_`,
- stored in the database.
+1. **Web-UI key** — created in the web UI (sidebar → API Keys), prefixed `lbk_`.
+ The secret is shown once; only its SHA-256 hash is stored. Each key is bound
+ to one Workspace and has explicit scopes, status, optional expiry, and
+ last-used metadata. The key determines the Workspace; callers cannot switch
+ it with `X-Workspace-Id`.
2. **Global API key** — set in `data/config.yaml` under `api.global_api_key`.
Requires no login session and no DB record; does not need the `lbk_` prefix.
- Leave empty to disable. See the `langbot-deploy` skill for config details.
+ It is accepted only by a community instance with exactly one local
+ Workspace and is disabled for SaaS multi-Workspace operation. Leave empty to
+ disable. See the `langbot-deploy` skill for config details.
-Requests without a valid key get `401 Unauthorized`.
+Invalid, revoked, or expired keys get `401 Unauthorized`. A valid key whose
+scopes do not authorize a tool gets `403 Forbidden`.
## Client configuration
@@ -66,7 +72,9 @@ The tools wrap the LangBot service layer. Current tools (v1):
Mutating tools (`create_*`, `update_*`) take a JSON object matching the same
shape as the corresponding HTTP API request body. Discover resources with the
-`list_*` / `get_*` tools before mutating; identifiers are UUIDs.
+`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
+`resource.view`; mutations require `resource.manage`. All service calls inherit
+the immutable Workspace context authenticated at the MCP transport boundary.
## How to use
@@ -93,7 +101,8 @@ shape as the corresponding HTTP API request body. Discover resources with the
- `/mcp` is the **server** LangBot exposes. The `/api/v1/mcp` routes are the
**client** side (managing external MCP servers LangBot connects to). Don't
confuse them.
-- A `401` means the key is wrong, missing, or (for the global key)
- `api.global_api_key` is empty in config.yaml.
+- A `401` means the key is wrong, missing, revoked, expired, or (for the global
+ key) `api.global_api_key` is empty or the instance is not an OSS singleton.
+- A `403` means the key is valid but lacks the permission required by the tool.
- The global key is plaintext in config.yaml — only enable it on trusted/internal
deployments and serve over HTTPS.
diff --git a/src/langbot/__main__.py b/src/langbot/__main__.py
index 485598296..06043c339 100644
--- a/src/langbot/__main__.py
+++ b/src/langbot/__main__.py
@@ -20,8 +20,7 @@ asciiart = r"""
"""
-async def main_entry(loop: asyncio.AbstractEventLoop):
- """Main entry point for LangBot"""
+def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description='LangBot')
parser.add_argument(
'--standalone-runtime',
@@ -36,7 +35,20 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
default=False,
)
parser.add_argument('--debug', action='store_true', help='Debug mode / 调试模式', default=False)
- args = parser.parse_args()
+ subparsers = parser.add_subparsers(dest='command')
+ migrate_parser = subparsers.add_parser('migrate', help='Run an operator-only database migration')
+ migrate_parser.add_argument(
+ '--cloud',
+ action='store_true',
+ required=True,
+ help='Migrate and validate the Cloud PostgreSQL business database',
+ )
+ return parser
+
+
+async def main_entry(loop: asyncio.AbstractEventLoop):
+ """Main entry point for LangBot"""
+ args = _build_parser().parse_args()
if args.standalone_runtime:
from langbot.pkg.utils import platform
@@ -55,22 +67,26 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
print(asciiart)
- # Check dependencies
- from langbot.pkg.core.bootutils import deps
+ # A release migration is a deterministic one-shot deployment Job. It must
+ # fail with the current image when a dependency is absent, never mutate its
+ # environment and ask an orchestrator to restart it.
+ if args.command != 'migrate':
+ from langbot.pkg.core.bootutils import deps
- missing_deps = await deps.check_deps()
+ missing_deps = await deps.check_deps()
- if missing_deps:
- print('以下依赖包未安装,将自动安装,请完成后重启程序:')
- print(
- 'These dependencies are missing, they will be installed automatically, please restart the program after completion:'
- )
- for dep in missing_deps:
- print('-', dep)
- await deps.install_deps(missing_deps)
- print('已自动安装缺失的依赖包,请重启程序。')
- print('The missing dependencies have been installed automatically, please restart the program.')
- sys.exit(0)
+ if missing_deps:
+ print('以下依赖包未安装,将自动安装,请完成后重启程序:')
+ print(
+ 'These dependencies are missing, they will be installed automatically, '
+ 'please restart the program after completion:'
+ )
+ for dep in missing_deps:
+ print('-', dep)
+ await deps.install_deps(missing_deps)
+ print('已自动安装缺失的依赖包,请重启程序。')
+ print('The missing dependencies have been installed automatically, please restart the program.')
+ sys.exit(0)
# Check configuration files
from langbot.pkg.core.bootutils import files
@@ -83,6 +99,12 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
for file in generated_files:
print('-', file)
+ if args.command == 'migrate':
+ from langbot.pkg.persistence.release_migration import run_cloud_release_migration_from_config
+
+ await run_cloud_release_migration_from_config(loop)
+ return
+
from langbot.pkg.core import boot
await boot.main(loop)
diff --git a/src/langbot/libs/coze_server_api/client.py b/src/langbot/libs/coze_server_api/client.py
index 54fb47495..6ce035c94 100644
--- a/src/langbot/libs/coze_server_api/client.py
+++ b/src/langbot/libs/coze_server_api/client.py
@@ -6,6 +6,22 @@ from typing import Dict, List, Any, AsyncGenerator
import os
from pathlib import Path
+from langbot.pkg.utils import httpclient
+
+_MAX_COZE_RESPONSE_BYTES = 16 * 1024 * 1024
+_MAX_COZE_EVENT_BYTES = 1024 * 1024
+_MAX_COZE_MEDIA_BYTES = 10 * 1024 * 1024
+
+
+def _read_local_media_limited(path: Path) -> bytes:
+ if path.stat().st_size > _MAX_COZE_MEDIA_BYTES:
+ raise ValueError('Coze upload exceeds the size limit')
+ with path.open('rb') as handle:
+ body = handle.read(_MAX_COZE_MEDIA_BYTES + 1)
+ if len(body) > _MAX_COZE_MEDIA_BYTES:
+ raise ValueError('Coze upload exceeds the size limit')
+ return body
+
class AsyncCozeAPIClient:
def __init__(self, api_key: str, api_base: str = 'https://api.coze.cn'):
@@ -58,19 +74,24 @@ class AsyncCozeAPIClient:
if isinstance(file, Path):
if not file.exists():
raise ValueError(f'File not found: {file}')
- with open(file, 'rb') as f:
- file = f.read()
+ file = await asyncio.to_thread(_read_local_media_limited, file)
# 处理文件路径字符串
elif isinstance(file, str):
if not os.path.isfile(file):
raise ValueError(f'File not found: {file}')
- with open(file, 'rb') as f:
- file = f.read()
+ file = await asyncio.to_thread(
+ _read_local_media_limited,
+ Path(file),
+ )
# 处理文件对象
elif hasattr(file, 'read'):
- file = file.read()
+ file = await asyncio.to_thread(file.read, _MAX_COZE_MEDIA_BYTES + 1)
+ if not isinstance(file, (bytes, bytearray)):
+ raise ValueError('Unsupported Coze upload type')
+ if len(file) > _MAX_COZE_MEDIA_BYTES:
+ raise ValueError('Coze upload exceeds the size limit')
session = await self.coze_session()
url = f'{self.api_base}/v1/files/upload'
@@ -87,13 +108,18 @@ class AsyncCozeAPIClient:
if response.status == 401:
raise Exception('Coze API 认证失败,请检查 API Key 是否正确')
- response_text = await response.text()
+ response_text = (
+ await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_COZE_EVENT_BYTES,
+ )
+ ).decode('utf-8', errors='replace')
if response.status != 200:
raise Exception(f'文件上传失败,状态码: {response.status}, 响应: {response_text}')
try:
- result = await response.json()
- except json.JSONDecodeError:
+ result = json.loads(response_text)
+ except (json.JSONDecodeError, TypeError):
raise Exception(f'文件上传响应解析失败: {response_text}')
if result.get('code') != 0:
@@ -158,7 +184,15 @@ class AsyncCozeAPIClient:
if response.status != 200:
raise Exception(f'Coze API 流式请求失败,状态码: {response.status}')
+ total_bytes = 0
+ chunk_type = 'message'
+ chunk_data = ''
async for chunk in response.content:
+ total_bytes += len(chunk)
+ if total_bytes > _MAX_COZE_RESPONSE_BYTES:
+ raise Exception('Coze API stream exceeds the runtime limit')
+ if len(chunk) > _MAX_COZE_EVENT_BYTES:
+ raise Exception('Coze API event exceeds the runtime limit')
chunk = chunk.decode('utf-8')
if chunk != '\n':
if chunk.startswith('event:'):
diff --git a/src/langbot/libs/deerflow_api/client.py b/src/langbot/libs/deerflow_api/client.py
index b66bf7e2e..dbf25ead7 100644
--- a/src/langbot/libs/deerflow_api/client.py
+++ b/src/langbot/libs/deerflow_api/client.py
@@ -12,10 +12,26 @@ from collections.abc import AsyncGenerator
import httpx
+from langbot.pkg.utils import httpclient
+
from .errors import DeerFlowAPIError
SSE_MAX_BUFFER_CHARS = 1_048_576
+SSE_MAX_TOTAL_BYTES = 16 * 1024 * 1024
+ERROR_BODY_MAX_BYTES = 1024 * 1024
+
+
+async def _read_error_body(response: httpx.Response) -> str:
+ body = bytearray()
+ async for chunk in response.aiter_bytes(8192):
+ body.extend(chunk)
+ if len(body) > ERROR_BODY_MAX_BYTES:
+ raise DeerFlowAPIError(
+ operation='read error response',
+ body='response exceeds the runtime limit',
+ )
+ return body.decode('utf-8', errors='replace')
def _normalize_sse_newlines(text: str) -> str:
@@ -94,6 +110,7 @@ class AsyncDeerFlowClient:
async with httpx.AsyncClient(
trust_env=True,
timeout=timeout,
+ event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.post(
url,
@@ -101,13 +118,14 @@ class AsyncDeerFlowClient:
json=payload,
)
if response.status_code not in (200, 201):
+ body = await httpclient.response_text(response)
raise DeerFlowAPIError(
operation='create thread',
status=response.status_code,
- body=response.text,
+ body=body,
url=url,
)
- return response.json()
+ return await httpclient.parse_json_response(response)
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
"""删除指定 thread"""
@@ -116,13 +134,15 @@ class AsyncDeerFlowClient:
async with httpx.AsyncClient(
trust_env=True,
timeout=timeout,
+ event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.delete(url, headers=self.headers)
if response.status_code not in (200, 202, 204, 404):
+ body = await httpclient.response_text(response)
raise DeerFlowAPIError(
operation='delete thread',
status=response.status_code,
- body=response.text,
+ body=body,
url=url,
thread_id=thread_id,
)
@@ -163,19 +183,27 @@ class AsyncDeerFlowClient:
json=payload,
) as resp:
if resp.status_code != 200:
- body = await resp.aread()
raise DeerFlowAPIError(
operation='runs/stream request',
status=resp.status_code,
- body=body.decode('utf-8', errors='replace'),
+ body=await _read_error_body(resp),
url=url,
thread_id=thread_id,
)
decoder = codecs.getincrementaldecoder('utf-8')('replace')
buffer = ''
+ total_bytes = 0
async for chunk in resp.aiter_bytes(8192):
+ total_bytes += len(chunk)
+ if total_bytes > SSE_MAX_TOTAL_BYTES:
+ raise DeerFlowAPIError(
+ operation='runs/stream response',
+ body='response exceeds the runtime limit',
+ url=url,
+ thread_id=thread_id,
+ )
buffer += _normalize_sse_newlines(decoder.decode(chunk))
while '\n\n' in buffer:
diff --git a/src/langbot/libs/dify_service_api/v1/client.py b/src/langbot/libs/dify_service_api/v1/client.py
index a00eedbc1..d1317e00e 100644
--- a/src/langbot/libs/dify_service_api/v1/client.py
+++ b/src/langbot/libs/dify_service_api/v1/client.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
import httpx
import typing
import json
@@ -8,6 +9,75 @@ from .errors import DifyAPIError
from pathlib import Path
import os
+_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
+_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
+_MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
+_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
+
+
+async def _read_limited_response(
+ response: httpx.Response,
+ *,
+ max_bytes: int = _MAX_DIFY_RESPONSE_BYTES,
+) -> bytes:
+ content_length = response.headers.get('Content-Length')
+ if content_length is not None:
+ try:
+ if int(content_length) > max_bytes:
+ raise DifyAPIError(f'Remote response exceeds the {max_bytes}-byte limit')
+ except (TypeError, ValueError):
+ pass
+
+ body = bytearray()
+ async for chunk in response.aiter_bytes(chunk_size=8192):
+ body.extend(chunk)
+ if len(body) > max_bytes:
+ raise DifyAPIError(f'Remote response exceeds the {max_bytes}-byte limit')
+ return bytes(body)
+
+
+async def _iter_sse_json(
+ response: httpx.Response,
+) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
+ """Parse Dify's one-JSON-per-data-line SSE without unbounded line buffering."""
+
+ buffer = bytearray()
+ total = 0
+ async for chunk in response.aiter_bytes(chunk_size=8192):
+ total += len(chunk)
+ if total > _MAX_DIFY_STREAM_BYTES:
+ raise DifyAPIError('Dify SSE stream exceeds the runtime limit')
+ buffer.extend(chunk)
+ while b'\n' in buffer:
+ raw_line, _, remainder = buffer.partition(b'\n')
+ buffer = bytearray(remainder)
+ if len(raw_line) > _MAX_DIFY_SSE_LINE_BYTES:
+ raise DifyAPIError('Dify SSE event exceeds the runtime limit')
+ line = raw_line.rstrip(b'\r').strip()
+ if not line or not line.startswith(b'data:'):
+ continue
+ payload = json.loads(line[5:].decode('utf-8', errors='replace'))
+ if isinstance(payload, dict):
+ yield payload
+ if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
+ raise DifyAPIError('Dify SSE event exceeds the runtime limit')
+
+ line = bytes(buffer).rstrip(b'\r').strip()
+ if line.startswith(b'data:'):
+ payload = json.loads(line[5:].decode('utf-8', errors='replace'))
+ if isinstance(payload, dict):
+ yield payload
+
+
+def _read_local_file_limited(path: Path) -> bytes:
+ if path.stat().st_size > _MAX_DIFY_UPLOAD_BYTES:
+ raise ValueError('Dify upload exceeds the size limit')
+ with path.open('rb') as handle:
+ body = handle.read(_MAX_DIFY_UPLOAD_BYTES + 1)
+ if len(body) > _MAX_DIFY_UPLOAD_BYTES:
+ raise ValueError('Dify upload exceeds the size limit')
+ return body
+
class AsyncDifyServiceClient:
"""Dify Service API 客户端"""
@@ -22,6 +92,21 @@ class AsyncDifyServiceClient:
) -> None:
self.api_key = api_key
self.base_url = base_url
+ self._client: httpx.AsyncClient | None = None
+
+ def _get_client(self) -> httpx.AsyncClient:
+ if self._client is None:
+ self._client = httpx.AsyncClient(
+ base_url=self.base_url,
+ trust_env=True,
+ )
+ return self._client
+
+ async def aclose(self) -> None:
+ client = self._client
+ self._client = None
+ if client is not None:
+ await client.aclose()
async def chat_messages(
self,
@@ -38,37 +123,32 @@ class AsyncDifyServiceClient:
if response_mode != 'streaming':
raise DifyAPIError('当前仅支持 streaming 模式')
- async with httpx.AsyncClient(
- base_url=self.base_url,
- trust_env=True,
- timeout=timeout,
- ) as client:
- payload = {
- 'inputs': inputs,
- 'query': query,
- 'user': user,
- 'response_mode': response_mode,
- 'conversation_id': conversation_id,
- 'files': files,
- 'model_config': model_config or {},
- }
+ client = self._get_client()
+ payload = {
+ 'inputs': inputs,
+ 'query': query,
+ 'user': user,
+ 'response_mode': response_mode,
+ 'conversation_id': conversation_id,
+ 'files': files,
+ 'model_config': model_config or {},
+ }
- async with client.stream(
- 'POST',
- '/chat-messages',
- headers={
- 'Authorization': f'Bearer {self.api_key}',
- 'Content-Type': 'application/json',
- },
- json=payload,
- ) as r:
- async for chunk in r.aiter_lines():
- if r.status_code != 200:
- raise DifyAPIError(f'{r.status_code} {chunk}')
- if chunk.strip() == '':
- continue
- if chunk.startswith('data:'):
- yield json.loads(chunk[5:])
+ async with client.stream(
+ 'POST',
+ '/chat-messages',
+ headers={
+ 'Authorization': f'Bearer {self.api_key}',
+ 'Content-Type': 'application/json',
+ },
+ json=payload,
+ timeout=timeout,
+ ) as r:
+ if r.status_code != 200:
+ body = await _read_limited_response(r)
+ raise DifyAPIError(f'{r.status_code} {body.decode(errors="replace")}')
+ async for event in _iter_sse_json(r):
+ yield event
async def workflow_run(
self,
@@ -82,32 +162,27 @@ class AsyncDifyServiceClient:
if response_mode != 'streaming':
raise DifyAPIError('当前仅支持 streaming 模式')
- async with httpx.AsyncClient(
- base_url=self.base_url,
- trust_env=True,
+ client = self._get_client()
+ async with client.stream(
+ 'POST',
+ '/workflows/run',
+ headers={
+ 'Authorization': f'Bearer {self.api_key}',
+ 'Content-Type': 'application/json',
+ },
+ json={
+ 'inputs': inputs,
+ 'user': user,
+ 'response_mode': response_mode,
+ 'files': files,
+ },
timeout=timeout,
- ) as client:
- async with client.stream(
- 'POST',
- '/workflows/run',
- headers={
- 'Authorization': f'Bearer {self.api_key}',
- 'Content-Type': 'application/json',
- },
- json={
- 'inputs': inputs,
- 'user': user,
- 'response_mode': response_mode,
- 'files': files,
- },
- ) as r:
- async for chunk in r.aiter_lines():
- if r.status_code != 200:
- raise DifyAPIError(f'{r.status_code} {chunk}')
- if chunk.strip() == '':
- continue
- if chunk.startswith('data:'):
- yield json.loads(chunk[5:])
+ ) as r:
+ if r.status_code != 200:
+ body = await _read_limited_response(r)
+ raise DifyAPIError(f'{r.status_code} {body.decode(errors="replace")}')
+ async for event in _iter_sse_json(r):
+ yield event
async def workflow_submit(
self,
@@ -129,41 +204,38 @@ class AsyncDifyServiceClient:
'Content-Type': 'application/json',
}
- async with httpx.AsyncClient(
- base_url=self.base_url,
- trust_env=True,
+ client = self._get_client()
+ # Step 1: Submit the form
+ payload: dict[str, typing.Any] = {
+ 'inputs': inputs if isinstance(inputs, dict) else {},
+ 'user': user,
+ 'action': action,
+ }
+
+ async with client.stream(
+ 'POST',
+ f'/form/human_input/{form_token}',
+ headers=headers,
+ json=payload,
timeout=timeout,
- ) as client:
- # Step 1: Submit the form
- payload: dict[str, typing.Any] = {
- 'inputs': inputs if isinstance(inputs, dict) else {},
- 'user': user,
- 'action': action,
- }
-
- submit_resp = await client.post(
- f'/form/human_input/{form_token}',
- headers=headers,
- json=payload,
- )
+ ) as submit_resp:
+ submit_body = await _read_limited_response(submit_resp)
if submit_resp.status_code != 200:
- raise DifyAPIError(f'{submit_resp.status_code} {submit_resp.text}')
+ raise DifyAPIError(f'{submit_resp.status_code} {submit_body.decode(errors="replace")}')
- # Step 2: Stream resumed workflow events
- async with client.stream(
- 'GET',
- f'/workflow/{workflow_run_id}/events',
- headers={'Authorization': f'Bearer {self.api_key}'},
- params={'user': user},
- ) as r:
- if r.status_code != 200:
- body = (await r.aread()).decode(errors='replace')
- raise DifyAPIError(f'{r.status_code} {body}')
- async for chunk in r.aiter_lines():
- if chunk.strip() == '':
- continue
- if chunk.startswith('data:'):
- yield json.loads(chunk[5:])
+ # Step 2: Stream resumed workflow events
+ async with client.stream(
+ 'GET',
+ f'/workflow/{workflow_run_id}/events',
+ headers={'Authorization': f'Bearer {self.api_key}'},
+ params={'user': user},
+ timeout=timeout,
+ ) as r:
+ if r.status_code != 200:
+ body = await _read_limited_response(r)
+ raise DifyAPIError(f'{r.status_code} {body.decode(errors="replace")}')
+ async for event in _iter_sse_json(r):
+ yield event
async def upload_file(
self,
@@ -175,37 +247,30 @@ class AsyncDifyServiceClient:
if isinstance(file, Path):
if not file.exists():
raise ValueError(f'File not found: {file}')
- with open(file, 'rb') as f:
- file = f.read()
+ file = await asyncio.to_thread(_read_local_file_limited, file)
# 处理文件路径字符串
elif isinstance(file, str):
if not os.path.isfile(file):
raise ValueError(f'File not found: {file}')
- with open(file, 'rb') as f:
- file = f.read()
+ file = await asyncio.to_thread(_read_local_file_limited, Path(file))
# 处理文件对象
elif hasattr(file, 'read'):
- file = file.read()
- async with httpx.AsyncClient(
- base_url=self.base_url,
- trust_env=True,
+ file = await asyncio.to_thread(file.read, _MAX_DIFY_UPLOAD_BYTES + 1)
+ if len(file) > _MAX_DIFY_UPLOAD_BYTES:
+ raise ValueError('Dify upload exceeds the size limit')
+ client = self._get_client()
+ # multipart/form-data
+ async with client.stream(
+ 'POST',
+ '/files/upload',
+ headers={'Authorization': f'Bearer {self.api_key}'},
+ files={'file': file},
+ data={'user': user},
timeout=timeout,
- ) as client:
- # multipart/form-data
- response = await client.post(
- '/files/upload',
- headers={'Authorization': f'Bearer {self.api_key}'},
- files={
- 'file': file,
- },
- data={
- 'user': user,
- },
- )
-
+ ) as response:
+ body = await _read_limited_response(response)
if response.status_code != 201:
- raise DifyAPIError(f'{response.status_code} {response.text}')
-
- return response.json()
+ raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
+ return json.loads(body)
diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py
index 8d93ee27f..c0a7492ad 100644
--- a/src/langbot/libs/dingtalk_api/api.py
+++ b/src/langbot/libs/dingtalk_api/api.py
@@ -7,6 +7,7 @@ import time
import typing
import uuid
import urllib.parse
+from contextlib import asynccontextmanager
from typing import Awaitable, Callable, Optional
import dingtalk_stream # type: ignore
import websockets
@@ -15,12 +16,42 @@ from .card_callback import DingTalkCardActionHandler
from .dingtalkevent import DingTalkEvent
import httpx
import traceback
+from langbot.pkg.utils import httpclient
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
+_MAX_MEDIA_BYTES = 10 * 1024 * 1024
+_MAX_GATEWAY_MESSAGE_BYTES = 1024 * 1024
+
+
+def _read_local_media_limited(file_path: str) -> bytes:
+ if os.path.getsize(file_path) > _MAX_MEDIA_BYTES:
+ raise ValueError('DingTalk media exceeds the size limit')
+ with open(file_path, 'rb') as file:
+ body = file.read(_MAX_MEDIA_BYTES + 1)
+ if len(body) > _MAX_MEDIA_BYTES:
+ raise ValueError('DingTalk media exceeds the size limit')
+ return body
+
+
+async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
+ content_length = response.headers.get('Content-Length')
+ if content_length is not None:
+ try:
+ if int(content_length) > _MAX_MEDIA_BYTES:
+ raise ValueError('DingTalk media exceeds the size limit')
+ except (TypeError, ValueError) as exc:
+ if 'exceeds' in str(exc):
+ raise
+ body = bytearray()
+ async for chunk in response.aiter_bytes():
+ body.extend(chunk)
+ if len(body) > _MAX_MEDIA_BYTES:
+ raise ValueError('DingTalk media exceeds the size limit')
+ return bytes(body)
def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
@@ -44,6 +75,8 @@ def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
class DingTalkClient:
+ _MAX_INBOUND_TASKS = 100
+
def __init__(
self,
client_id: str,
@@ -86,6 +119,37 @@ class DingTalkClient:
self.legacy_access_token = ''
self.legacy_access_token_expiry_time: typing.Optional[float] = None
self._stopped = False # Flag to control the event loop
+ self._inbound_tasks: set[asyncio.Task] = set()
+ self._http_client: httpx.AsyncClient | None = None
+
+ @asynccontextmanager
+ async def _http_client_context(self):
+ """Reuse one connection pool while preserving existing call structure."""
+
+ if self._http_client is None or self._http_client.is_closed:
+ self._http_client = httpx.AsyncClient(event_hooks=httpclient.httpx_response_limit_hooks())
+ yield self._http_client
+
+ def _start_inbound_task(self, coro: typing.Coroutine) -> bool:
+ """Start one bounded inbound callback task."""
+
+ for task in tuple(self._inbound_tasks):
+ if task.done():
+ self._inbound_tasks.discard(task)
+ if len(self._inbound_tasks) >= self._MAX_INBOUND_TASKS:
+ coro.close()
+ return False
+
+ task = asyncio.create_task(coro)
+ self._inbound_tasks.add(task)
+
+ def done(done_task: asyncio.Task) -> None:
+ self._inbound_tasks.discard(done_task)
+ if not done_task.cancelled():
+ done_task.exception()
+
+ task.add_done_callback(done)
+ return True
async def _on_card_action(self, payload: dict) -> None:
"""Dispatch a parsed card-action payload to the adapter callback."""
@@ -101,11 +165,11 @@ class DingTalkClient:
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
headers = {'Content-Type': 'application/json'}
data = {'appKey': self.key, 'appSecret': self.secret}
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
try:
response = await client.post(url, json=data, headers=headers)
if response.status_code == 200:
- response_data = response.json()
+ response_data = await httpclient.parse_json_response(response)
self.access_token = response_data.get('accessToken')
expires_in = int(response_data.get('expireIn', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60
@@ -129,28 +193,28 @@ class DingTalkClient:
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
headers = {'x-acs-dingtalk-access-token': self.access_token}
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, json=params)
if response.status_code == 200:
- result = response.json()
+ result = await httpclient.parse_json_response(response)
download_url = result.get('downloadUrl')
else:
- await self.logger.error(f'failed to get download url: {response.json()}')
+ error_payload = await httpclient.parse_json_response(response)
+ await self.logger.error(f'failed to get download url: {error_payload}')
if download_url:
return await self.download_url_to_base64(download_url)
async def download_url_to_base64(self, download_url):
- async with httpx.AsyncClient() as client:
- response = await client.get(download_url)
-
- if response.status_code == 200:
- file_bytes = response.content
- mime_type = response.headers.get('Content-Type', 'application/octet-stream')
- base64_str = base64.b64encode(file_bytes).decode('utf-8')
- return f'data:{mime_type};base64,{base64_str}'
- else:
- await self.logger.error(f'failed to get files: {response.json()}')
+ async with self._http_client_context() as client:
+ async with client.stream('GET', download_url) as response:
+ if response.status_code == 200:
+ file_bytes = await _read_httpx_media_limited(response)
+ mime_type = response.headers.get('Content-Type', 'application/octet-stream')
+ base64_str = (await asyncio.to_thread(base64.b64encode, file_bytes)).decode('utf-8')
+ return f'data:{mime_type};base64,{base64_str}'
+ error_body = await _read_httpx_media_limited(response)
+ await self.logger.error(f'failed to get files: {error_body[:300]!r}')
async def get_audio_url(self, download_code: str):
if not await self.check_access_token():
@@ -158,17 +222,19 @@ class DingTalkClient:
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
headers = {'x-acs-dingtalk-access-token': self.access_token}
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, json=params)
if response.status_code == 200:
- result = response.json()
+ result = await httpclient.parse_json_response(response)
download_url = result.get('downloadUrl')
if download_url:
return await self.download_url_to_base64(download_url)
else:
- await self.logger.error(f'failed to get audio: {response.json()}')
+ error_payload = await httpclient.parse_json_response(response)
+ await self.logger.error(f'failed to get audio: {error_payload}')
else:
- raise Exception(f'Error: {response.status_code}, {response.text}')
+ body = await httpclient.response_text(response)
+ raise Exception(f'Error: {response.status_code}, {body}')
async def get_file_url(self, download_code: str):
if not await self.check_access_token():
@@ -176,17 +242,19 @@ class DingTalkClient:
url = 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'
params = {'downloadCode': download_code, 'robotCode': self.robot_code}
headers = {'x-acs-dingtalk-access-token': self.access_token}
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, json=params)
if response.status_code == 200:
- result = response.json()
+ result = await httpclient.parse_json_response(response)
download_url = result.get('downloadUrl')
if download_url:
return download_url
else:
- await self.logger.error(f'failed to get file: {response.json()}')
+ error_payload = await httpclient.parse_json_response(response)
+ await self.logger.error(f'failed to get file: {error_payload}')
else:
- raise Exception(f'Error: {response.status_code}, {response.text}')
+ body = await httpclient.response_text(response)
+ raise Exception(f'Error: {response.status_code}, {body}')
async def update_incoming_message(self, message):
"""异步更新 DingTalkClient 中的 incoming_message"""
@@ -503,12 +571,13 @@ class DingTalkClient:
len(content),
)
try:
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, json=data)
+ response_body = await httpclient.response_text(response, max_chars=500)
_stdout_logger.info(
'DingTalk send_proactive_message_to_one response: status=%d body=%s',
response.status_code,
- response.text[:500],
+ response_body,
)
if response.status_code == 200:
return
@@ -535,7 +604,7 @@ class DingTalkClient:
'msgParam': json.dumps({'content': content}),
}
try:
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, json=data)
if response.status_code == 200:
return
@@ -667,22 +736,23 @@ class DingTalkClient:
'DingTalk createAndDeliver request body: %s',
json.dumps(body, ensure_ascii=False)[:1500],
)
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, json=body, timeout=30.0)
+ response_body = await httpclient.response_text(response, max_chars=500)
if response.status_code == 200:
_stdout_logger.info(
'DingTalk createAndDeliver response: %s',
- response.text[:500],
+ response_body,
)
return True
_stdout_logger.error(
'DingTalk createAndDeliver failed: status=%s body=%s',
response.status_code,
- response.text,
+ response_body,
)
if self.logger:
await self.logger.error(
- f'DingTalk createAndDeliver failed: status={response.status_code} body={response.text}'
+ f'DingTalk createAndDeliver failed: status={response.status_code} body={response_body}'
)
return False
except Exception:
@@ -725,13 +795,14 @@ class DingTalkClient:
'Content-Type': 'application/json',
}
try:
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.put(url, headers=headers, json=body, timeout=30.0)
if response.status_code == 200:
return True
if self.logger:
+ response_body = await httpclient.response_text(response)
await self.logger.error(
- f'DingTalk card streaming failed: status={response.status_code} body={response.text}'
+ f'DingTalk card streaming failed: status={response.status_code} body={response_body}'
)
return False
except Exception:
@@ -768,18 +839,19 @@ class DingTalkClient:
out_track_id,
json.dumps(body, ensure_ascii=False)[:1500],
)
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.put(url, headers=headers, json=body, timeout=30.0)
+ response_body = await httpclient.response_text(response, max_chars=300)
_stdout_logger.info(
'DingTalk update_card_data response: status=%d body=%s',
response.status_code,
- response.text[:300],
+ response_body,
)
if response.status_code == 200:
return True
if self.logger:
await self.logger.error(
- f'DingTalk update card failed: status={response.status_code} body={response.text}'
+ f'DingTalk update card failed: status={response.status_code} body={response_body}'
)
return False
except Exception:
@@ -808,17 +880,18 @@ class DingTalkClient:
url = 'https://oapi.dingtalk.com/gettoken'
try:
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0)
- data = response.json() if response.status_code == 200 else {}
+ data = await httpclient.parse_json_response(response) if response.status_code == 200 else {}
if data.get('errcode') == 0 and data.get('access_token'):
self.legacy_access_token = data['access_token']
expires_in = int(data.get('expires_in', 7200))
self.legacy_access_token_expiry_time = now + expires_in - 60
return self.legacy_access_token
if self.logger:
+ response_body = await httpclient.response_text(response, max_chars=200)
await self.logger.error(
- f'DingTalk legacy gettoken failed: status={response.status_code} body={response.text[:200]}'
+ f'DingTalk legacy gettoken failed: status={response.status_code} body={response_body}'
)
except Exception:
_stdout_logger.exception('DingTalk legacy gettoken error')
@@ -848,8 +921,7 @@ class DingTalkClient:
url = 'https://oapi.dingtalk.com/media/upload'
try:
- with open(file_path, 'rb') as f:
- file_bytes = f.read()
+ file_bytes = await asyncio.to_thread(_read_local_media_limited, file_path)
file_name = os.path.basename(file_path)
# Best-effort content-type guess; DingTalk accepts the major image
# mime types and otherwise infers from the bytes.
@@ -857,20 +929,21 @@ class DingTalkClient:
mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(
ext, 'application/octet-stream'
)
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(
url,
params={'access_token': token, 'type': 'image'},
files={'media': (file_name, file_bytes, mime)},
timeout=30.0,
)
- data = response.json() if response.status_code == 200 else {}
+ data = await httpclient.parse_json_response(response) if response.status_code == 200 else {}
if data.get('errcode') == 0 and data.get('media_id'):
_stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id'])
return data['media_id']
if self.logger:
+ response_body = await httpclient.response_text(response, max_chars=300)
await self.logger.error(
- f'DingTalk upload_image_media failed: status={response.status_code} body={response.text[:300]}'
+ f'DingTalk upload_image_media failed: status={response.status_code} body={response_body}'
)
except Exception:
_stdout_logger.exception('DingTalk upload_image_media error')
@@ -897,15 +970,19 @@ class DingTalkClient:
continue
uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))
- async with websockets.connect(uri) as websocket:
+ async with websockets.connect(uri, max_size=_MAX_GATEWAY_MESSAGE_BYTES) as websocket:
self.client.websocket = websocket
keepalive_task = asyncio.create_task(self._keepalive(websocket))
try:
async for raw_message in websocket:
if self._stopped:
break
- json_message = json.loads(raw_message)
- asyncio.create_task(self.client.background_task(json_message))
+ json_message = await asyncio.to_thread(json.loads, raw_message)
+ if not self._start_inbound_task(self.client.background_task(json_message)):
+ if self.logger:
+ await self.logger.warning(
+ 'DingTalk inbound task capacity reached; dropping message'
+ )
finally:
keepalive_task.cancel()
try:
@@ -948,5 +1025,15 @@ class DingTalkClient:
await self.client.websocket.close()
except Exception:
pass
+ inbound_tasks = list(self._inbound_tasks)
+ for task in inbound_tasks:
+ if not task.done():
+ task.cancel()
+ if inbound_tasks:
+ await asyncio.gather(*inbound_tasks, return_exceptions=True)
+ self._inbound_tasks.clear()
# Clear message handlers to prevent stale callbacks
self._message_handlers = {'example': []}
+ if self._http_client is not None:
+ await self._http_client.aclose()
+ self._http_client = None
diff --git a/src/langbot/libs/official_account_api/api.py b/src/langbot/libs/official_account_api/api.py
index b474205dc..38935f8a4 100644
--- a/src/langbot/libs/official_account_api/api.py
+++ b/src/langbot/libs/official_account_api/api.py
@@ -21,8 +21,14 @@ xml_template = """
"""
+_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
+
class OAClient:
+ _STATE_TTL_SECONDS = 600
+ _STATE_MAX = 4096
+ _MAX_CONTENT_CHARS = 200000
+
def __init__(
self,
token: str,
@@ -41,6 +47,7 @@ class OAClient:
self.access_token = ''
self.unified_mode = unified_mode
self.app = Quart(__name__)
+ self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
@@ -57,8 +64,38 @@ class OAClient:
self.access_token_expiry_time = None
self.msg_id_map = {}
self.generated_content = {}
+ self._msg_seen_at = {}
+ self._generated_at = {}
+ self._last_state_prune = 0.0
self.logger = logger
+ def _prune_state(self) -> None:
+ now = time.monotonic()
+ if now - self._last_state_prune >= 60:
+ self._last_state_prune = now
+ for message_id, seen_at in tuple(self._msg_seen_at.items()):
+ if now - seen_at > self._STATE_TTL_SECONDS:
+ self._msg_seen_at.pop(message_id, None)
+ self.msg_id_map.pop(message_id, None)
+ for message_id, generated_at in tuple(self._generated_at.items()):
+ if now - generated_at > self._STATE_TTL_SECONDS:
+ self._generated_at.pop(message_id, None)
+ self.generated_content.pop(message_id, None)
+ while len(self.msg_id_map) > self._STATE_MAX:
+ message_id = next(iter(self.msg_id_map))
+ self.msg_id_map.pop(message_id, None)
+ self._msg_seen_at.pop(message_id, None)
+ while len(self.generated_content) > self._STATE_MAX:
+ message_id = next(iter(self.generated_content))
+ self.generated_content.pop(message_id, None)
+ self._generated_at.pop(message_id, None)
+
+ def clear(self) -> None:
+ self.msg_id_map.clear()
+ self.generated_content.clear()
+ self._msg_seen_at.clear()
+ self._generated_at.clear()
+
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request)。"""
return await self._handle_callback_internal(request)
@@ -104,8 +141,16 @@ class OAClient:
raise Exception('拒绝请求')
elif req.method == 'POST':
encryt_msg = await req.data
+ if len(encryt_msg) > _MAX_CALLBACK_BODY_BYTES:
+ raise ValueError('Official Account callback body exceeds the size limit')
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
- ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
+ ret, xml_msg = await asyncio.to_thread(
+ wxcpt.DecryptMsg,
+ encryt_msg,
+ msg_signature,
+ timestamp,
+ nonce,
+ )
xml_msg = xml_msg.decode('utf-8')
if ret != 0:
@@ -118,7 +163,7 @@ class OAClient:
if event:
await self._handle_message(event)
- root = ET.fromstring(xml_msg)
+ root = await asyncio.to_thread(ET.fromstring, xml_msg)
from_user = root.find('FromUserName').text # 发送者
to_user = root.find('ToUserName').text # 机器人
@@ -126,6 +171,7 @@ class OAClient:
interval = 0.1
while True:
content = self.generated_content.pop(message_data['MsgId'], None)
+ self._generated_at.pop(message_data['MsgId'], None)
if content:
response_xml = xml_template.format(
to_user=from_user,
@@ -156,7 +202,7 @@ class OAClient:
traceback.print_exc()
async def get_message(self, xml_msg: str):
- root = ET.fromstring(xml_msg)
+ root = await asyncio.to_thread(ET.fromstring, xml_msg)
message_data = {
'ToUserName': root.find('ToUserName').text,
@@ -193,21 +239,30 @@ class OAClient:
处理消息事件。
"""
message_id = event.message_id
+ self._prune_state()
if message_id in self.msg_id_map.keys():
self.msg_id_map[message_id] += 1
+ self._msg_seen_at[message_id] = time.monotonic()
return
self.msg_id_map[message_id] = 1
+ self._msg_seen_at[message_id] = time.monotonic()
msg_type = event.type
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
await handler(event)
async def set_message(self, msg_id: int, content: str):
- self.generated_content[msg_id] = content
+ self.generated_content[msg_id] = str(content)[: self._MAX_CONTENT_CHARS]
+ self._generated_at[msg_id] = time.monotonic()
+ self._prune_state()
class OAClientForLongerResponse:
+ _MAX_USERS = 4096
+ _MAX_MESSAGES_PER_USER = 20
+ _MAX_CONTENT_CHARS = 200000
+
def __init__(
self,
token: str,
@@ -227,6 +282,7 @@ class OAClientForLongerResponse:
self.access_token = ''
self.unified_mode = unified_mode
self.app = Quart(__name__)
+ self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
@@ -244,8 +300,28 @@ class OAClientForLongerResponse:
self.loading_message = LoadingMessage
self.msg_queue = {}
self.user_msg_queue = {}
+ self._last_queue_cleanup = 0.0
self.logger = logger
+ def _prune_queues(self) -> None:
+ now = time.monotonic()
+ if now - self._last_queue_cleanup >= 60:
+ self._last_queue_cleanup = now
+ for user_id, queue in tuple(self.msg_queue.items()):
+ if not queue:
+ self.msg_queue.pop(user_id, None)
+ for user_id, queue in tuple(self.user_msg_queue.items()):
+ if not queue:
+ self.user_msg_queue.pop(user_id, None)
+ while len(self.msg_queue) > self._MAX_USERS:
+ self.msg_queue.pop(next(iter(self.msg_queue)), None)
+ while len(self.user_msg_queue) > self._MAX_USERS:
+ self.user_msg_queue.pop(next(iter(self.user_msg_queue)), None)
+
+ def clear(self) -> None:
+ self.msg_queue.clear()
+ self.user_msg_queue.clear()
+
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request)。"""
return await self._handle_callback_internal(request)
@@ -285,8 +361,16 @@ class OAClientForLongerResponse:
elif req.method == 'POST':
encryt_msg = await req.data
+ if len(encryt_msg) > _MAX_CALLBACK_BODY_BYTES:
+ raise ValueError('Official Account callback body exceeds the size limit')
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
- ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
+ ret, xml_msg = await asyncio.to_thread(
+ wxcpt.DecryptMsg,
+ encryt_msg,
+ msg_signature,
+ timestamp,
+ nonce,
+ )
xml_msg = xml_msg.decode('utf-8')
if ret != 0:
@@ -294,7 +378,7 @@ class OAClientForLongerResponse:
raise Exception('消息解密失败')
# 解析 XML
- root = ET.fromstring(xml_msg)
+ root = await asyncio.to_thread(ET.fromstring, xml_msg)
from_user = root.find('FromUserName').text
to_user = root.find('ToUserName').text
@@ -305,6 +389,7 @@ class OAClientForLongerResponse:
# 弹出用户消息
if self.user_msg_queue.get(from_user) and self.user_msg_queue[from_user]:
self.user_msg_queue[from_user].pop(0)
+ self._prune_queues()
response_xml = xml_template.format(
to_user=from_user,
@@ -332,9 +417,13 @@ class OAClientForLongerResponse:
if event:
self.user_msg_queue.setdefault(from_user, []).append(
{
- 'content': event.message,
+ 'content': str(event.message)[: self._MAX_CONTENT_CHARS],
}
)
+ self.user_msg_queue[from_user] = self.user_msg_queue[from_user][
+ -self._MAX_MESSAGES_PER_USER :
+ ]
+ self._prune_queues()
await self._handle_message(event)
return response_xml
@@ -344,7 +433,7 @@ class OAClientForLongerResponse:
traceback.print_exc()
async def get_message(self, xml_msg: str):
- root = ET.fromstring(xml_msg)
+ root = await asyncio.to_thread(ET.fromstring, xml_msg)
message_data = {
'ToUserName': root.find('ToUserName').text,
@@ -393,6 +482,8 @@ class OAClientForLongerResponse:
self.msg_queue[from_user].append(
{
'msg_id': message_id,
- 'content': content,
+ 'content': str(content)[: self._MAX_CONTENT_CHARS],
}
)
+ self.msg_queue[from_user] = self.msg_queue[from_user][-self._MAX_MESSAGES_PER_USER :]
+ self._prune_queues()
diff --git a/src/langbot/libs/openclaw_weixin_api/client.py b/src/langbot/libs/openclaw_weixin_api/client.py
index d713f2996..fa89c0427 100644
--- a/src/langbot/libs/openclaw_weixin_api/client.py
+++ b/src/langbot/libs/openclaw_weixin_api/client.py
@@ -10,7 +10,9 @@ from __future__ import annotations
import asyncio
import base64
+import hashlib
import io
+import json
import logging
import os
import struct
@@ -21,6 +23,8 @@ from urllib.parse import quote
import aiohttp
+from langbot.pkg.utils import httpclient
+
from .types import (
ApiError,
CDNMedia,
@@ -58,6 +62,51 @@ DEFAULT_BOT_TYPE = '3'
# Maximum text length per message chunk (WeChat limit)
MAX_TEXT_CHUNK_SIZE = 2000
+MAX_CDN_MEDIA_BYTES = 10 * 1024 * 1024
+
+
+async def _response_text(response: aiohttp.ClientResponse) -> str:
+ body = await httpclient.read_limited(
+ response,
+ max_bytes=MAX_CDN_MEDIA_BYTES,
+ )
+ return body.decode('utf-8', errors='replace')
+
+
+async def _response_json(response: aiohttp.ClientResponse) -> dict:
+ payload = json.loads(await _response_text(response))
+ if not isinstance(payload, dict):
+ raise ApiError('OpenClaw API returned a non-object response', status=response.status)
+ return payload
+
+
+def _decrypt_cdn_payload(encrypted: bytes, aes_key: bytes) -> bytes:
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ from cryptography.hazmat.primitives.padding import PKCS7
+
+ cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
+ decryptor = cipher.decryptor()
+ padded = decryptor.update(encrypted) + decryptor.finalize()
+ unpadder = PKCS7(128).unpadder()
+ return unpadder.update(padded) + unpadder.finalize()
+
+
+def _encrypt_cdn_payload(
+ file_bytes: bytes,
+) -> tuple[str, str, bytes, str]:
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ from cryptography.hazmat.primitives.padding import PKCS7
+
+ raw_key = os.urandom(16)
+ aes_key_hex = raw_key.hex()
+ encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
+ padder = PKCS7(128).padder()
+ padded = padder.update(file_bytes) + padder.finalize()
+ cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
+ encryptor = cipher.encryptor()
+ encrypted = encryptor.update(padded) + encryptor.finalize()
+ raw_md5 = hashlib.md5(file_bytes).hexdigest()
+ return aes_key_hex, encoded_key, encrypted, raw_md5
def _random_wechat_uin() -> str:
@@ -125,12 +174,12 @@ class OpenClawWeixinClient:
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
) as resp:
if resp.status != 200:
- text = await resp.text()
+ text = await _response_text(resp)
raise ApiError(
f'OpenClaw API error {resp.status}: {text}',
status=resp.status,
)
- data = await resp.json(content_type=None)
+ data = await _response_json(resp)
# Check for application-level errors in the response body
errcode = data.get('errcode') or data.get('ret')
@@ -170,12 +219,12 @@ class OpenClawWeixinClient:
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
if resp.status != 200:
- text = await resp.text()
+ text = await _response_text(resp)
raise ApiError(
f'OpenClaw API error {resp.status}: {text}',
status=resp.status,
)
- data = await resp.json(content_type=None)
+ data = await _response_json(resp)
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
@@ -258,9 +307,6 @@ class OpenClawWeixinClient:
Returns:
Decrypted file bytes.
"""
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
- from cryptography.hazmat.primitives.padding import PKCS7
-
if not media.encrypt_query_param:
raise ApiError('CDN media has no encrypt_query_param', status=0)
if not media.aes_key:
@@ -285,17 +331,14 @@ class OpenClawWeixinClient:
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
if resp.status != 200:
- text = await resp.text()
+ text = await _response_text(resp)
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
- encrypted = await resp.read()
+ encrypted = await httpclient.read_limited(
+ resp,
+ max_bytes=MAX_CDN_MEDIA_BYTES,
+ )
- # Decrypt AES-128-ECB with PKCS7 padding
- cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
- decryptor = cipher.decryptor()
- padded = decryptor.update(encrypted) + decryptor.finalize()
-
- unpadder = PKCS7(128).unpadder()
- return unpadder.update(padded) + unpadder.finalize()
+ return await asyncio.to_thread(_decrypt_cdn_payload, encrypted, aes_key)
async def upload_media(
self,
@@ -313,28 +356,13 @@ class OpenClawWeixinClient:
Returns:
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
"""
- import hashlib
+ if len(file_bytes) > MAX_CDN_MEDIA_BYTES:
+ raise ApiError('CDN media exceeds the size limit', status=0)
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
- from cryptography.hazmat.primitives.padding import PKCS7
-
- # 1. Generate random 16-byte AES key
- raw_key = os.urandom(16)
- aes_key_hex = raw_key.hex() # 32-char hex string
-
- # 2. Encode key for CDNMedia: base64(hex_string) — same for all media types
- # Matches official SDK: Buffer.from(aeskey_hex).toString("base64")
- encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
-
- # 3. Encrypt file with AES-128-ECB + PKCS7
- padder = PKCS7(128).padder()
- padded = padder.update(file_bytes) + padder.finalize()
- cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
- encryptor = cipher.encryptor()
- encrypted = encryptor.update(padded) + encryptor.finalize()
-
- # 4. Get upload URL
- raw_md5 = hashlib.md5(file_bytes).hexdigest()
+ aes_key_hex, encoded_key, encrypted, raw_md5 = await asyncio.to_thread(
+ _encrypt_cdn_payload,
+ file_bytes,
+ )
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
upload_resp = await self.get_upload_url(
@@ -370,7 +398,7 @@ class OpenClawWeixinClient:
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
if resp.status != 200:
- text = await resp.text()
+ text = await _response_text(resp)
logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500])
raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status)
download_param = resp.headers.get('x-encrypted-param', '')
@@ -491,12 +519,12 @@ class OpenClawWeixinClient:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
if resp.status != 200:
- text = await resp.text()
+ text = await _response_text(resp)
raise ApiError(
f'Failed to fetch QR code: {resp.status} {text}',
status=resp.status,
)
- data = await resp.json(content_type=None)
+ data = await _response_json(resp)
logger.debug(
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
@@ -536,12 +564,12 @@ class OpenClawWeixinClient:
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
) as resp:
if resp.status != 200:
- text = await resp.text()
+ text = await _response_text(resp)
raise ApiError(
f'Failed to poll QR status: {resp.status} {text}',
status=resp.status,
)
- data = await resp.json(content_type=None)
+ data = await _response_json(resp)
logger.debug('QR status poll response: %s', data)
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
return QRStatusResponse(status='wait')
diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py
index 0ba1917d9..dfca4378c 100644
--- a/src/langbot/libs/qq_official_api/api.py
+++ b/src/langbot/libs/qq_official_api/api.py
@@ -9,10 +9,13 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
from .qqofficialevent import QQOfficialEvent
import json
import traceback
+from contextlib import asynccontextmanager
from cryptography.hazmat.primitives.asymmetric import ed25519
+from langbot.pkg.utils import httpclient
QQ_SELECT_ACTION_PREFIX = '__langbot_select__:'
+_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
def get_select_field_options(form_data: dict) -> tuple[str, list[str]]:
@@ -152,6 +155,7 @@ class QQOfficialClient:
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
self.unified_mode = unified_mode
self.app = Quart(__name__)
+ self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
@@ -176,6 +180,32 @@ class QQOfficialClient:
self.logger = logger
self._msg_seq_counter = 0
self._token_refresh_task: Optional[asyncio.Task] = None
+ self._http_clients: dict[float | None, httpx.AsyncClient] = {}
+
+ @asynccontextmanager
+ async def _http_client_context(self, timeout: float | None = None):
+ client = self._http_clients.get(timeout)
+ if client is None or client.is_closed:
+ response_hooks = httpclient.httpx_response_limit_hooks()
+ client = (
+ httpx.AsyncClient(event_hooks=response_hooks)
+ if timeout is None
+ else httpx.AsyncClient(timeout=timeout, event_hooks=response_hooks)
+ )
+ self._http_clients[timeout] = client
+ yield client
+
+ async def close(self) -> None:
+ """Stop client-owned background work."""
+
+ if self._token_refresh_task and not self._token_refresh_task.done():
+ self._token_refresh_task.cancel()
+ await asyncio.gather(self._token_refresh_task, return_exceptions=True)
+ self._token_refresh_task = None
+ clients = list(self._http_clients.values())
+ self._http_clients.clear()
+ if clients:
+ await asyncio.gather(*(client.aclose() for client in clients), return_exceptions=True)
async def check_access_token(self):
"""检查access_token是否存在"""
@@ -186,7 +216,7 @@ class QQOfficialClient:
async def get_access_token(self):
"""获取access_token"""
url = 'https://bots.qq.com/app/getAppAccessToken'
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'appId': self.app_id,
'clientSecret': self.secret,
@@ -196,8 +226,9 @@ class QQOfficialClient:
}
response = await client.post(url, json=params, headers=headers)
if response.status_code != 200:
- raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
- response_data = response.json()
+ body = await httpclient.response_text(response)
+ raise Exception(f'Failed to get access_token: HTTP {response.status_code} {body}')
+ response_data = await httpclient.parse_json_response(response)
access_token = response_data.get('access_token')
expires_in = int(response_data.get('expires_in', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60
@@ -236,8 +267,10 @@ class QQOfficialClient:
if not body or len(body) == 0:
await self.logger.info('Received empty body, might be health check or GET request')
return {'code': 0, 'message': 'ok'}, 200
+ if len(body) > _MAX_CALLBACK_BODY_BYTES:
+ return {'error': 'callback body exceeds the size limit'}, 413
- payload = json.loads(body)
+ payload = await asyncio.to_thread(json.loads, body)
if payload.get('op') == 13:
validation_data = payload.get('d')
@@ -367,7 +400,7 @@ class QQOfficialClient:
await self.get_access_token()
url = self.base_url + '/v2/users/' + user_openid + '/messages'
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
@@ -382,7 +415,7 @@ class QQOfficialClient:
if event_id:
data['event_id'] = event_id
response = await client.post(url, headers=headers, json=data)
- response_data = response.json()
+ response_data = await httpclient.parse_json_response(response)
if response.status_code == 200:
return
else:
@@ -406,7 +439,7 @@ class QQOfficialClient:
await self.get_access_token()
url = self.base_url + '/v2/groups/' + group_openid + '/messages'
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
@@ -424,8 +457,9 @@ class QQOfficialClient:
if response.status_code == 200:
return
else:
- await self.logger.error(f'Failed to send group message: {response.json()}')
- raise Exception(response.read().decode())
+ error_payload = await httpclient.parse_json_response(response)
+ await self.logger.error(f'Failed to send group message: {error_payload}')
+ raise Exception(str(error_payload))
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
"""发送频道群聊消息"""
@@ -433,7 +467,7 @@ class QQOfficialClient:
await self.get_access_token()
url = self.base_url + '/channels/' + channel_id + '/messages'
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
@@ -447,7 +481,8 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
- await self.logger.error(f'Failed to send channel group message: {response.json()}')
+ error_payload = await httpclient.parse_json_response(response)
+ await self.logger.error(f'Failed to send channel group message: {error_payload}')
raise Exception(response)
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
@@ -456,7 +491,7 @@ class QQOfficialClient:
await self.get_access_token()
url = self.base_url + '/dms/' + guild_id + '/messages'
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
@@ -470,7 +505,8 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
- await self.logger.error(f'Failed to send channel private message: {response.json()}')
+ error_payload = await httpclient.parse_json_response(response)
+ await self.logger.error(f'Failed to send channel private message: {error_payload}')
raise Exception(response)
# ---- 富媒体消息 ----
@@ -532,20 +568,21 @@ class QQOfficialClient:
if file_type == self.MEDIA_TYPE_FILE and file_name:
body['file_name'] = file_name
- async with httpx.AsyncClient(timeout=120) as client:
+ async with self._http_client_context(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=body)
if response.status_code == 200:
- data = response.json()
+ data = await httpclient.parse_json_response(response)
file_info = data.get('file_info', '')
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
await self.logger.info(f'Upload media success, file_info={preview}')
return file_info
else:
- raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
+ body = await httpclient.response_text(response)
+ raise Exception(f'Failed to upload media: HTTP {response.status_code} {body}')
async def _send_media_msg(
self,
@@ -578,7 +615,7 @@ class QQOfficialClient:
if msg_id:
body['msg_id'] = msg_id
- async with httpx.AsyncClient(timeout=120) as client:
+ async with self._http_client_context(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
@@ -586,7 +623,8 @@ class QQOfficialClient:
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
- raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
+ response_body = await httpclient.response_text(response)
+ raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response_body}')
async def send_image_msg(
self,
@@ -678,15 +716,16 @@ class QQOfficialClient:
if stream_msg_id:
body['stream_msg_id'] = stream_msg_id
- async with httpx.AsyncClient(timeout=120) as client:
+ async with self._http_client_context(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
- raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
- return response.json()
+ response_body = await httpclient.response_text(response)
+ raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response_body}')
+ return await httpclient.parse_json_response(response)
async def send_markdown_keyboard(
self,
@@ -743,18 +782,19 @@ class QQOfficialClient:
if event_id:
body['event_id'] = event_id
- async with httpx.AsyncClient(timeout=30) as client:
+ async with self._http_client_context(timeout=30) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
+ response_body = await httpclient.response_text(response)
await self.logger.error(
- f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}'
+ f'Failed to send markdown+keyboard: HTTP {response.status_code} {response_body}'
)
- raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}')
- return response.json()
+ raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response_body}')
+ return await httpclient.parse_json_response(response)
async def ack_interaction(self, interaction_id: str, code: int = 0) -> None:
"""Acknowledge a button-click INTERACTION_CREATE event.
@@ -775,7 +815,7 @@ class QQOfficialClient:
await self.get_access_token()
url = f'{self.base_url}/interactions/{interaction_id}'
- async with httpx.AsyncClient(timeout=10) as client:
+ async with self._http_client_context(timeout=10) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
@@ -783,8 +823,9 @@ class QQOfficialClient:
try:
response = await client.put(url, headers=headers, json={'code': code})
if response.status_code >= 400:
+ response_body = await httpclient.response_text(response)
await self.logger.warning(
- f'ack_interaction non-success: HTTP {response.status_code} {response.text}'
+ f'ack_interaction non-success: HTTP {response.status_code} {response_body}'
)
except Exception as e:
await self.logger.warning(f'ack_interaction error (non-fatal): {e}')
@@ -796,10 +837,11 @@ class QQOfficialClient:
return time.time() > self.access_token_expiry_time
async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes:
- seed = bot_secret
- while len(seed) < target_size:
- seed *= 2
- return seed[:target_size].encode('utf-8')
+ if not bot_secret:
+ raise ValueError('QQ bot secret must not be empty')
+ target_size = max(int(target_size), 1)
+ repeats = (target_size + len(bot_secret) - 1) // len(bot_secret)
+ return (bot_secret * repeats)[:target_size].encode('utf-8')
async def verify(self, validation_payload: dict):
seed = await self.repeat_seed(self.secret)
@@ -843,19 +885,20 @@ class QQOfficialClient:
await self.get_access_token()
url = f'{self.base_url}/gateway'
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
}
response = await client.get(url, headers=headers)
if response.status_code == 200:
- data = response.json()
+ data = await httpclient.parse_json_response(response)
ws_url = data.get('url', '')
if not ws_url:
raise Exception('Gateway URL is empty')
return ws_url
else:
- raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
+ body = await httpclient.response_text(response)
+ raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {body}')
async def _background_token_refresh(self):
"""在 token 到期前主动刷新"""
@@ -935,7 +978,7 @@ class QQOfficialClient:
try:
await self.logger.info('Connecting to WebSocket gateway...')
- ws = await websockets.connect(ws_url)
+ ws = await websockets.connect(ws_url, max_size=_MAX_CALLBACK_BODY_BYTES)
await self.logger.info('WebSocket connected')
except Exception as e:
await self.logger.error(f'WebSocket connection failed: {e}')
@@ -948,7 +991,7 @@ class QQOfficialClient:
try:
async for raw_msg in ws:
try:
- payload = json.loads(raw_msg)
+ payload = await asyncio.to_thread(json.loads, raw_msg)
except json.JSONDecodeError:
await self.logger.error(f'Failed to parse message: {raw_msg}')
continue
diff --git a/src/langbot/libs/slack_api/api.py b/src/langbot/libs/slack_api/api.py
index 6f869b932..69d70b402 100644
--- a/src/langbot/libs/slack_api/api.py
+++ b/src/langbot/libs/slack_api/api.py
@@ -1,3 +1,4 @@
+import asyncio
import json
import traceback
from quart import Quart, jsonify, request
@@ -6,6 +7,8 @@ from .slackevent import SlackEvent
from typing import Callable
import langbot_plugin.api.entities.builtin.platform.events as platform_events
+_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
+
class SlackClient:
def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False):
@@ -13,6 +16,7 @@ class SlackClient:
self.signing_secret = signing_secret
self.unified_mode = unified_mode
self.app = Quart(__name__)
+ self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
self.client = AsyncWebClient(self.bot_token)
# 只有在非统一模式下才注册独立路由
@@ -50,7 +54,9 @@ class SlackClient:
"""
try:
body = await req.get_data()
- data = json.loads(body)
+ if len(body) > _MAX_CALLBACK_BODY_BYTES:
+ raise ValueError('Slack callback body exceeds the size limit')
+ data = await asyncio.to_thread(json.loads, body)
if 'type' in data:
if data['type'] == 'url_verification':
return data['challenge']
diff --git a/src/langbot/libs/wechatpad_api/api/downloadpai.py b/src/langbot/libs/wechatpad_api/api/downloadpai.py
index 3fbdb624a..b1c527e10 100644
--- a/src/langbot/libs/wechatpad_api/api/downloadpai.py
+++ b/src/langbot/libs/wechatpad_api/api/downloadpai.py
@@ -1,7 +1,32 @@
-from langbot.libs.wechatpad_api.util.http_util import post_json
-import httpx
+import asyncio
import base64
+import httpx
+
+from langbot.libs.wechatpad_api.util.http_util import post_json
+from langbot.pkg.utils import httpclient
+
+
+_MAX_WECHATPAD_MEDIA_BYTES = 16 * 1024 * 1024
+
+
+async def _read_media_limited(response: httpx.Response) -> bytes:
+ content_length = response.headers.get('content-length')
+ if content_length is not None:
+ try:
+ declared_size = int(content_length)
+ except ValueError:
+ declared_size = None
+ if declared_size is not None and declared_size > _MAX_WECHATPAD_MEDIA_BYTES:
+ raise RuntimeError('WeChatPad media exceeds the runtime limit')
+
+ body = bytearray()
+ async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
+ body.extend(chunk)
+ if len(body) > _MAX_WECHATPAD_MEDIA_BYTES:
+ raise RuntimeError('WeChatPad media exceeds the runtime limit')
+ return bytes(body)
+
class DownloadApi:
def __init__(self, base_url, token):
@@ -19,12 +44,13 @@ class DownloadApi:
return post_json(url, token=self.token, data=json_data)
async def download_url_to_base64(self, download_url):
- async with httpx.AsyncClient() as client:
- response = await client.get(download_url)
-
- if response.status_code == 200:
- file_bytes = response.content
- base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
- return base64_str
- else:
- raise Exception('获取文件失败')
+ async with httpx.AsyncClient(
+ timeout=30,
+ event_hooks=httpclient.httpx_response_limit_hooks(_MAX_WECHATPAD_MEDIA_BYTES),
+ ) as client:
+ async with client.stream('GET', download_url) as response:
+ if response.status_code != 200:
+ raise RuntimeError('获取文件失败')
+ file_bytes = await _read_media_limited(response)
+ encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
+ return encoded.decode('utf-8')
diff --git a/src/langbot/libs/wechatpad_api/util/http_util.py b/src/langbot/libs/wechatpad_api/util/http_util.py
index 7390f43ec..312afb416 100644
--- a/src/langbot/libs/wechatpad_api/util/http_util.py
+++ b/src/langbot/libs/wechatpad_api/util/http_util.py
@@ -1,6 +1,29 @@
+import json as json_module
+
import requests
from langbot.pkg.utils import httpclient
+_MAX_WECHATPAD_RESPONSE_BYTES = 16 * 1024 * 1024
+
+
+def _read_requests_response_limited(response: requests.Response) -> dict:
+ content_length = response.headers.get('Content-Length')
+ if content_length is not None:
+ try:
+ if int(content_length) > _MAX_WECHATPAD_RESPONSE_BYTES:
+ raise RuntimeError('WeChatPad response exceeds the runtime limit')
+ except (TypeError, ValueError):
+ pass
+ body = bytearray()
+ for chunk in response.iter_content(chunk_size=64 * 1024):
+ body.extend(chunk)
+ if len(body) > _MAX_WECHATPAD_RESPONSE_BYTES:
+ raise RuntimeError('WeChatPad response exceeds the runtime limit')
+ result = json_module.loads(body)
+ if not isinstance(result, dict):
+ raise RuntimeError('WeChatPad returned a non-object response')
+ return result
+
def post_json(base_url, token, data=None):
headers = {'Content-Type': 'application/json'}
@@ -8,16 +31,21 @@ def post_json(base_url, token, data=None):
url = base_url + f'?key={token}'
try:
- response = requests.post(url, json=data, headers=headers, timeout=60)
- response.raise_for_status()
- result = response.json()
+ with requests.post(
+ url,
+ json=data,
+ headers=headers,
+ timeout=60,
+ stream=True,
+ ) as response:
+ response.raise_for_status()
+ result = _read_requests_response_limited(response)
if result:
return result
else:
- raise RuntimeError(response.text)
+ raise RuntimeError('WeChatPad returned an empty response')
except Exception as e:
- print(f'http请求失败, url={url}, exception={e}')
raise RuntimeError(str(e))
@@ -27,16 +55,20 @@ def get_json(base_url, token):
url = base_url + f'?key={token}'
try:
- response = requests.get(url, headers=headers, timeout=60)
- response.raise_for_status()
- result = response.json()
+ with requests.get(
+ url,
+ headers=headers,
+ timeout=60,
+ stream=True,
+ ) as response:
+ response.raise_for_status()
+ result = _read_requests_response_limited(response)
if result:
return result
else:
- raise RuntimeError(response.text)
+ raise RuntimeError('WeChatPad returned an empty response')
except Exception as e:
- print(f'http请求失败, url={url}, exception={e}')
raise RuntimeError(str(e))
@@ -68,7 +100,12 @@ async def async_request(
method=method, url=url, params=params, headers=headers, data=data, json=json
) as response:
response.raise_for_status() # 如果状态码不是200,抛出异常
- result = await response.json()
+ result = json_module.loads(
+ await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_WECHATPAD_RESPONSE_BYTES,
+ )
+ )
# print(result)
return result
# if result.get('Code') == 200:
diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py
index 4e2c6a28a..63687943d 100644
--- a/src/langbot/libs/wecom_ai_bot_api/api.py
+++ b/src/langbot/libs/wecom_ai_bot_api/api.py
@@ -10,13 +10,16 @@ import re
from typing import Any, Callable, Optional, Tuple
from urllib.parse import unquote
-import httpx
from Crypto.Cipher import AES
from quart import Quart, request, Response, jsonify
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
from langbot.pkg.platform.logger import EventLogger
+from langbot.pkg.utils import httpclient
+
+_CLIENT_TRANSIENT_CACHE_MAX = 4096
+_MAX_STREAM_CONTENT_CHARS = 200000
@dataclass
@@ -56,7 +59,7 @@ class StreamSession:
last_access: float = field(default_factory=time.time)
# 将流水线增量结果缓存到队列,刷新请求逐条消费
- queue: asyncio.Queue = field(default_factory=asyncio.Queue)
+ queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=1))
# 是否已经完成(收到最终片段)
finished: bool = False
@@ -85,6 +88,7 @@ class StreamSessionManager:
# full like → cancel → dislike feedback flow. Must align with the adapter's
# _stream_to_monitoring_msg TTL (wecombot.py).
_FEEDBACK_SESSION_TTL = 600 # 10 minutes
+ _MAX_SESSIONS = 4096
def __init__(self, logger: EventLogger, ttl: int = 60) -> None:
self.logger = logger
@@ -165,6 +169,26 @@ class StreamSessionManager:
if task_id:
self._task_index.pop(task_id, None)
+ def clear(self) -> None:
+ """Release every retained stream and reverse index."""
+
+ self._sessions.clear()
+ self._msg_index.clear()
+ self._feedback_index.clear()
+ self._task_index.clear()
+
+ def _drop_session(self, stream_id: str) -> StreamSession | None:
+ session = self._sessions.pop(stream_id, None)
+ if session is None:
+ return None
+ if session.msg_id and self._msg_index.get(session.msg_id) == stream_id:
+ self._msg_index.pop(session.msg_id, None)
+ if session.feedback_id:
+ self._feedback_index.pop(session.feedback_id, None)
+ if session.pending_form_task_id:
+ self._task_index.pop(session.pending_form_task_id, None)
+ return session
+
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
"""根据企业微信回调创建或获取会话。
@@ -185,6 +209,14 @@ class StreamSessionManager:
session.last_access = time.time()
return session, False
+ self.cleanup()
+ while len(self._sessions) >= self._MAX_SESSIONS:
+ oldest_stream_id = min(
+ self._sessions,
+ key=lambda candidate: self._sessions[candidate].last_access,
+ )
+ self._drop_session(oldest_stream_id)
+
stream_id = str(uuid.uuid4())
session = StreamSession(
stream_id=stream_id,
@@ -221,8 +253,13 @@ class StreamSessionManager:
try:
session.queue.put_nowait(chunk)
except asyncio.QueueFull:
- # 默认无界队列,此处兜底防御
- await session.queue.put(chunk)
+ # Each chunk is a complete snapshot. Coalesce a slow consumer to
+ # the newest value instead of retaining every intermediate body.
+ try:
+ session.queue.get_nowait()
+ except asyncio.QueueEmpty:
+ pass
+ session.queue.put_nowait(chunk)
if chunk.is_final:
session.finished = True
@@ -265,7 +302,7 @@ class StreamSessionManager:
session.finished = True
session.last_access = time.time()
- def cleanup(self) -> None:
+ def cleanup(self) -> list[str]:
"""定期清理过期会话,防止队列与映射无上限累积。
已注册 feedback_id 的会话使用更长的 TTL,确保用户在点赞/取消/点踩流程中
@@ -279,16 +316,14 @@ class StreamSessionManager:
if now - session.last_access > effective_ttl:
expired.append(stream_id)
+ removed_msg_ids: list[str] = []
for stream_id in expired:
- session = self._sessions.pop(stream_id, None)
+ session = self._drop_session(stream_id)
if not session:
continue
- msg_id = session.msg_id
- if msg_id and self._msg_index.get(msg_id) == stream_id:
- self._msg_index.pop(msg_id, None)
- # Clean up feedback index for expired sessions
- if session.feedback_id:
- self._feedback_index.pop(session.feedback_id, None)
+ if session.msg_id:
+ removed_msg_ids.append(session.msg_id)
+ return removed_msg_ids
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
@@ -405,19 +440,19 @@ async def download_encrypted_file(
filename: Optional[str] = None
try:
- async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.get(download_url)
- if response.status_code != 200:
- await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
+ client = httpclient.get_session()
+ async with client.get(download_url, timeout=30.0) as response:
+ if response.status != 200:
+ await logger.error(f'Failed to download file (HTTP {response.status})')
return None, None
- encrypted_bytes = response.content
+ encrypted_bytes = await httpclient.read_limited(response)
filename = _extract_filename(response.headers.get('content-disposition', ''))
except Exception:
await logger.error(f'Failed to download file: {traceback.format_exc()}')
return None, None
try:
- decrypted = _decrypt_file(encrypted_bytes, aes_key)
+ decrypted = await asyncio.to_thread(_decrypt_file, encrypted_bytes, aes_key)
return decrypted, filename
except Exception:
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
@@ -466,7 +501,7 @@ async def parse_wecom_bot_message(
"""Download, decrypt, and convert to data URI for backward compatibility."""
data, _filename = await _safe_download(url, per_msg_aeskey)
if data:
- return _bytes_to_data_uri(data)
+ return await asyncio.to_thread(_bytes_to_data_uri, data)
return None
if msg_type == 'text':
@@ -579,7 +614,10 @@ async def parse_wecom_bot_message(
if (file_data.get('filesize') or 0) <= max_inline_file_size:
file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
if file_bytes:
- file_data['base64'] = _bytes_to_data_uri(file_bytes)
+ file_data['base64'] = await asyncio.to_thread(
+ _bytes_to_data_uri,
+ file_bytes,
+ )
if dl_filename and not file_data.get('filename'):
file_data['filename'] = dl_filename
files.append(file_data)
@@ -1567,6 +1605,8 @@ def build_multiple_interaction_update_card(
class WecomBotClient:
+ _MAX_DISPATCH_TASKS = 100
+
def __init__(
self,
Token: str,
@@ -1613,6 +1653,7 @@ class WecomBotClient:
self._feedback_callback: Optional[Callable] = None
self._card_action_callback: Optional[Callable] = None
self._stream_last_content: dict[str, str] = {}
+ self._dispatch_tasks: set[asyncio.Task] = set()
# Optional `source` block injected into every interactive template_card
# the client builds. Set via `set_card_source` from the adapter after
# reading config. Format: {icon_url, desc, desc_color}.
@@ -1695,7 +1736,12 @@ class WecomBotClient:
"""
reply_plain_str = json.dumps(payload, ensure_ascii=False)
reply_timestamp = str(int(time.time()))
- ret, encrypt_text = self.wxcpt.EncryptMsg(reply_plain_str, nonce, reply_timestamp)
+ ret, encrypt_text = await asyncio.to_thread(
+ self.wxcpt.EncryptMsg,
+ reply_plain_str,
+ nonce,
+ reply_timestamp,
+ )
if ret != 0:
await self.logger.error(f'加密失败: {ret}')
return jsonify({'error': 'encrypt_failed'}), 500
@@ -1718,6 +1764,41 @@ class WecomBotClient:
except Exception:
await self.logger.error(traceback.format_exc())
+ def _start_dispatch_task(self, event: wecombotevent.WecomBotEvent) -> bool:
+ """Start one bounded pipeline dispatch task."""
+
+ for task in tuple(self._dispatch_tasks):
+ if task.done():
+ self._dispatch_tasks.discard(task)
+ if len(self._dispatch_tasks) >= self._MAX_DISPATCH_TASKS:
+ return False
+
+ task = asyncio.create_task(self._dispatch_event(event))
+ self._dispatch_tasks.add(task)
+
+ def done(done_task: asyncio.Task) -> None:
+ self._dispatch_tasks.discard(done_task)
+ if not done_task.cancelled():
+ done_task.exception()
+
+ task.add_done_callback(done)
+ return True
+
+ async def close(self) -> None:
+ """Cancel callbacks and release retained webhook state."""
+
+ dispatch_tasks = list(self._dispatch_tasks)
+ for task in dispatch_tasks:
+ if not task.done():
+ task.cancel()
+ if dispatch_tasks:
+ await asyncio.gather(*dispatch_tasks, return_exceptions=True)
+ self._dispatch_tasks.clear()
+ self.generated_content.clear()
+ self.msg_id_map.clear()
+ self._stream_last_content.clear()
+ self.stream_sessions.clear()
+
async def _handle_post_initial_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""处理企业微信首次推送的消息,返回 stream_id 并开启流水线。
@@ -1747,7 +1828,8 @@ class WecomBotClient:
await self.logger.error(traceback.format_exc())
else:
if is_new:
- asyncio.create_task(self._dispatch_event(event))
+ if not self._start_dispatch_task(event):
+ await self.logger.warning('WeCom webhook dispatch capacity reached; dropping message')
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
return await self._encrypt_and_reply(payload, nonce)
@@ -1870,7 +1952,10 @@ class WecomBotClient:
async def _handle_post_callback(self, req) -> tuple[Response, int] | Response:
"""处理企业微信的 POST 回调请求。"""
- self.stream_sessions.cleanup()
+ for expired_msg_id in self.stream_sessions.cleanup():
+ self.generated_content.pop(expired_msg_id, None)
+ self._stream_last_content.pop(expired_msg_id, None)
+ self.msg_id_map.pop(expired_msg_id, None)
msg_signature = unquote(req.args.get('msg_signature', ''))
timestamp = unquote(req.args.get('timestamp', ''))
@@ -1883,12 +1968,18 @@ class WecomBotClient:
return Response('Bad Request', status=400)
xml_post_data = f''
- ret, decrypted_xml = self.wxcpt.DecryptMsg(xml_post_data, msg_signature, timestamp, nonce)
+ ret, decrypted_xml = await asyncio.to_thread(
+ self.wxcpt.DecryptMsg,
+ xml_post_data,
+ msg_signature,
+ timestamp,
+ nonce,
+ )
if ret != 0:
await self.logger.error('解密失败')
return Response('解密失败', status=400)
- msg_json = json.loads(decrypted_xml)
+ msg_json = await asyncio.to_thread(json.loads, decrypted_xml)
event_type = extract_wecom_event_type(msg_json)
@@ -2014,6 +2105,8 @@ class WecomBotClient:
self.msg_id_map[message_id] += 1
return
self.msg_id_map[message_id] = 1
+ while len(self.msg_id_map) > _CLIENT_TRANSIENT_CACHE_MAX:
+ self.msg_id_map.pop(next(iter(self.msg_id_map)), None)
msg_type = event.type
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
@@ -2047,6 +2140,8 @@ class WecomBotClient:
next_content = previous_content
else:
next_content = previous_content + content if previous_content else content
+ if len(next_content) > _MAX_STREAM_CONTENT_CHARS:
+ next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:]
if not is_final and next_content == previous_content:
return True
@@ -2096,7 +2191,9 @@ class WecomBotClient:
"""
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
if not handled:
- self.generated_content[msg_id] = content
+ self.generated_content[msg_id] = content[-_MAX_STREAM_CONTENT_CHARS:]
+ while len(self.generated_content) > _CLIENT_TRANSIENT_CACHE_MAX:
+ self.generated_content.pop(next(iter(self.generated_content)), None)
def on_message(self, msg_type: str):
def decorator(func: Callable[[wecombotevent.WecomBotEvent], None]):
@@ -2119,7 +2216,7 @@ class WecomBotClient:
async def download_url_to_base64(self, download_url, encoding_aes_key):
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
if data:
- return _bytes_to_data_uri(data)
+ return await asyncio.to_thread(_bytes_to_data_uri, data)
return None
async def run_task(self, host: str, port: int, *args, **kwargs):
diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py
index 32c11bd50..3fe985255 100644
--- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py
+++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py
@@ -47,6 +47,17 @@ CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
CMD_SEND_MSG = 'aibot_send_msg'
+_DEDUP_CACHE_MAX = 4096
+_STREAM_CACHE_MAX = 1024
+_FEEDBACK_CACHE_MAX = 4096
+_PENDING_FORM_MAX = 1024
+_PENDING_FORM_TTL_SECONDS = 1800
+_MAX_STREAM_CONTENT_CHARS = 200000
+_MAX_CALLBACK_TASKS = 100
+_MAX_REPLY_WORKERS = 100
+_MAX_REPLY_QUEUE_SIZE = 100
+_MAX_PENDING_ACKS = 256
+
def _generate_req_id(prefix: str) -> str:
"""Generate a unique request ID in the format: {prefix}_{timestamp}_{random}."""
@@ -106,6 +117,7 @@ class WecomBotWsClient:
# Per-req_id serial reply queues
self._reply_queues: dict[str, asyncio.Queue] = {}
self._reply_workers: dict[str, asyncio.Task] = {}
+ self._callback_tasks: set[asyncio.Task] = set()
self._reply_ack_timeout = 5.0
# Stream ID tracking for WebSocket mode
@@ -135,6 +147,31 @@ class WecomBotWsClient:
# `set_card_source` from the adapter after reading config.
self.card_source: Optional[dict] = None
+ @staticmethod
+ def _cap_mapping(mapping: dict, max_entries: int) -> None:
+ while len(mapping) > max_entries:
+ mapping.pop(next(iter(mapping)), None)
+
+ def _prune_stream_state(self) -> None:
+ while len(self._stream_sessions) > _STREAM_CACHE_MAX:
+ msg_id = next(iter(self._stream_sessions))
+ self._stream_sessions.pop(msg_id, None)
+ self._stream_ids.pop(msg_id, None)
+ self._stream_last_content.pop(msg_id, None)
+ task_id = self._task_id_by_msg.pop(msg_id, None)
+ if task_id:
+ self._pending_forms_by_task.pop(task_id, None)
+
+ def _prune_pending_forms(self) -> None:
+ cutoff = time.monotonic() - _PENDING_FORM_TTL_SECONDS
+ for task_id, pending in tuple(self._pending_forms_by_task.items()):
+ if float(pending.get('created_at', 0.0)) <= cutoff:
+ self._drop_pending_form_task(task_id, pending)
+ while len(self._pending_forms_by_task) > _PENDING_FORM_MAX:
+ task_id = next(iter(self._pending_forms_by_task))
+ pending = self._pending_forms_by_task.get(task_id, {})
+ self._drop_pending_form_task(task_id, pending)
+
# ── Public API ──────────────────────────────────────────────────
async def connect(self):
@@ -173,17 +210,40 @@ class WecomBotWsClient:
async def disconnect(self):
"""Gracefully disconnect from the WebSocket server."""
self._running = False
+ heartbeat_tasks = []
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
- for task in self._reply_workers.values():
+ heartbeat_tasks.append(self._heartbeat_task)
+ reply_workers = list(self._reply_workers.values())
+ for task in reply_workers:
if not task.done():
task.cancel()
+ callback_tasks = list(self._callback_tasks)
+ for task in callback_tasks:
+ if not task.done():
+ task.cancel()
+ shutdown_tasks = [*heartbeat_tasks, *reply_workers, *callback_tasks]
+ if shutdown_tasks:
+ await asyncio.gather(*shutdown_tasks, return_exceptions=True)
+ self._clear_pending_acks('Connection closed')
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
self._session = None
+ self._heartbeat_task = None
+ self._reply_queues.clear()
+ self._reply_workers.clear()
+ self._callback_tasks.clear()
+ self._stream_ids.clear()
+ self._stream_last_content.clear()
+ self._stream_sessions.clear()
+ self._feedback_sessions.clear()
+ self._msg_feedback_ids.clear()
+ self._pending_forms_by_task.clear()
+ self._task_id_by_msg.clear()
+ self._msg_id_map.clear()
def on_message(self, msg_type: str) -> Callable:
"""Decorator to register a message handler.
@@ -366,8 +426,10 @@ class WecomBotWsClient:
'chat_id': session_info.get('chat_id', ''),
'stream_id': stream_id,
'req_id': req_id,
+ 'created_at': time.monotonic(),
}
self._task_id_by_msg[msg_id] = task_id
+ self._prune_pending_forms()
card_payload = build_human_input_template_card_payload(
form_data,
@@ -458,6 +520,8 @@ class WecomBotWsClient:
next_content = previous_content
else:
next_content = previous_content + content if previous_content else content
+ if len(next_content) > _MAX_STREAM_CONTENT_CHARS:
+ next_content = next_content[-_MAX_STREAM_CONTENT_CHARS:]
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
if not is_final and next_content == previous_content:
@@ -485,6 +549,8 @@ class WecomBotWsClient:
session_info = self._stream_sessions.get(msg_id)
if session_info:
self._feedback_sessions[feedback_id] = session_info
+ self._cap_mapping(self._feedback_sessions, _FEEDBACK_CACHE_MAX)
+ self._cap_mapping(self._msg_feedback_ids, _FEEDBACK_CACHE_MAX)
# WeCom replaces the displayed stream content on each refresh, so
# every frame must contain the complete snapshot, not only a delta.
@@ -516,7 +582,7 @@ class WecomBotWsClient:
self._session = aiohttp.ClientSession()
try:
- self._ws = await self._session.ws_connect(self.ws_url)
+ self._ws = await self._session.ws_connect(self.ws_url, max_msg_size=1024 * 1024)
self._missed_pong_count = 0
self._reconnect_attempts = 0
await self.logger.info('WebSocket connected, sending auth...')
@@ -539,6 +605,8 @@ class WecomBotWsClient:
finally:
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
+ await asyncio.gather(self._heartbeat_task, return_exceptions=True)
+ self._heartbeat_task = None
self._clear_pending_acks('Connection closed')
finally:
if self._ws and not self._ws.closed:
@@ -565,7 +633,7 @@ class WecomBotWsClient:
try:
msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0)
if msg.type in (aiohttp.WSMsgType.TEXT,):
- frame = json.loads(msg.data)
+ frame = await asyncio.to_thread(json.loads, msg.data)
req_id = frame.get('headers', {}).get('req_id', '')
if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0:
return True
@@ -614,7 +682,7 @@ class WecomBotWsClient:
break
if msg.type == aiohttp.WSMsgType.TEXT:
try:
- frame = json.loads(msg.data)
+ frame = await asyncio.to_thread(json.loads, msg.data)
await self._handle_frame(frame)
except json.JSONDecodeError:
await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}')
@@ -622,7 +690,7 @@ class WecomBotWsClient:
await self.logger.error(f'Error handling frame: {traceback.format_exc()}')
elif msg.type == aiohttp.WSMsgType.BINARY:
try:
- frame = json.loads(msg.data)
+ frame = await asyncio.to_thread(json.loads, msg.data)
await self._handle_frame(frame)
except Exception:
await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}')
@@ -638,12 +706,14 @@ class WecomBotWsClient:
# Message push
if cmd == CMD_MSG_CALLBACK:
- asyncio.create_task(self._handle_message_callback(frame))
+ if not self._start_callback_task(self._handle_message_callback(frame)):
+ await self.logger.warning('WeCom WebSocket callback capacity reached; dropping message')
return
# Event push
if cmd == CMD_EVENT_CALLBACK:
- asyncio.create_task(self._handle_event_callback(frame))
+ if not self._start_callback_task(self._handle_event_callback(frame)):
+ await self.logger.warning('WeCom WebSocket callback capacity reached; dropping event')
return
# No cmd → response/ACK frame, dispatch by req_id prefix
@@ -665,6 +735,27 @@ class WecomBotWsClient:
# Unknown frame
await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}')
+ def _start_callback_task(self, coro) -> bool:
+ """Start one bounded inbound frame callback."""
+
+ for task in tuple(self._callback_tasks):
+ if task.done():
+ self._callback_tasks.discard(task)
+ if len(self._callback_tasks) >= _MAX_CALLBACK_TASKS:
+ coro.close()
+ return False
+
+ task = asyncio.create_task(coro)
+ self._callback_tasks.add(task)
+
+ def done(done_task: asyncio.Task) -> None:
+ self._callback_tasks.discard(done_task)
+ if not done_task.cancelled():
+ done_task.exception()
+
+ task.add_done_callback(done)
+ return True
+
async def _handle_message_callback(self, frame: dict):
"""Handle an incoming message callback frame."""
try:
@@ -697,6 +788,7 @@ class WecomBotWsClient:
'chat_id': message_data.get('chatid', ''),
'chat_type': message_data.get('type', 'single'),
}
+ self._prune_stream_state()
message_data['stream_id'] = stream_id
message_data['req_id'] = req_id
@@ -748,7 +840,7 @@ class WecomBotWsClient:
)
# Look up session by feedback_id
- session_info = self._feedback_sessions.get(feedback_id)
+ session_info = self._feedback_sessions.pop(feedback_id, None)
session = None
if session_info:
session = StreamSession(
@@ -806,6 +898,10 @@ class WecomBotWsClient:
if pending is None:
await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored')
return
+ if time.monotonic() - float(pending.get('created_at', 0.0)) > _PENDING_FORM_TTL_SECONDS:
+ self._drop_pending_form_task(task_id, pending)
+ await self.logger.warning(f'Pending form expired for task_id={task_id} (ws)')
+ return
req_id_for_update = frame.get('headers', {}).get('req_id', '')
form_data = pending.get('form_data', {}) or {}
@@ -868,6 +964,7 @@ class WecomBotWsClient:
self._msg_id_map[message_id] += 1
return
self._msg_id_map[message_id] = 1
+ self._cap_mapping(self._msg_id_map, _DEDUP_CACHE_MAX)
msg_type = event.type
if msg_type in self._message_handlers:
@@ -899,40 +996,61 @@ class WecomBotWsClient:
# Ensure serial delivery per req_id
if req_id not in self._reply_queues:
- self._reply_queues[req_id] = asyncio.Queue()
+ if len(self._reply_queues) >= _MAX_REPLY_WORKERS:
+ await self.logger.warning('WeCom WebSocket reply worker capacity reached; dropping reply')
+ return None
+ self._reply_queues[req_id] = asyncio.Queue(maxsize=_MAX_REPLY_QUEUE_SIZE)
self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id))
future: asyncio.Future = asyncio.get_event_loop().create_future()
- await self._reply_queues[req_id].put((frame, future))
+ try:
+ self._reply_queues[req_id].put_nowait((frame, future))
+ except asyncio.QueueFull:
+ await self.logger.warning(f'WeCom WebSocket reply queue full for req_id={req_id}; dropping reply')
+ return None
return await future
async def _reply_queue_worker(self, req_id: str):
"""Process reply queue items serially for a given req_id."""
queue = self._reply_queues[req_id]
+ current_future: asyncio.Future | None = None
try:
while self._running:
try:
- frame, future = await asyncio.wait_for(queue.get(), timeout=60.0)
+ frame, current_future = await asyncio.wait_for(queue.get(), timeout=60.0)
except asyncio.TimeoutError:
# Queue idle, clean up worker
break
try:
ack = await self._send_and_wait_ack(frame)
- if not future.done():
- future.set_result(ack)
+ if not current_future.done():
+ current_future.set_result(ack)
except Exception as e:
- if not future.done():
- future.set_exception(e)
+ if not current_future.done():
+ current_future.set_exception(e)
+ finally:
+ current_future = None
except asyncio.CancelledError:
- pass
+ if current_future is not None and not current_future.done():
+ current_future.set_exception(ConnectionError('Connection closed'))
finally:
+ while True:
+ try:
+ _, future = queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ if not future.done():
+ future.set_exception(ConnectionError('Reply worker stopped'))
self._reply_queues.pop(req_id, None)
self._reply_workers.pop(req_id, None)
async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]:
"""Send a frame and wait for the corresponding ACK."""
req_id = frame['headers']['req_id']
+ if len(self._pending_acks) >= _MAX_PENDING_ACKS:
+ await self.logger.warning('WeCom WebSocket pending ACK capacity reached; dropping frame')
+ return None
ack_future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending_acks[req_id] = ack_future
diff --git a/src/langbot/libs/wecom_api/api.py b/src/langbot/libs/wecom_api/api.py
index b6c7ef0b7..72bd7ade7 100644
--- a/src/langbot/libs/wecom_api/api.py
+++ b/src/langbot/libs/wecom_api/api.py
@@ -1,16 +1,82 @@
from quart import request
from .WXBizMsgCrypt3 import WXBizMsgCrypt
+import asyncio
import base64
import binascii
+import contextvars
+import functools
import httpx
+import os
import traceback
from urllib.parse import quote
from quart import Quart
import xml.etree.ElementTree as ET
+from contextlib import asynccontextmanager
from typing import Callable, Dict, Any
from .wecomevent import WecomEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import aiofiles
+from langbot.pkg.utils import httpclient
+
+_MAX_MEDIA_BYTES = 10 * 1024 * 1024
+_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
+_EXTENDED_HTTP_TIMEOUT_SECONDS = 120
+
+
+async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
+ content_length = response.headers.get('Content-Length')
+ if content_length is not None:
+ try:
+ if int(content_length) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom media exceeds the size limit')
+ except (TypeError, ValueError) as exc:
+ if 'exceeds' in str(exc):
+ raise
+ content = bytearray()
+ async for chunk in response.aiter_bytes():
+ content.extend(chunk)
+ if len(content) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom media exceeds the size limit')
+ return bytes(content)
+
+
+async def _read_local_media_limited(path: str) -> bytes:
+ if await asyncio.to_thread(os.path.getsize, path) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom media exceeds the size limit')
+ async with aiofiles.open(path, 'rb') as file:
+ content = await file.read(_MAX_MEDIA_BYTES + 1)
+ if len(content) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom media exceeds the size limit')
+ return content
+
+
+async def _decode_media_base64_limited(value: str) -> bytes:
+ max_encoded_chars = 4 * ((_MAX_MEDIA_BYTES + 2) // 3) + 4
+ if len(value) > max_encoded_chars:
+ raise ValueError('WeCom media exceeds the size limit')
+ content = await asyncio.to_thread(base64.b64decode, value)
+ if len(content) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom media exceeds the size limit')
+ return content
+
+
+def _bounded_token_retry(method):
+ """Allow one token-refresh retry without unbounded async recursion."""
+
+ depth = contextvars.ContextVar(f'{method.__name__}_token_retry_depth', default=0)
+
+ @functools.wraps(method)
+ async def wrapped(*args, **kwargs):
+ current_depth = depth.get()
+ if current_depth >= 2:
+ raise RuntimeError(f'{method.__name__} exceeded the token refresh retry limit')
+ token = depth.set(current_depth + 1)
+ try:
+ return await method(*args, **kwargs)
+ finally:
+ depth.reset(token)
+
+ return wrapped
class WecomClient:
@@ -36,6 +102,7 @@ class WecomClient:
self.logger = logger
self.unified_mode = unified_mode
self.app = Quart(__name__)
+ self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
@@ -49,6 +116,29 @@ class WecomClient:
self._message_handlers = {
'example': [],
}
+ self._http_clients: dict[bool, httpx.AsyncClient] = {}
+
+ @asynccontextmanager
+ async def _http_client_context(self, *, unbounded_timeout: bool = False):
+ client = self._http_clients.get(unbounded_timeout)
+ if client is None or client.is_closed:
+ response_hooks = httpclient.httpx_response_limit_hooks()
+ client = (
+ httpx.AsyncClient(
+ timeout=_EXTENDED_HTTP_TIMEOUT_SECONDS,
+ event_hooks=response_hooks,
+ )
+ if unbounded_timeout
+ else httpx.AsyncClient(event_hooks=response_hooks)
+ )
+ self._http_clients[unbounded_timeout] = client
+ yield client
+
+ async def close(self) -> None:
+ clients = list(self._http_clients.values())
+ self._http_clients.clear()
+ if clients:
+ await asyncio.gather(*(client.aclose() for client in clients), return_exceptions=True)
# access——token操作
async def check_access_token(self):
@@ -59,15 +149,16 @@ class WecomClient:
async def get_access_token(self, secret):
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.get(url)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if 'access_token' in data:
return data['access_token']
else:
- await self.logger.error(f'获取accesstoken失败:{response.json()}')
+ await self.logger.error(f'获取accesstoken失败:{data}')
raise Exception(f'未获取access token: {data}')
+ @_bounded_token_retry
async def get_user_info(self, userid: str) -> dict:
"""
Get user information by user ID using the application secret.
@@ -82,9 +173,9 @@ class WecomClient:
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.get(url)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.get_user_info(userid)
@@ -98,13 +189,13 @@ class WecomClient:
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
url = self.base_url + '/user/list_id?access_token=' + self.access_token_for_contacts
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'cursor': '',
'limit': 10000,
}
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 0:
dept_users = data['dept_user']
userid = []
@@ -121,7 +212,7 @@ class WecomClient:
url = self.base_url + '/message/send?access_token=' + self.access_token_for_contacts
user_ids = await self.get_users()
user_ids_string = '|'.join(user_ids)
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'touser': user_ids_string,
'msgtype': 'text',
@@ -135,16 +226,17 @@ class WecomClient:
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] != 0:
raise Exception('Failed to send message: ' + str(data))
+ @_bounded_token_retry
async def send_image(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'touser': user_id,
'msgtype': 'image',
@@ -158,7 +250,7 @@ class WecomClient:
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_image(user_id, agent_id, media_id)
@@ -166,11 +258,12 @@ class WecomClient:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(data))
+ @_bounded_token_retry
async def send_voice(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'touser': user_id,
'msgtype': 'voice',
@@ -184,7 +277,7 @@ class WecomClient:
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_voice(user_id, agent_id, media_id)
@@ -192,11 +285,12 @@ class WecomClient:
await self.logger.error(f'发送语音失败:{data}')
raise Exception('Failed to send voice: ' + str(data))
+ @_bounded_token_retry
async def send_file(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'touser': user_id,
'msgtype': 'file',
@@ -210,7 +304,7 @@ class WecomClient:
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_file(user_id, agent_id, media_id)
@@ -218,12 +312,13 @@ class WecomClient:
await self.logger.error(f'发送文件失败:{data}')
raise Exception('Failed to send file: ' + str(data))
+ @_bounded_token_retry
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
- async with httpx.AsyncClient(timeout=None) as client:
+ async with self._http_client_context(unbounded_timeout=True) as client:
params = {
'touser': user_id,
'msgtype': 'text',
@@ -237,7 +332,7 @@ class WecomClient:
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_private_msg(user_id, agent_id, content)
@@ -283,7 +378,15 @@ class WecomClient:
elif req.method == 'POST':
encrypt_msg = await req.data
- ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
+ if len(encrypt_msg) > _MAX_CALLBACK_BODY_BYTES:
+ raise ValueError('WeCom callback body exceeds the size limit')
+ ret, xml_msg = await asyncio.to_thread(
+ wxcpt.DecryptMsg,
+ encrypt_msg,
+ msg_signature,
+ timestamp,
+ nonce,
+ )
if ret != 0:
await self.logger.error('消息解密失败')
raise Exception(f'消息解密失败,错误码: {ret}')
@@ -332,7 +435,7 @@ class WecomClient:
"""
解析微信返回的 XML 消息并转换为字典。
"""
- root = ET.fromstring(xml_msg)
+ root = await asyncio.to_thread(ET.fromstring, xml_msg)
message_data = {
'ToUserName': root.find('ToUserName').text,
'FromUserName': root.find('FromUserName').text,
@@ -366,6 +469,7 @@ class WecomClient:
return ext
return 'jpg' # 默认返回jpg
+ @_bounded_token_retry
async def upload_image_to_work(self, image: platform_message.Image):
"""
获取 media_id
@@ -379,9 +483,8 @@ class WecomClient:
# 获取文件的二进制数据
if image.path:
- async with aiofiles.open(image.path, 'rb') as f:
- file_bytes = await f.read()
- file_name = image.path.split('/')[-1]
+ file_bytes = await _read_local_media_limited(image.path)
+ file_name = image.path.split('/')[-1]
elif image.url:
file_bytes = await self.download_media_to_bytes(image.url)
file_name = image.url.split('/')[-1]
@@ -392,7 +495,7 @@ class WecomClient:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
- file_bytes = base64.b64decode(padded_base64)
+ file_bytes = await _decode_media_base64_limited(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
@@ -400,6 +503,8 @@ class WecomClient:
raise ValueError('image对象出错')
# 设置 multipart/form-data 格式的文件
+ if len(file_bytes) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom media exceeds the size limit')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
@@ -413,9 +518,9 @@ class WecomClient:
)
# 上传文件
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, content=body)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_image_to_work(image)
@@ -426,6 +531,7 @@ class WecomClient:
media_id = data.get('media_id')
return media_id
+ @_bounded_token_retry
async def upload_voice_to_work(self, voice: platform_message.Voice):
"""
上传语音文件到企业微信
@@ -437,9 +543,8 @@ class WecomClient:
file_name = 'voice.mp3'
if voice.path:
- async with aiofiles.open(voice.path, 'rb') as f:
- file_bytes = await f.read()
- file_name = voice.path.split('/')[-1]
+ file_bytes = await _read_local_media_limited(voice.path)
+ file_name = voice.path.split('/')[-1]
elif voice.url:
file_bytes = await self.download_media_to_bytes(voice.url)
file_name = voice.url.split('/')[-1]
@@ -450,13 +555,15 @@ class WecomClient:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
- file_bytes = base64.b64decode(padded_base64)
+ file_bytes = await _decode_media_base64_limited(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error('Voice对象出错')
raise ValueError('voice对象出错')
+ if len(file_bytes) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom media exceeds the size limit')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
@@ -470,9 +577,9 @@ class WecomClient:
)
# print(body)
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, content=body)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_voice_to_work(voice)
@@ -482,6 +589,7 @@ class WecomClient:
media_id = data.get('media_id')
return media_id
+ @_bounded_token_retry
async def upload_file_to_work(self, file: platform_message.File):
"""
上传文件到企业微信
@@ -492,9 +600,8 @@ class WecomClient:
file_bytes = None
file_name = 'file.txt'
if file.path:
- async with aiofiles.open(file.path, 'rb') as f:
- file_bytes = await f.read()
- file_name = file.path.split('/')[-1]
+ file_bytes = await _read_local_media_limited(file.path)
+ file_name = file.path.split('/')[-1]
elif file.url:
file_bytes = await self.download_media_to_bytes(file.url)
file_name = file.url.split('/')[-1]
@@ -505,12 +612,14 @@ class WecomClient:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
- file_bytes = base64.b64decode(padded_base64)
+ file_bytes = await _decode_media_base64_limited(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error('File对象出错')
raise ValueError('file对象出错')
+ if len(file_bytes) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom media exceeds the size limit')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
@@ -522,9 +631,9 @@ class WecomClient:
+ file_bytes
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
)
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, content=body)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_file_to_work(file)
@@ -535,10 +644,10 @@ class WecomClient:
return media_id
async def download_media_to_bytes(self, url: str) -> bytes:
- async with httpx.AsyncClient() as client:
- response = await client.get(url)
- response.raise_for_status()
- return response.content
+ async with self._http_client_context() as client:
+ async with client.stream('GET', url) as response:
+ response.raise_for_status()
+ return await _read_httpx_media_limited(response)
# 进行media_id的获取
async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):
diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py
index 70270b727..18561da0e 100644
--- a/src/langbot/libs/wecom_customer_service_api/api.py
+++ b/src/langbot/libs/wecom_customer_service_api/api.py
@@ -1,8 +1,13 @@
from quart import request
from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
+import asyncio
import base64
import binascii
+import contextvars
+import functools
import httpx
+import json
+import os
import traceback
from quart import Quart
import xml.etree.ElementTree as ET
@@ -11,9 +16,72 @@ from .wecomcsevent import WecomCSEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import aiofiles
import time
+from contextlib import asynccontextmanager
+from langbot.pkg.utils import httpclient
+
+_MAX_MEDIA_BYTES = 10 * 1024 * 1024
+_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
+
+
+async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
+ content_length = response.headers.get('Content-Length')
+ if content_length is not None:
+ try:
+ if int(content_length) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom customer-service media exceeds the size limit')
+ except (TypeError, ValueError) as exc:
+ if 'exceeds' in str(exc):
+ raise
+ content = bytearray()
+ async for chunk in response.aiter_bytes():
+ content.extend(chunk)
+ if len(content) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom customer-service media exceeds the size limit')
+ return bytes(content)
+
+
+async def _read_local_media_limited(path: str) -> bytes:
+ if await asyncio.to_thread(os.path.getsize, path) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom customer-service media exceeds the size limit')
+ async with aiofiles.open(path, 'rb') as file:
+ content = await file.read(_MAX_MEDIA_BYTES + 1)
+ if len(content) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom customer-service media exceeds the size limit')
+ return content
+
+
+async def _decode_media_base64_limited(value: str) -> bytes:
+ max_encoded_chars = 4 * ((_MAX_MEDIA_BYTES + 2) // 3) + 4
+ if len(value) > max_encoded_chars:
+ raise ValueError('WeCom customer-service media exceeds the size limit')
+ content = await asyncio.to_thread(base64.b64decode, value)
+ if len(content) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom customer-service media exceeds the size limit')
+ return content
+
+
+def _bounded_token_retry(method):
+ """Allow one token-refresh retry without unbounded async recursion."""
+
+ depth = contextvars.ContextVar(f'{method.__name__}_token_retry_depth', default=0)
+
+ @functools.wraps(method)
+ async def wrapped(*args, **kwargs):
+ current_depth = depth.get()
+ if current_depth >= 2:
+ raise RuntimeError(f'{method.__name__} exceeded the token refresh retry limit')
+ token = depth.set(current_depth + 1)
+ try:
+ return await method(*args, **kwargs)
+ finally:
+ depth.reset(token)
+
+ return wrapped
class WecomCSClient:
+ _CUSTOMER_CACHE_MAX = 4096
+
def __init__(
self,
corpid: str,
@@ -34,10 +102,12 @@ class WecomCSClient:
self.logger = logger
self.unified_mode = unified_mode
self.app = Quart(__name__)
+ self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
# Customer info cache: {external_userid: (info_dict, timestamp)}
self._customer_cache: dict[str, tuple[dict, float]] = {}
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
+ self._customer_cache_cleanup_at = 0.0
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
@@ -48,29 +118,40 @@ class WecomCSClient:
self._message_handlers = {
'example': [],
}
+ self._http_client: httpx.AsyncClient | None = None
+ @asynccontextmanager
+ async def _http_client_context(self):
+ if self._http_client is None or self._http_client.is_closed:
+ self._http_client = httpx.AsyncClient(event_hooks=httpclient.httpx_response_limit_hooks())
+ yield self._http_client
+
+ async def close(self) -> None:
+ if self._http_client is not None:
+ await self._http_client.aclose()
+ self._http_client = None
+
+ @_bounded_token_retry
async def get_pic_url(self, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}'
- async with httpx.AsyncClient() as client:
- response = await client.get(url)
- if response.headers.get('Content-Type', '').startswith('application/json'):
- data = response.json()
- if data.get('errcode') in [40014, 42001]:
- self.access_token = await self.get_access_token(self.secret)
- return await self.get_pic_url(media_id)
- else:
+ async with self._http_client_context() as client:
+ async with client.stream('GET', url) as response:
+ image_bytes = await _read_httpx_media_limited(response)
+ content_type = response.headers.get('Content-Type', '')
+ if content_type.startswith('application/json'):
+ data = json.loads(image_bytes)
+ if data.get('errcode') in [40014, 42001]:
+ self.access_token = await self.get_access_token(self.secret)
+ return await self.get_pic_url(media_id)
raise Exception('Failed to get image: ' + str(data))
- # 否则是图片,转成 base64
- image_bytes = response.content
- content_type = response.headers.get('Content-Type', '')
- base64_str = base64.b64encode(image_bytes).decode('utf-8')
- base64_str = f'data:{content_type};base64,{base64_str}'
- return base64_str
+ # 否则是图片,转成 base64
+ base64_str = (await asyncio.to_thread(base64.b64encode, image_bytes)).decode('utf-8')
+ return f'data:{content_type};base64,{base64_str}'
# access——token操作
async def check_access_token(self):
@@ -81,19 +162,20 @@ class WecomCSClient:
async def get_access_token(self, secret):
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.get(url)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if 'access_token' in data:
return data['access_token']
else:
raise Exception(f'未获取access token: {data}')
+ @_bounded_token_retry
async def get_detailed_message_list(self, xml_msg: str):
# 在本方法中解析消息,并且获得消息的具体内容
if isinstance(xml_msg, bytes):
xml_msg = xml_msg.decode('utf-8')
- root = ET.fromstring(xml_msg)
+ root = await asyncio.to_thread(ET.fromstring, xml_msg)
token = root.find('Token').text
open_kfid = root.find('OpenKfId').text
@@ -106,14 +188,14 @@ class WecomCSClient:
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'token': token,
'voice_format': 0,
'open_kfid': open_kfid,
}
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.get_detailed_message_list(xml_msg)
@@ -130,11 +212,12 @@ class WecomCSClient:
# await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer)
return last_msg_data
+ @_bounded_token_retry
async def change_service_status(self, userid: str, openkfid: str, servicer: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/kf/service_state/get?access_token=' + self.access_token
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'open_kfid': openkfid,
'external_userid': userid,
@@ -142,18 +225,19 @@ class WecomCSClient:
'servicer_userid': servicer,
}
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
- return await self.change_service_status(userid, openkfid)
+ return await self.change_service_status(userid, openkfid, servicer)
if data['errcode'] != 0:
raise Exception('Failed to change service status: ' + str(data))
+ @_bounded_token_retry
async def send_image(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
params = {
'touser': user_id,
'toparty': '',
@@ -170,7 +254,7 @@ class WecomCSClient:
}
try:
response = await client.post(url, json=params)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
except Exception as e:
raise Exception('Failed to send image: ' + str(e))
@@ -182,6 +266,7 @@ class WecomCSClient:
if data['errcode'] != 0:
raise Exception('Failed to send image: ' + str(data))
+ @_bounded_token_retry
async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, content: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
@@ -198,10 +283,10 @@ class WecomCSClient:
},
}
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, json=payload)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
@@ -250,7 +335,15 @@ class WecomCSClient:
elif req.method == 'POST':
encrypt_msg = await req.data
- ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
+ if len(encrypt_msg) > _MAX_CALLBACK_BODY_BYTES:
+ raise ValueError('WeCom customer-service callback body exceeds the size limit')
+ ret, xml_msg = await asyncio.to_thread(
+ wxcpt.DecryptMsg,
+ encrypt_msg,
+ msg_signature,
+ timestamp,
+ nonce,
+ )
if ret != 0:
raise Exception(f'消息解密失败,错误码: {ret}')
@@ -315,6 +408,7 @@ class WecomCSClient:
return ext
return 'jpg' # 默认返回jpg
+ @_bounded_token_retry
async def upload_to_work(self, image: platform_message.Image):
"""
获取 media_id
@@ -328,9 +422,8 @@ class WecomCSClient:
# 获取文件的二进制数据
if image.path:
- async with aiofiles.open(image.path, 'rb') as f:
- file_bytes = await f.read()
- file_name = image.path.split('/')[-1]
+ file_bytes = await _read_local_media_limited(image.path)
+ file_name = image.path.split('/')[-1]
elif image.url:
file_bytes = await self.download_image_to_bytes(image.url)
file_name = image.url.split('/')[-1]
@@ -341,13 +434,15 @@ class WecomCSClient:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
- file_bytes = base64.b64decode(padded_base64)
+ file_bytes = await _decode_media_base64_limited(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
raise ValueError('image对象出错')
# 设置 multipart/form-data 格式的文件
+ if len(file_bytes) > _MAX_MEDIA_BYTES:
+ raise ValueError('WeCom customer-service media exceeds the size limit')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
@@ -361,9 +456,9 @@ class WecomCSClient:
)
# 上传文件
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, headers=headers, content=body)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_to_work(image)
@@ -374,16 +469,17 @@ class WecomCSClient:
return media_id
async def download_image_to_bytes(self, url: str) -> bytes:
- async with httpx.AsyncClient() as client:
- response = await client.get(url)
- response.raise_for_status()
- return response.content
+ async with self._http_client_context() as client:
+ async with client.stream('GET', url) as response:
+ response.raise_for_status()
+ return await _read_httpx_media_limited(response)
# 进行media_id的获取
async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image)
return media_id
+ @_bounded_token_retry
async def get_customer_info(self, external_userid: str) -> dict | None:
"""
Get customer information by external_userid with caching.
@@ -398,6 +494,11 @@ class WecomCSClient:
"""
# Check cache first
current_time = time.time()
+ if current_time - self._customer_cache_cleanup_at >= 30:
+ self._customer_cache_cleanup_at = current_time
+ for user_id, (_, cached_time) in tuple(self._customer_cache.items()):
+ if current_time - cached_time >= self._cache_ttl:
+ self._customer_cache.pop(user_id, None)
if external_userid in self._customer_cache:
cached_info, cached_time = self._customer_cache[external_userid]
if current_time - cached_time < self._cache_ttl:
@@ -413,9 +514,9 @@ class WecomCSClient:
'external_userid_list': [external_userid],
}
- async with httpx.AsyncClient() as client:
+ async with self._http_client_context() as client:
response = await client.post(url, json=payload)
- data = response.json()
+ data = await httpclient.parse_json_response(response)
if data.get('errcode') in [40014, 42001]:
self.access_token = await self.get_access_token(self.secret)
@@ -431,5 +532,10 @@ class WecomCSClient:
customer_info = customer_list[0]
# Store in cache
self._customer_cache[external_userid] = (customer_info, current_time)
+ while len(self._customer_cache) > self._CUSTOMER_CACHE_MAX:
+ self._customer_cache.pop(next(iter(self._customer_cache)), None)
return customer_info
return None
+
+ def clear(self) -> None:
+ self._customer_cache.clear()
diff --git a/src/langbot/libs/weknora_api/client.py b/src/langbot/libs/weknora_api/client.py
index f753136d8..c0a7724cb 100644
--- a/src/langbot/libs/weknora_api/client.py
+++ b/src/langbot/libs/weknora_api/client.py
@@ -6,6 +6,56 @@ import json
from .errors import WeKnoraAPIError
+_MAX_WENKORA_RESPONSE_BYTES = 1024 * 1024
+_MAX_WENKORA_STREAM_BYTES = 16 * 1024 * 1024
+_MAX_WENKORA_SSE_LINE_BYTES = 1024 * 1024
+
+
+async def _read_limited_response(response: httpx.Response) -> bytes:
+ body = bytearray()
+ async for chunk in response.aiter_bytes(chunk_size=8192):
+ body.extend(chunk)
+ if len(body) > _MAX_WENKORA_RESPONSE_BYTES:
+ raise WeKnoraAPIError('WeKnora response exceeds the runtime limit')
+ return bytes(body)
+
+
+async def _iter_sse_json(
+ response: httpx.Response,
+) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
+ buffer = bytearray()
+ total = 0
+ async for chunk in response.aiter_bytes(chunk_size=8192):
+ total += len(chunk)
+ if total > _MAX_WENKORA_STREAM_BYTES:
+ raise WeKnoraAPIError('WeKnora stream exceeds the runtime limit')
+ buffer.extend(chunk)
+ while b'\n' in buffer:
+ raw_line, _, remainder = buffer.partition(b'\n')
+ buffer = bytearray(remainder)
+ if len(raw_line) > _MAX_WENKORA_SSE_LINE_BYTES:
+ raise WeKnoraAPIError('WeKnora SSE event exceeds the runtime limit')
+ line = raw_line.rstrip(b'\r').strip()
+ if not line.startswith(b'data:'):
+ continue
+ try:
+ data = json.loads(line[5:].strip())
+ except json.JSONDecodeError:
+ continue
+ if isinstance(data, dict):
+ yield data
+ if len(buffer) > _MAX_WENKORA_SSE_LINE_BYTES:
+ raise WeKnoraAPIError('WeKnora SSE event exceeds the runtime limit')
+
+ line = bytes(buffer).rstrip(b'\r').strip()
+ if line.startswith(b'data:'):
+ try:
+ data = json.loads(line[5:].strip())
+ except json.JSONDecodeError:
+ return
+ if isinstance(data, dict):
+ yield data
+
class AsyncWeKnoraClient:
"""WeKnora API 客户端"""
@@ -39,19 +89,19 @@ class AsyncWeKnoraClient:
if description:
payload['description'] = description
- response = await client.post(
+ async with client.stream(
+ 'POST',
'/sessions',
headers={
'X-API-Key': self.api_key,
'Content-Type': 'application/json',
},
json=payload,
- )
-
- if response.status_code not in (200, 201):
- raise WeKnoraAPIError(f'{response.status_code} {response.text}')
-
- data = response.json()
+ ) as response:
+ body = await _read_limited_response(response)
+ if response.status_code not in (200, 201):
+ raise WeKnoraAPIError(f'{response.status_code} {body.decode("utf-8", errors="replace")}')
+ data = json.loads(body)
return data['data']['id']
async def agent_chat(
@@ -107,20 +157,13 @@ class AsyncWeKnoraClient:
},
json=payload,
) as r:
- async for chunk in r.aiter_lines():
- if r.status_code != 200:
- raise WeKnoraAPIError(f'{r.status_code} {chunk}')
- if chunk.strip() == '':
- continue
- if chunk.startswith('data:'):
- try:
- data = json.loads(chunk[5:].strip())
- except json.JSONDecodeError:
- continue
- yield data
- # 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
- if data.get('response_type') == 'error':
- return
+ if r.status_code != 200:
+ body = await _read_limited_response(r)
+ raise WeKnoraAPIError(f'{r.status_code} {body.decode("utf-8", errors="replace")}')
+ async for data in _iter_sse_json(r):
+ yield data
+ if data.get('response_type') == 'error':
+ return
async def knowledge_chat(
self,
@@ -164,17 +207,10 @@ class AsyncWeKnoraClient:
},
json=payload,
) as r:
- async for chunk in r.aiter_lines():
- if r.status_code != 200:
- raise WeKnoraAPIError(f'{r.status_code} {chunk}')
- if chunk.strip() == '':
- continue
- if chunk.startswith('data:'):
- try:
- data = json.loads(chunk[5:].strip())
- except json.JSONDecodeError:
- continue
- yield data
- # 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
- if data.get('response_type') == 'error':
- return
+ if r.status_code != 200:
+ body = await _read_limited_response(r)
+ raise WeKnoraAPIError(f'{r.status_code} {body.decode("utf-8", errors="replace")}')
+ async for data in _iter_sse_json(r):
+ yield data
+ if data.get('response_type') == 'error':
+ return
diff --git a/src/langbot/pkg/api/http/authz.py b/src/langbot/pkg/api/http/authz.py
new file mode 100644
index 000000000..36b796fe7
--- /dev/null
+++ b/src/langbot/pkg/api/http/authz.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import enum
+import types
+import typing
+
+from .context import RequestContext
+
+
+class WorkspaceRole(enum.StrEnum):
+ OWNER = 'owner'
+ ADMIN = 'admin'
+ DEVELOPER = 'developer'
+ OPERATOR = 'operator'
+ VIEWER = 'viewer'
+
+
+class Permission(enum.StrEnum):
+ WORKSPACE_VIEW = 'workspace.view'
+ WORKSPACE_UPDATE = 'workspace.update'
+ WORKSPACE_DELETE = 'workspace.delete'
+ OWNER_TRANSFER = 'owner.transfer'
+ MEMBER_VIEW = 'member.view'
+ MEMBER_INVITE = 'member.invite'
+ MEMBER_UPDATE_ROLE = 'member.update_role'
+ MEMBER_REMOVE = 'member.remove'
+ RESOURCE_VIEW = 'resource.view'
+ RESOURCE_MANAGE = 'resource.manage'
+ RUNTIME_OPERATE = 'runtime.operate'
+ PROVIDER_SECRET_MANAGE = 'provider_secret.manage'
+ API_KEY_MANAGE = 'api_key.manage'
+ AUDIT_VIEW = 'audit.view'
+ DATA_EXPORT = 'data.export'
+ BILLING_LINK_MANAGE = 'billing_link.manage'
+
+
+_VIEW_PERMISSIONS = {
+ Permission.WORKSPACE_VIEW,
+ Permission.MEMBER_VIEW,
+ Permission.RESOURCE_VIEW,
+}
+
+_ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
+ {
+ WorkspaceRole.OWNER: frozenset(Permission),
+ WorkspaceRole.ADMIN: frozenset(
+ permission
+ for permission in Permission
+ if permission
+ not in {
+ Permission.WORKSPACE_DELETE,
+ Permission.OWNER_TRANSFER,
+ Permission.BILLING_LINK_MANAGE,
+ }
+ ),
+ WorkspaceRole.DEVELOPER: frozenset(
+ _VIEW_PERMISSIONS
+ | {
+ Permission.RESOURCE_MANAGE,
+ Permission.RUNTIME_OPERATE,
+ Permission.PROVIDER_SECRET_MANAGE,
+ }
+ ),
+ WorkspaceRole.OPERATOR: frozenset(_VIEW_PERMISSIONS | {Permission.RUNTIME_OPERATE}),
+ WorkspaceRole.VIEWER: frozenset(_VIEW_PERMISSIONS),
+ }
+)
+
+
+class AuthorizationError(Exception):
+ """Base class for errors that map to an HTTP authorization response."""
+
+ status_code = 403
+ error_code = 'forbidden'
+
+
+class WorkspaceRequiredError(AuthorizationError):
+ status_code = 400
+ error_code = 'workspace_required'
+
+
+class PermissionDeniedError(AuthorizationError):
+ error_code = 'permission_denied'
+
+ def __init__(self, permission: str) -> None:
+ super().__init__(f'Missing Workspace permission: {permission}')
+ self.permission = permission
+
+
+class EditionLimitError(AuthorizationError):
+ error_code = 'edition_limit'
+
+
+def permissions_for_role(role: str | WorkspaceRole) -> frozenset[str]:
+ """Return the canonical fixed permissions for a Workspace role."""
+
+ try:
+ parsed_role = WorkspaceRole(role)
+ except ValueError:
+ return frozenset()
+ return frozenset(permission.value for permission in _ROLE_PERMISSIONS[parsed_role])
+
+
+def has_permission(ctx: RequestContext, permission: str | Permission) -> bool:
+ """Return whether the context contains one effective permission."""
+
+ permission_value = permission.value if isinstance(permission, Permission) else permission
+ return permission_value in ctx.workspace.permissions
+
+
+def require_permission(ctx: RequestContext, permission: str | Permission) -> None:
+ """Raise a stable authorization error when a permission is missing."""
+
+ permission_value = permission.value if isinstance(permission, Permission) else permission
+ if not has_permission(ctx, permission_value):
+ raise PermissionDeniedError(permission_value)
diff --git a/src/langbot/pkg/api/http/context.py b/src/langbot/pkg/api/http/context.py
new file mode 100644
index 000000000..7880a1e15
--- /dev/null
+++ b/src/langbot/pkg/api/http/context.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import dataclasses
+import enum
+
+
+class PrincipalType(enum.StrEnum):
+ """Kinds of authenticated principals accepted by LangBot."""
+
+ ACCOUNT = 'account'
+ API_KEY = 'api_key'
+ SYSTEM = 'system'
+ PUBLIC_BOT = 'public_bot'
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class PrincipalContext:
+ """Authenticated identity before Workspace authorization is applied."""
+
+ principal_type: PrincipalType
+ account_uuid: str | None = None
+ api_key_uuid: str | None = None
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class WorkspaceContext:
+ """Workspace membership and effective permissions for one request."""
+
+ workspace_uuid: str
+ membership_uuid: str | None
+ role: str | None
+ permissions: frozenset[str]
+ membership_revision: int = 0
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class RequestContext:
+ """Trusted authorization context passed to HTTP services."""
+
+ instance_uuid: str
+ placement_generation: int
+ request_id: str
+ auth_type: str
+ principal: PrincipalContext
+ workspace: WorkspaceContext
+ entitlement_revision: int = 0
+
+ @property
+ def workspace_uuid(self) -> str:
+ """Return the selected Workspace UUID."""
+
+ return self.workspace.workspace_uuid
+
+ @property
+ def account_uuid(self) -> str | None:
+ """Return the Account UUID when the principal is an Account."""
+
+ return self.principal.account_uuid
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class ExecutionContext:
+ """Workspace context propagated to asynchronous and runtime work."""
+
+ instance_uuid: str
+ workspace_uuid: str
+ placement_generation: int
+ bot_uuid: str | None = None
+ pipeline_uuid: str | None = None
+ query_uuid: str | None = None
+ trigger_principal: PrincipalContext | None = None
+ entitlement_revision: int = 0
+
+ @classmethod
+ def from_request(
+ cls,
+ ctx: RequestContext,
+ *,
+ bot_uuid: str | None = None,
+ pipeline_uuid: str | None = None,
+ query_uuid: str | None = None,
+ ) -> ExecutionContext:
+ """Create a runtime context without losing the tenant generation."""
+
+ return cls(
+ instance_uuid=ctx.instance_uuid,
+ workspace_uuid=ctx.workspace_uuid,
+ placement_generation=ctx.placement_generation,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ query_uuid=query_uuid,
+ trigger_principal=ctx.principal,
+ entitlement_revision=ctx.entitlement_revision,
+ )
diff --git a/src/langbot/pkg/api/http/controller/group.py b/src/langbot/pkg/api/http/controller/group.py
index 2ed55187d..1fc09a0b2 100644
--- a/src/langbot/pkg/api/http/controller/group.py
+++ b/src/langbot/pkg/api/http/controller/group.py
@@ -5,9 +5,21 @@ import typing
import enum
import quart
import traceback
+import inspect
+import uuid
from quart.typing import RouteCallable
-from ....core import app
+from ....utils import constants
+from ....utils import bounded_executor
+from ....workspace.collaboration import MembershipPermissionError, WorkspaceCollaborationError
+from ....workspace.errors import WorkspaceNotFoundError
+from ....cloud.entitlements import EntitlementUnavailableError
+from ....core.errors import TaskCapacityError
+from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
+from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
+
+if typing.TYPE_CHECKING:
+ from ....core.app import Application
# Maximum file upload size limit (10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
@@ -33,6 +45,7 @@ class AuthType(enum.Enum):
"""Authentication type"""
NONE = 'none'
+ ACCOUNT_TOKEN = 'account-token'
USER_TOKEN = 'user-token'
API_KEY = 'api-key'
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
@@ -43,11 +56,11 @@ class RouterGroup(abc.ABC):
path: str
- ap: app.Application
+ ap: Application
quart_app: quart.Quart
- def __init__(self, ap: app.Application, quart_app: quart.Quart) -> None:
+ def __init__(self, ap: Application, quart_app: quart.Quart) -> None:
self.ap = ap
self.quart_app = quart_app
@@ -59,16 +72,38 @@ class RouterGroup(abc.ABC):
self,
rule: str,
auth_type: AuthType = AuthType.USER_TOKEN,
+ permission: Permission | str | None = None,
**options: typing.Any,
) -> typing.Callable[[RouteCallable], RouteCallable]: # decorator
"""Register a route"""
+ if auth_type == AuthType.ACCOUNT_TOKEN and permission is not None:
+ raise ValueError('Account-token routes cannot declare Workspace permissions')
+
def decorator(f: RouteCallable) -> RouteCallable:
nonlocal rule
rule = self.path + rule
async def handler_error(*args, **kwargs):
- if auth_type == AuthType.USER_TOKEN:
+ request_context: RequestContext | None = None
+ if auth_type == AuthType.ACCOUNT_TOKEN:
+ authorization = quart.request.headers.get('Authorization', '')
+ if not authorization.startswith('Bearer '):
+ return self.http_status(401, -1, 'No valid user token provided')
+ token = authorization.removeprefix('Bearer ')
+ if not token:
+ return self.http_status(401, -1, 'No valid user token provided')
+
+ try:
+ account, user_email = await self._authenticate_account(token)
+ # Account-token routes deliberately stop before Workspace
+ # selection. They may bootstrap a selector, but cannot
+ # receive RequestContext or enforce Workspace permissions.
+ self._inject_handler_context(f, kwargs, user_email, None, account)
+ except Exception as e:
+ return self._auth_error_response(e)
+
+ elif auth_type == AuthType.USER_TOKEN:
# get token from Authorization header
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
@@ -76,18 +111,15 @@ class RouterGroup(abc.ABC):
return self.http_status(401, -1, 'No valid user token provided')
try:
- user_email = await self.ap.user_service.verify_jwt_token(token)
-
- # check if this account exists
- user = await self.ap.user_service.get_user_by_email(user_email)
- if not user:
- return self.http_status(401, -1, 'User not found')
-
- # check if f accepts user_email parameter
- if 'user_email' in f.__code__.co_varnames:
- kwargs['user_email'] = user_email
+ account, user_email = await self._authenticate_account(token)
+ request_context = await self._resolve_account_context(account, auth_type)
+ if permission is not None:
+ if request_context is None:
+ raise AuthorizationError('Workspace authorization is unavailable')
+ require_permission(request_context, permission)
+ self._inject_handler_context(f, kwargs, user_email, request_context)
except Exception as e:
- return self.http_status(401, -1, str(e))
+ return self._auth_error_response(e)
elif auth_type == AuthType.API_KEY:
# get API key from Authorization header or X-API-Key header
@@ -101,11 +133,12 @@ class RouterGroup(abc.ABC):
return self.http_status(401, -1, 'No valid API key provided')
try:
- is_valid = await self.ap.apikey_service.verify_api_key(api_key)
- if not is_valid:
- return self.http_status(401, -1, 'Invalid API key')
+ request_context = await self._authenticate_api_key(api_key, auth_type)
+ if permission is not None:
+ require_permission(request_context, permission)
+ self._inject_handler_context(f, kwargs, None, request_context)
except Exception as e:
- return self.http_status(401, -1, str(e))
+ return self._auth_error_response(e)
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
# Try API key first (check X-API-Key header)
@@ -114,11 +147,12 @@ class RouterGroup(abc.ABC):
if api_key:
# API key authentication
try:
- is_valid = await self.ap.apikey_service.verify_api_key(api_key)
- if not is_valid:
- return self.http_status(401, -1, 'Invalid API key')
+ request_context = await self._authenticate_api_key(api_key, auth_type)
+ if permission is not None:
+ require_permission(request_context, permission)
+ self._inject_handler_context(f, kwargs, None, request_context)
except Exception as e:
- return self.http_status(401, -1, str(e))
+ return self._auth_error_response(e)
else:
# Try user token authentication (Authorization header)
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
@@ -129,35 +163,89 @@ class RouterGroup(abc.ABC):
)
try:
- user_email = await self.ap.user_service.verify_jwt_token(token)
-
- # check if this account exists
- user = await self.ap.user_service.get_user_by_email(user_email)
- if not user:
- return self.http_status(401, -1, 'User not found')
-
- # check if f accepts user_email parameter
- if 'user_email' in f.__code__.co_varnames:
- kwargs['user_email'] = user_email
+ account, user_email = await self._authenticate_account(token)
+ request_context = await self._resolve_account_context(account, auth_type)
+ if permission is not None:
+ if request_context is None:
+ raise AuthorizationError('Workspace authorization is unavailable')
+ require_permission(request_context, permission)
+ self._inject_handler_context(f, kwargs, user_email, request_context)
+ except (AuthorizationError, WorkspaceNotFoundError, MembershipPermissionError) as e:
+ # Authentication succeeded and authorization was
+ # evaluated. Do not reinterpret a denied user token
+ # as an API key, which would mask the stable 403/404.
+ return self._auth_error_response(e)
except Exception:
# If user token fails, maybe it's an API key in Authorization header
try:
- is_valid = await self.ap.apikey_service.verify_api_key(token)
- if not is_valid:
- return self.http_status(401, -1, 'Invalid authentication credentials')
+ request_context = await self._authenticate_api_key(token, auth_type)
+ if permission is not None:
+ require_permission(request_context, permission)
+ self._inject_handler_context(f, kwargs, None, request_context)
except Exception as e:
- return self.http_status(401, -1, str(e))
+ return self._auth_error_response(e)
try:
+ if request_context is not None:
+ with bounded_executor.blocking_work_scope(request_context.workspace_uuid):
+ persistence_mgr = getattr(
+ self.ap,
+ 'persistence_mgr',
+ None,
+ )
+ tenant_scope_descriptor = getattr(
+ type(persistence_mgr),
+ 'tenant_scope',
+ None,
+ )
+ if callable(tenant_scope_descriptor):
+ # Authorization discovery is complete. Carry
+ # the trusted Workspace identity across the
+ # handler, but do not reserve a database
+ # connection while it waits on providers,
+ # runtimes, uploads, or streamed clients.
+ # Services that need atomic writes open a UoW.
+ async with persistence_mgr.tenant_scope(request_context.workspace_uuid):
+ return await f(*args, **kwargs)
+ return await f(*args, **kwargs)
return await f(*args, **kwargs)
except Exception as e: # 自动 500
- traceback.print_exc()
- # return self.http_status(500, -2, str(e))
- return self.http_status(500, -2, str(e))
+ if isinstance(e, AuthorizationError):
+ return self.http_status(e.status_code, e.error_code, str(e))
+ if isinstance(e, WorkspaceNotFoundError):
+ return self.http_status(404, 'resource_not_found', 'Resource not found')
+ if isinstance(e, MembershipPermissionError):
+ return self.http_status(403, e.code, str(e))
+ if isinstance(e, WorkspaceCollaborationError):
+ return self.http_status(400, e.code, str(e))
+ if isinstance(e, TaskCapacityError):
+ return self.http_status(429, 'task_capacity_exceeded', str(e))
+ if isinstance(
+ e,
+ bounded_executor.BlockingWorkCapacityError,
+ ):
+ return self.http_status(
+ 429,
+ 'blocking_work_capacity_exceeded',
+ str(e),
+ )
+ request_id = self.request_id()
+ logger = getattr(self.ap, 'logger', self.quart_app.logger)
+ logger.error(
+ f'Unhandled HTTP error request_id={request_id} '
+ f'method={quart.request.method} path={quart.request.path}\n{traceback.format_exc()}'
+ )
+ return self.internal_error_response(request_id)
new_f = handler_error
- new_f.__name__ = (self.name + rule).replace('/', '__')
+ # Quart/Flask requires a unique endpoint name even when the same URL
+ # intentionally has separate handlers for different HTTP methods.
+ # Include the method set so CRUD routes can declare distinct
+ # permissions without colliding during application startup.
+ methods = options.get('methods') or ['GET']
+ method_suffix = '__'.join(sorted(str(method).upper() for method in methods))
+ new_f.__name__ = (self.name + rule + '__' + method_suffix).replace('/', '__')
new_f.__doc__ = f.__doc__
self.quart_app.route(rule, **options)(new_f)
@@ -165,6 +253,192 @@ class RouterGroup(abc.ABC):
return decorator
+ async def _authenticate_account(self, token: str) -> tuple[typing.Any, str]:
+ account: typing.Any = None
+ resolver = getattr(self.ap.user_service, 'get_authenticated_account', None)
+ if callable(resolver):
+ resolved = resolver(token)
+ if inspect.isawaitable(resolved):
+ account = await resolved
+
+ if isinstance(account, str) or account is None:
+ user_email = account or await self.ap.user_service.verify_jwt_token(token)
+ account = await self.ap.user_service.get_user_by_email(user_email)
+ if account is None:
+ raise ValueError('User not found')
+ return account, account.user
+
+ async def _resolve_account_context(
+ self,
+ account: typing.Any,
+ auth_type: AuthType,
+ ) -> RequestContext | None:
+ collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
+ account_uuid = getattr(account, 'uuid', None)
+ # Compatibility for isolated controller tests that do not wire the tenancy kernel.
+ if collaboration_service is None or not isinstance(account_uuid, str):
+ return None
+
+ requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
+ access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
+ entitlement_revision = await self._resolve_entitlement_revision(
+ access.execution.instance_uuid,
+ access.workspace.uuid,
+ )
+ request_context = RequestContext(
+ instance_uuid=access.execution.instance_uuid,
+ placement_generation=access.execution.placement_generation,
+ request_id=self.request_id(),
+ auth_type=auth_type.value,
+ principal=PrincipalContext(
+ principal_type=PrincipalType.ACCOUNT,
+ account_uuid=account_uuid,
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid=access.workspace.uuid,
+ membership_uuid=access.membership.uuid,
+ role=access.membership.role,
+ permissions=permissions_for_role(access.membership.role),
+ membership_revision=access.membership.projection_revision,
+ ),
+ entitlement_revision=entitlement_revision,
+ )
+ quart.g.request_context = request_context
+ quart.g.workspace_membership = access.membership
+ return request_context
+
+ async def _authenticate_api_key(self, api_key: str, auth_type: AuthType) -> RequestContext:
+ authenticator = getattr(self.ap.apikey_service, 'authenticate_api_key', None)
+ if callable(authenticator):
+ authenticated = authenticator(api_key)
+ if inspect.isawaitable(authenticated):
+ identity = await authenticated
+ if identity is not None:
+ entitlement_revision = await self._resolve_entitlement_revision(
+ identity.instance_uuid,
+ identity.workspace_uuid,
+ )
+ request_context = RequestContext(
+ instance_uuid=identity.instance_uuid,
+ placement_generation=identity.placement_generation,
+ request_id=self.request_id(),
+ auth_type=auth_type.value,
+ principal=PrincipalContext(
+ principal_type=PrincipalType.API_KEY,
+ api_key_uuid=identity.api_key_uuid,
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid=identity.workspace_uuid,
+ membership_uuid=None,
+ role=None,
+ permissions=identity.permissions,
+ ),
+ entitlement_revision=entitlement_revision,
+ )
+ quart.g.request_context = request_context
+ return request_context
+
+ if not await self.ap.apikey_service.verify_api_key(api_key):
+ raise ValueError('Invalid API key')
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ if workspace_service is None:
+ raise ValueError('API key Workspace binding is unavailable')
+ binding = await workspace_service.get_local_execution_binding()
+ request_context = RequestContext(
+ instance_uuid=binding.instance_uuid or constants.instance_id,
+ placement_generation=binding.placement_generation,
+ request_id=self.request_id(),
+ auth_type=auth_type.value,
+ principal=PrincipalContext(
+ principal_type=PrincipalType.API_KEY,
+ api_key_uuid='legacy-oss-api-key',
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid=binding.workspace_uuid,
+ membership_uuid=None,
+ role=None,
+ permissions=frozenset(item.value for item in Permission),
+ ),
+ )
+ quart.g.request_context = request_context
+ return request_context
+
+ async def _resolve_entitlement_revision(self, instance_uuid: str, workspace_uuid: str) -> int:
+ deployment = getattr(self.ap, 'deployment', None)
+ if deployment is None or not getattr(deployment, 'multi_workspace_enabled', False):
+ return 0
+ resolver = getattr(self.ap, 'entitlement_resolver', None)
+ if resolver is None:
+ raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
+ if instance_uuid != resolver.instance_uuid:
+ raise EntitlementUnavailableError('Workspace entitlement targets another LangBot instance')
+ snapshot = await resolver.resolve(workspace_uuid)
+ return snapshot.entitlement_revision
+
+ @staticmethod
+ def _inject_handler_context(
+ handler: RouteCallable,
+ kwargs: dict[str, typing.Any],
+ user_email: str | None,
+ request_context: RequestContext | None,
+ account: typing.Any = None,
+ ) -> None:
+ parameters = inspect.signature(handler).parameters
+ if user_email is not None and 'user_email' in parameters:
+ kwargs['user_email'] = user_email
+ if account is not None and 'account' in parameters:
+ kwargs['account'] = account
+ if request_context is not None:
+ if 'request_context' in parameters:
+ kwargs['request_context'] = request_context
+ elif 'ctx' in parameters:
+ kwargs['ctx'] = request_context
+
+ def _auth_error_response(self, error: Exception) -> typing.Any:
+ if isinstance(error, AuthorizationError):
+ return self.http_status(error.status_code, error.error_code, str(error))
+ if isinstance(error, WorkspaceNotFoundError):
+ return self.http_status(404, 'resource_not_found', 'Resource not found')
+ if isinstance(error, MembershipPermissionError):
+ return self.http_status(403, error.code, str(error))
+ if isinstance(error, EntitlementUnavailableError):
+ return self.http_status(403, 'entitlement_unavailable', str(error))
+ request_id = self.request_id()
+ logger = getattr(self.ap, 'logger', self.quart_app.logger)
+ logger.warning(f'Authentication failed request_id={request_id} error_type={type(error).__name__}: {error}')
+ return self.http_status(
+ 401,
+ 'invalid_authentication',
+ 'Invalid authentication credentials',
+ )
+
+ def request_id(self) -> str:
+ """Return one stable request ID for authentication, logs, and errors."""
+
+ request_context = getattr(quart.g, 'request_context', None)
+ request_id = getattr(request_context, 'request_id', None) or getattr(quart.g, 'request_id', None)
+ if not request_id:
+ candidate = str(quart.request.headers.get('X-Request-Id') or '').strip()
+ if not candidate or len(candidate) > 128 or any(ord(char) < 32 for char in candidate):
+ candidate = str(uuid.uuid4())
+ request_id = candidate
+ quart.g.request_id = request_id
+ return str(request_id)
+
+ def internal_error_response(self, request_id: str | None = None) -> typing.Tuple[quart.Response, int]:
+ """Return a stable 500 response without exposing the underlying exception."""
+
+ resolved_request_id = request_id or self.request_id()
+ response = quart.jsonify(
+ {
+ 'code': 'internal_error',
+ 'msg': 'Internal server error',
+ 'request_id': resolved_request_id,
+ }
+ )
+ response.headers['X-Request-Id'] = resolved_request_id
+ return response, 500
+
def success(self, data: typing.Any = None) -> quart.Response:
"""Return a 200 response"""
return quart.jsonify(
@@ -175,7 +449,7 @@ class RouterGroup(abc.ABC):
}
)
- def fail(self, code: int, msg: str) -> quart.Response:
+ def fail(self, code: int | str, msg: str) -> quart.Response:
"""Return an error response"""
return quart.jsonify(
@@ -185,6 +459,6 @@ class RouterGroup(abc.ABC):
}
)
- def http_status(self, status: int, code: int, msg: str) -> typing.Tuple[quart.Response, int]:
+ def http_status(self, status: int, code: int | str, msg: str) -> typing.Tuple[quart.Response, int]:
"""返回一个指定状态码的响应"""
return (self.fail(code, msg), status)
diff --git a/src/langbot/pkg/api/http/controller/groups/apikeys.py b/src/langbot/pkg/api/http/controller/groups/apikeys.py
index f53728bf0..6c8b4a656 100644
--- a/src/langbot/pkg/api/http/controller/groups/apikeys.py
+++ b/src/langbot/pkg/api/http/controller/groups/apikeys.py
@@ -1,43 +1,66 @@
+from __future__ import annotations
+
+import datetime
+
import quart
+from ...authz import Permission
+from ...context import RequestContext
from .. import group
@group.group_class('apikeys', '/api/v1/apikeys')
class ApiKeysRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'])
- async def _() -> str:
- if quart.request.method == 'GET':
- keys = await self.ap.apikey_service.get_api_keys()
- return self.success(data={'keys': keys})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- name = json_data.get('name', '')
- description = json_data.get('description', '')
+ @self.route('', methods=['GET'], permission=Permission.API_KEY_MANAGE)
+ async def _(request_context: RequestContext) -> str:
+ keys = await self.ap.apikey_service.get_api_keys(request_context)
+ return self.success(data={'keys': keys})
- if not name:
- return self.http_status(400, -1, 'Name is required')
+ @self.route('', methods=['POST'], permission=Permission.API_KEY_MANAGE)
+ async def _(request_context: RequestContext) -> str:
+ json_data = await quart.request.json
+ expires_at = json_data.get('expires_at')
+ parsed_expiry = None
+ if expires_at:
+ try:
+ parsed_expiry = datetime.datetime.fromisoformat(str(expires_at).replace('Z', '+00:00'))
+ except ValueError:
+ return self.http_status(400, 'invalid_expiry', 'Invalid API key expiry')
+ try:
+ key = await self.ap.apikey_service.create_api_key(
+ request_context,
+ json_data.get('name', ''),
+ json_data.get('description', ''),
+ scopes=json_data.get('scopes'),
+ expires_at=parsed_expiry,
+ )
+ except ValueError as error:
+ return self.http_status(400, 'invalid_api_key', str(error))
+ return self.success(data={'key': key})
- key = await self.ap.apikey_service.create_api_key(name, description)
- return self.success(data={'key': key})
+ @self.route('/', methods=['GET'], permission=Permission.API_KEY_MANAGE)
+ async def _(key_id: int, request_context: RequestContext) -> str:
+ key = await self.ap.apikey_service.get_api_key(request_context, key_id)
+ if key is None:
+ return self.http_status(404, 'resource_not_found', 'API key not found')
+ return self.success(data={'key': key})
- @self.route('/', methods=['GET', 'PUT', 'DELETE'])
- async def _(key_id: int) -> str:
- if quart.request.method == 'GET':
- key = await self.ap.apikey_service.get_api_key(key_id)
- if key is None:
- return self.http_status(404, -1, 'API key not found')
- return self.success(data={'key': key})
+ @self.route('/', methods=['PUT'], permission=Permission.API_KEY_MANAGE)
+ async def _(key_id: int, request_context: RequestContext) -> str:
+ json_data = await quart.request.json
+ try:
+ await self.ap.apikey_service.update_api_key(
+ request_context,
+ key_id,
+ json_data.get('name'),
+ json_data.get('description'),
+ )
+ except ValueError as error:
+ return self.http_status(400, 'invalid_api_key', str(error))
+ return self.success()
- elif quart.request.method == 'PUT':
- json_data = await quart.request.json
- name = json_data.get('name')
- description = json_data.get('description')
-
- await self.ap.apikey_service.update_api_key(key_id, name, description)
- return self.success()
-
- elif quart.request.method == 'DELETE':
- await self.ap.apikey_service.delete_api_key(key_id)
- return self.success()
+ @self.route('/', methods=['DELETE'], permission=Permission.API_KEY_MANAGE)
+ async def _(key_id: int, request_context: RequestContext) -> str:
+ await self.ap.apikey_service.delete_api_key(request_context, key_id)
+ return self.success()
diff --git a/src/langbot/pkg/api/http/controller/groups/box.py b/src/langbot/pkg/api/http/controller/groups/box.py
index d8c961e7a..d63e9d7b5 100644
--- a/src/langbot/pkg/api/http/controller/groups/box.py
+++ b/src/langbot/pkg/api/http/controller/groups/box.py
@@ -1,7 +1,11 @@
from __future__ import annotations
from langbot.pkg.utils import constants
+from langbot_plugin.box.errors import BoxAdmissionError
+from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
+from ...authz import Permission
+from ...context import RequestContext
from .. import group
from .box_visibility import should_hide_box_runtime_status
@@ -9,18 +13,56 @@ from .box_visibility import should_hide_box_runtime_status
@group.group_class('box', '/api/v1/box')
class BoxRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- status = await self.ap.box_service.get_status()
+ @self.route(
+ '/status',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ try:
+ status = await self.ap.box_service.get_status(request_context)
+ except (BoxAdmissionError, EntitlementUnavailableError) as exc:
+ return self.http_status(403, 'managed_sandbox_unavailable', str(exc))
status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
return self.success(data=status)
- @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- sessions = await self.ap.box_service.get_sessions()
+ @self.route(
+ '/runtime-status',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ del request_context
+ status = await self.ap.box_service.get_backend_status()
+ status['hidden'] = should_hide_box_runtime_status(constants.edition, status.get('enabled'))
+ return self.success(data=status)
+
+ @self.route(
+ '/sessions',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.AUDIT_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ try:
+ sessions = await self.ap.box_service.get_sessions(request_context)
+ except (BoxAdmissionError, EntitlementUnavailableError) as exc:
+ return self.http_status(403, 'managed_sandbox_unavailable', str(exc))
return self.success(data=sessions)
- @self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- errors = self.ap.box_service.get_recent_errors()
+ @self.route(
+ '/errors',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.AUDIT_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ try:
+ if getattr(self.ap.box_service, 'managed_admission_required', False):
+ await self.ap.box_service.require_workspace_sandbox(request_context)
+ except (BoxAdmissionError, EntitlementUnavailableError) as exc:
+ return self.http_status(403, 'managed_sandbox_unavailable', str(exc))
+ errors = self.ap.box_service.get_recent_errors(request_context)
return self.success(data=errors)
diff --git a/src/langbot/pkg/api/http/controller/groups/extensions.py b/src/langbot/pkg/api/http/controller/groups/extensions.py
index ac8463c90..040c04379 100644
--- a/src/langbot/pkg/api/http/controller/groups/extensions.py
+++ b/src/langbot/pkg/api/http/controller/groups/extensions.py
@@ -3,6 +3,9 @@ from __future__ import annotations
import asyncio
import quart
+from ...authz import Permission
+from ...context import RequestContext
+from ...service.secrets import redact_secrets
from .. import group
@@ -11,12 +14,29 @@ class ExtensionsRouterGroup(group.RouterGroup):
"""Unified API for installed extensions (plugins, MCP servers, skills)."""
async def initialize(self) -> None:
- @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> quart.Response:
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> quart.Response:
+ if self.ap.plugin_connector.is_enable_plugin:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
+
+ async def read_in_task_scope(operation):
+ tenant_scope = getattr(getattr(self.ap, 'persistence_mgr', None), 'tenant_scope', None)
+ if callable(tenant_scope):
+ async with tenant_scope(request_context.workspace_uuid):
+ return await operation()
+ return await operation()
+
plugins, mcp_servers, skills = await asyncio.gather(
- self.ap.plugin_connector.list_plugins(),
- self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True),
- self.ap.skill_service.list_skills(),
+ read_in_task_scope(self.ap.plugin_connector.list_plugins),
+ read_in_task_scope(
+ lambda: self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True)
+ ),
+ read_in_task_scope(lambda: self.ap.skill_service.list_skills(request_context)),
return_exceptions=True,
)
@@ -39,7 +59,7 @@ class ExtensionsRouterGroup(group.RouterGroup):
extensions: list[dict] = []
if isinstance(plugins, list):
for plugin in plugins:
- extensions.append({'type': 'plugin', 'plugin': plugin})
+ extensions.append({'type': 'plugin', 'plugin': redact_secrets(plugin)})
if isinstance(mcp_servers, list):
for server in mcp_servers:
extensions.append({'type': 'mcp', 'server': server})
diff --git a/src/langbot/pkg/api/http/controller/groups/files.py b/src/langbot/pkg/api/http/controller/groups/files.py
index 439cc57e3..026f6c194 100644
--- a/src/langbot/pkg/api/http/controller/groups/files.py
+++ b/src/langbot/pkg/api/http/controller/groups/files.py
@@ -7,29 +7,53 @@ import asyncio
import quart.datastructures
+from ...authz import Permission
+from ...context import RequestContext
from .. import group
+def _storage_owner(context: RequestContext) -> str:
+ if context.principal.account_uuid:
+ return f'account:{context.principal.account_uuid}'
+ if context.principal.api_key_uuid:
+ return f'api-key:{context.principal.api_key_uuid}'
+ return f'principal:{context.principal.principal_type.value}'
+
+
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('/image/', methods=['GET'], auth_type=group.AuthType.NONE)
- async def _(image_key: str) -> quart.Response:
- if '..' in image_key or '\\' in image_key:
+ @self.route(
+ '/image/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(image_key: str, request_context: RequestContext) -> quart.Response:
+ image_bytes = await self.ap.storage_mgr.resolve_public_object(
+ image_key,
+ expected_owner_type='upload_image',
+ )
+ if image_bytes is None:
+ image_bytes = await self.ap.storage_mgr.resolve_public_object(
+ image_key,
+ expected_owner_type='bot_log',
+ )
+ if image_bytes is None:
return quart.Response(status=404)
-
- if not await self.ap.storage_mgr.storage_provider.exists(image_key):
- return quart.Response(status=404)
-
- image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key)
mime_type = mimetypes.guess_type(image_key)[0]
if mime_type is None:
mime_type = 'image/jpeg'
return quart.Response(image_bytes, mimetype=mime_type)
- @self.route('/images', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def upload_image() -> quart.Response:
+ @self.route(
+ '/images',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def upload_image(request_context: RequestContext) -> quart.Response:
request = quart.request
# Check file size limit before reading the file
@@ -66,18 +90,29 @@ class FilesRouterGroup(group.RouterGroup):
if '/' in file_name or '\\' in file_name:
return self.fail(400, 'File name contains invalid characters')
- file_key = file_name + '_' + str(uuid.uuid4())[:8] + '.' + extension
+ logical_key = f'{uuid.uuid4()}.{extension}'
# save file to storage
- await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
+ file_key = await self.ap.storage_mgr.save_scoped(
+ request_context,
+ owner_type='upload_image',
+ owner=_storage_owner(request_context),
+ key=logical_key,
+ value=file_bytes,
+ )
return self.success(
data={
'file_key': file_key,
}
)
- @self.route('/documents', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def upload_document() -> quart.Response:
+ @self.route(
+ '/documents',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def upload_document(request_context: RequestContext) -> quart.Response:
request = quart.request
# Check file size limit before reading the file
@@ -110,12 +145,18 @@ class FilesRouterGroup(group.RouterGroup):
if '/' in file_name or '\\' in file_name:
return self.fail(400, 'File name contains invalid characters')
- file_key = file_name + '_' + str(uuid.uuid4())[:8]
+ logical_key = str(uuid.uuid4())
if extension:
- file_key += '.' + extension
+ logical_key += '.' + extension
# save file to storage
- await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
+ file_key = await self.ap.storage_mgr.save_scoped(
+ request_context,
+ owner_type='upload_document',
+ owner=_storage_owner(request_context),
+ key=logical_key,
+ value=file_bytes,
+ )
return self.success(
data={
'file_id': file_key,
diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py
index 4f9bb5b4f..87dc2ac30 100644
--- a/src/langbot/pkg/api/http/controller/groups/knowledge/base.py
+++ b/src/langbot/pkg/api/http/controller/groups/knowledge/base.py
@@ -1,100 +1,146 @@
import quart
+
+from ....authz import Permission, has_permission
+from ....context import RequestContext
from ... import group
@group.group_class('knowledge_base', '/api/v1/knowledge/bases')
class KnowledgeBaseRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['POST', 'GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def handle_knowledge_bases() -> quart.Response:
- if quart.request.method == 'GET':
- knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases()
- return self.success(data={'bases': knowledge_bases})
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def handle_knowledge_bases(request_context: RequestContext) -> quart.Response:
+ knowledge_bases = await self.ap.knowledge_service.get_knowledge_bases(
+ request_context,
+ include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
+ )
+ return self.success(data={'bases': knowledge_bases})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- try:
- knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(json_data)
- except ValueError as e:
- return self.http_status(400, -1, str(e))
- return self.success(data={'uuid': knowledge_base_uuid})
-
- return self.http_status(405, -1, 'Method not allowed')
+ @self.route(
+ '',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def create_knowledge_base(request_context: RequestContext) -> quart.Response:
+ json_data = await quart.request.json
+ try:
+ knowledge_base_uuid = await self.ap.knowledge_service.create_knowledge_base(
+ request_context,
+ json_data,
+ )
+ except ValueError as e:
+ return self.http_status(400, -1, str(e))
+ return self.success(data={'uuid': knowledge_base_uuid})
@self.route(
'/',
- methods=['GET', 'DELETE', 'PUT'],
+ methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def handle_specific_knowledge_base(knowledge_base_uuid: str) -> quart.Response:
- if quart.request.method == 'GET':
- knowledge_base = await self.ap.knowledge_service.get_knowledge_base(knowledge_base_uuid)
+ async def get_specific_knowledge_base(
+ knowledge_base_uuid: str,
+ request_context: RequestContext,
+ ) -> quart.Response:
+ knowledge_base = await self.ap.knowledge_service.get_knowledge_base(
+ request_context,
+ knowledge_base_uuid,
+ include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
+ )
+ if knowledge_base is None:
+ return self.http_status(404, 'resource_not_found', 'knowledge base not found')
+ return self.success(data={'base': knowledge_base})
- if knowledge_base is None:
- return self.http_status(404, -1, 'knowledge base not found')
-
- return self.success(
- data={
- 'base': knowledge_base,
- }
- )
-
- elif quart.request.method == 'PUT':
+ @self.route(
+ '/',
+ methods=['DELETE', 'PUT'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def mutate_specific_knowledge_base(
+ knowledge_base_uuid: str,
+ request_context: RequestContext,
+ ) -> quart.Response:
+ if quart.request.method == 'PUT':
json_data = await quart.request.json
- await self.ap.knowledge_service.update_knowledge_base(knowledge_base_uuid, json_data)
+ await self.ap.knowledge_service.update_knowledge_base(
+ request_context,
+ knowledge_base_uuid,
+ json_data,
+ )
return self.success(data={'uuid': knowledge_base_uuid})
-
- elif quart.request.method == 'DELETE':
- await self.ap.knowledge_service.delete_knowledge_base(knowledge_base_uuid)
- return self.success({})
+ await self.ap.knowledge_service.delete_knowledge_base(request_context, knowledge_base_uuid)
+ return self.success({})
@self.route(
'//files',
- methods=['GET', 'POST'],
+ methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def get_knowledge_base_files(knowledge_base_uuid: str) -> str:
- if quart.request.method == 'GET':
- files = await self.ap.knowledge_service.get_files_by_knowledge_base(knowledge_base_uuid)
- return self.success(
- data={
- 'files': files,
- }
- )
+ async def get_knowledge_base_files(
+ knowledge_base_uuid: str,
+ request_context: RequestContext,
+ ) -> str:
+ files = await self.ap.knowledge_service.get_files_by_knowledge_base(
+ request_context,
+ knowledge_base_uuid,
+ )
+ return self.success(data={'files': files})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- file_id = json_data.get('file_id')
- if not file_id:
- return self.http_status(400, -1, 'File ID is required')
-
- parser_plugin_id = json_data.get('parser_plugin_id')
-
- # 调用服务层方法将文件与知识库关联
- task_id = await self.ap.knowledge_service.store_file(
- knowledge_base_uuid, file_id, parser_plugin_id=parser_plugin_id
- )
- return self.success(
- {
- 'task_id': task_id,
- }
- )
+ @self.route(
+ '//files',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def add_knowledge_base_file(
+ knowledge_base_uuid: str,
+ request_context: RequestContext,
+ ) -> str:
+ json_data = await quart.request.json
+ file_id = json_data.get('file_id')
+ if not file_id:
+ return self.http_status(400, -1, 'File ID is required')
+ parser_plugin_id = json_data.get('parser_plugin_id')
+ task_id = await self.ap.knowledge_service.store_file(
+ request_context,
+ knowledge_base_uuid,
+ file_id,
+ parser_plugin_id=parser_plugin_id,
+ )
+ return self.success({'task_id': task_id})
@self.route(
'//files/',
methods=['DELETE'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
)
- async def delete_specific_file_in_kb(file_id: str, knowledge_base_uuid: str) -> str:
- await self.ap.knowledge_service.delete_file(knowledge_base_uuid, file_id)
+ async def delete_specific_file_in_kb(
+ file_id: str,
+ knowledge_base_uuid: str,
+ request_context: RequestContext,
+ ) -> str:
+ await self.ap.knowledge_service.delete_file(request_context, knowledge_base_uuid, file_id)
return self.success({})
@self.route(
'//retrieve',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def retrieve_knowledge_base(knowledge_base_uuid: str) -> str:
+ async def retrieve_knowledge_base(
+ knowledge_base_uuid: str,
+ request_context: RequestContext,
+ ) -> str:
json_data = await quart.request.json
query = json_data.get('query')
@@ -104,6 +150,9 @@ class KnowledgeBaseRouterGroup(group.RouterGroup):
# Extract retrieval_settings to allow dynamic control over Knowledge Engine behavior (e.g. top_k, filters)
retrieval_settings = json_data.get('retrieval_settings', {})
results = await self.ap.knowledge_service.retrieve_knowledge_base(
- knowledge_base_uuid, query, retrieval_settings
+ request_context,
+ knowledge_base_uuid,
+ query,
+ retrieval_settings,
)
return self.success(data={'results': results})
diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py b/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py
index 28f0710e8..02d047f15 100644
--- a/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py
+++ b/src/langbot/pkg/api/http/controller/groups/knowledge/engines.py
@@ -1,25 +1,39 @@
import quart
from urllib.parse import unquote
+
+from ....authz import Permission
+from ....context import RequestContext
from ... import group
@group.group_class('knowledge_engines', '/api/v1/knowledge/engines')
class KnowledgeEnginesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def list_knowledge_engines() -> quart.Response:
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def list_knowledge_engines(request_context: RequestContext) -> quart.Response:
"""List all available Knowledge Engines from plugins.
Returns a list of Knowledge Engines with their capabilities and configuration schemas.
This is used by the frontend to render the knowledge base creation wizard.
"""
- engines = await self.ap.knowledge_service.list_knowledge_engines()
+ engines = await self.ap.knowledge_service.list_knowledge_engines(request_context)
return self.success(data={'engines': engines})
@self.route(
- '//creation-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
+ '//creation-schema',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def get_engine_creation_schema(plugin_id: str) -> quart.Response:
+ async def get_engine_creation_schema(
+ plugin_id: str,
+ request_context: RequestContext,
+ ) -> quart.Response:
"""Get creation settings schema for a specific Knowledge Engine.
plugin_id is in 'author/name' format, captured via converter.
@@ -27,13 +41,19 @@ class KnowledgeEnginesRouterGroup(group.RouterGroup):
plugin_id = unquote(plugin_id)
if '/' not in plugin_id:
return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.')
- schema = await self.ap.knowledge_service.get_engine_creation_schema(plugin_id)
+ schema = await self.ap.knowledge_service.get_engine_creation_schema(request_context, plugin_id)
return self.success(data={'schema': schema})
@self.route(
- '//retrieval-schema', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
+ '//retrieval-schema',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def get_engine_retrieval_schema(plugin_id: str) -> quart.Response:
+ async def get_engine_retrieval_schema(
+ plugin_id: str,
+ request_context: RequestContext,
+ ) -> quart.Response:
"""Get retrieval settings schema for a specific Knowledge Engine.
plugin_id is in 'author/name' format, captured via converter.
@@ -41,5 +61,5 @@ class KnowledgeEnginesRouterGroup(group.RouterGroup):
plugin_id = unquote(plugin_id)
if '/' not in plugin_id:
return self.http_status(400, -1, 'Invalid plugin_id format. Expected author/name.')
- schema = await self.ap.knowledge_service.get_engine_retrieval_schema(plugin_id)
+ schema = await self.ap.knowledge_service.get_engine_retrieval_schema(request_context, plugin_id)
return self.success(data={'schema': schema})
diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/migration.py b/src/langbot/pkg/api/http/controller/groups/knowledge/migration.py
index 2db835d89..0006d37e7 100644
--- a/src/langbot/pkg/api/http/controller/groups/knowledge/migration.py
+++ b/src/langbot/pkg/api/http/controller/groups/knowledge/migration.py
@@ -6,8 +6,12 @@ import quart
import sqlalchemy
from ... import group
+from ....authz import Permission
+from ....context import ExecutionContext, RequestContext
from ......core import taskmgr
from ......entity.persistence import metadata as persistence_metadata
+from ......workspace.errors import WorkspaceError, WorkspaceNotFoundError
+from ......utils import httpclient
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
LANGRAG_PLUGIN_AUTHOR = 'langbot-team'
@@ -31,24 +35,100 @@ EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
'langbot-team/FastGPTConnector': None, # all fields -> creation_settings
}
+_INFORMATION_SCHEMA_TABLES = sqlalchemy.table(
+ 'tables',
+ sqlalchemy.column('table_schema'),
+ sqlalchemy.column('table_name'),
+ schema='information_schema',
+)
+_SQLITE_MASTER = sqlalchemy.table(
+ 'sqlite_master',
+ sqlalchemy.column('type'),
+ sqlalchemy.column('name'),
+)
+_LEGACY_KNOWLEDGE_BASE_BACKUP = sqlalchemy.table(
+ 'knowledge_bases_backup',
+ sqlalchemy.column('uuid'),
+ sqlalchemy.column('name'),
+ sqlalchemy.column('description'),
+ sqlalchemy.column('emoji'),
+ sqlalchemy.column('embedding_model_uuid'),
+ sqlalchemy.column('top_k'),
+ sqlalchemy.column('created_at'),
+ sqlalchemy.column('updated_at'),
+)
+_LEGACY_EXTERNAL_KNOWLEDGE_BASE = sqlalchemy.table(
+ 'external_knowledge_bases',
+ sqlalchemy.column('uuid'),
+ sqlalchemy.column('name'),
+ sqlalchemy.column('description'),
+ sqlalchemy.column('emoji'),
+ sqlalchemy.column('plugin_author'),
+ sqlalchemy.column('plugin_name'),
+ sqlalchemy.column('retriever_config'),
+ sqlalchemy.column('created_at'),
+)
+_CURRENT_KNOWLEDGE_BASE = sqlalchemy.table(
+ 'knowledge_bases',
+ sqlalchemy.column('uuid'),
+ sqlalchemy.column('workspace_uuid'),
+ sqlalchemy.column('name'),
+ sqlalchemy.column('description'),
+ sqlalchemy.column('emoji'),
+ sqlalchemy.column('created_at'),
+ sqlalchemy.column('updated_at'),
+ sqlalchemy.column('knowledge_engine_plugin_id'),
+ sqlalchemy.column('collection_id'),
+ sqlalchemy.column('creation_settings'),
+ sqlalchemy.column('retrieval_settings'),
+)
+
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
class KnowledgeMigrationRouterGroup(group.RouterGroup):
- async def _get_migration_flag(self) -> bool:
+ async def _require_local_migration_context(
+ self,
+ execution_context: ExecutionContext,
+ ) -> ExecutionContext:
+ """Fence legacy-table migration to the OSS singleton Workspace.
+
+ The backup tables predate Workspace scoping and are deliberately
+ instance-global. A cloud projection must therefore never be allowed
+ to inspect or restore them, even when it has a valid execution lease.
+ """
+ try:
+ binding = await self.ap.workspace_service.get_local_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ except WorkspaceNotFoundError:
+ raise
+ except WorkspaceError as exc:
+ raise WorkspaceNotFoundError('RAG migration is unavailable') from exc
+
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceNotFoundError('RAG migration is unavailable')
+ return ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ )
+
+ async def _get_migration_flag(self, execution_context: ExecutionContext) -> bool:
"""Check if rag_plugin_migration_needed flag is set."""
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_metadata.Metadata).where(
- persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'
- )
+ sqlalchemy.select(persistence_metadata.WorkspaceMetadata.value)
+ .where(persistence_metadata.WorkspaceMetadata.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_metadata.WorkspaceMetadata.key == 'rag_plugin_migration_needed')
)
- row = result.first()
- return row is not None and row.value == 'true'
+ return result.scalar_one_or_none() == 'true'
- async def _set_migration_flag(self, value: str):
+ async def _set_migration_flag(self, execution_context: ExecutionContext, value: str):
"""Set rag_plugin_migration_needed flag."""
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_metadata.Metadata)
- .where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')
+ sqlalchemy.update(persistence_metadata.WorkspaceMetadata)
+ .where(persistence_metadata.WorkspaceMetadata.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_metadata.WorkspaceMetadata.key == 'rag_plugin_migration_needed')
.values(value=value)
)
@@ -56,35 +136,47 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
"""Check if a table exists."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text(
- 'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
- ).bindparams(table_name=table_name)
+ sqlalchemy.select(_INFORMATION_SCHEMA_TABLES.c.table_name)
+ .where(_INFORMATION_SCHEMA_TABLES.c.table_schema == 'public')
+ .where(_INFORMATION_SCHEMA_TABLES.c.table_name == table_name)
+ .limit(1)
)
- return result.scalar()
+ return result.first() is not None
else:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
- table_name=table_name
- )
+ sqlalchemy.select(_SQLITE_MASTER.c.name)
+ .where(_SQLITE_MASTER.c.type == 'table')
+ .where(_SQLITE_MASTER.c.name == table_name)
+ .limit(1)
)
return result.first() is not None
async def _install_plugin_from_marketplace(
- self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str
+ self,
+ execution_context: ExecutionContext,
+ plugin_id: str,
+ task_context: taskmgr.TaskContext,
+ space_url: str,
) -> None:
"""Install a single plugin from the marketplace."""
p_author, p_name = plugin_id.split('/', 1)
self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...')
task_context.trace(f'Installing plugin {plugin_id} from marketplace...')
- async with httpx.AsyncClient(trust_env=True, timeout=15) as client:
+ async with httpx.AsyncClient(
+ trust_env=True,
+ timeout=15,
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ ) as client:
resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}')
resp.raise_for_status()
- p_data = resp.json().get('data', {}).get('plugin', {})
+ response_data = await httpclient.parse_json_response(resp)
+ p_data = response_data.get('data', {}).get('plugin', {})
p_version = p_data.get('latest_version')
if not p_version:
raise Exception(f'Could not determine latest version for {plugin_id}')
+ await self.ap.plugin_connector.require_workspace_context(execution_context)
await self.ap.plugin_connector.install_plugin(
PluginInstallSource.MARKETPLACE,
{
@@ -96,8 +188,15 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
)
self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')
- async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):
+ async def _execute_rag_migration(
+ self,
+ execution_context: ExecutionContext,
+ task_context: taskmgr.TaskContext,
+ install_plugin: bool = True,
+ ):
"""Execute RAG migration: install required plugins and restore backup data."""
+ execution_context = await self._require_local_migration_context(execution_context)
+ execution_context = await self.ap.plugin_connector.require_workspace_context(execution_context)
warnings = []
# Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)
@@ -108,7 +207,10 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
has_external = await self._table_exists('external_knowledge_bases')
if has_external:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;')
+ sqlalchemy.select(
+ _LEGACY_EXTERNAL_KNOWLEDGE_BASE.c.plugin_author,
+ _LEGACY_EXTERNAL_KNOWLEDGE_BASE.c.plugin_name,
+ ).distinct()
)
for row in result.fetchall():
plugin_author = row[0] or ''
@@ -127,7 +229,14 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
for plugin_id in needed_plugins:
try:
- await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)
+ await self._install_plugin_from_marketplace(
+ execution_context,
+ plugin_id,
+ task_context,
+ space_url,
+ )
+ except WorkspaceNotFoundError:
+ raise
except Exception as e:
self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')
task_context.trace(f'Plugin install note ({plugin_id}): {e}')
@@ -141,8 +250,11 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
engine_id_set: set[str] = set()
for i in range(max_retries):
try:
+ await self.ap.plugin_connector.require_workspace_context(execution_context)
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_id_set = {e.get('plugin_id') for e in engines}
+ except WorkspaceNotFoundError:
+ raise
except Exception:
pass
if all(pid in engine_id_set for pid in needed_plugins):
@@ -158,17 +270,18 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
await asyncio.sleep(2)
else:
try:
+ await self.ap.plugin_connector.require_workspace_context(execution_context)
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_id_set = {e.get('plugin_id') for e in engines}
+ except WorkspaceNotFoundError:
+ raise
except Exception:
engine_id_set = set()
# Step 3: Restore internal knowledge bases from backup
task_context.trace('Restoring internal knowledge bases...', action='restore-internal')
if await self._table_exists('knowledge_bases_backup'):
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text('SELECT * FROM knowledge_bases_backup;')
- )
+ result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(_LEGACY_KNOWLEDGE_BASE_BACKUP))
rows = result.fetchall()
columns = result.keys()
@@ -183,30 +296,30 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
created_at = row_dict.get('created_at')
updated_at = row_dict.get('updated_at')
+ # DB migration 20 created these columns as TEXT, while a fresh
+ # schema uses SQLAlchemy JSON. Keep the statement structured,
+ # but retain untyped bound values so both physical schemas and
+ # SQLite's string-valued legacy DATETIME rows remain valid.
creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid})
retrieval_settings = json.dumps({'top_k': top_k})
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text(
- 'INSERT INTO knowledge_bases '
- '(uuid, name, description, emoji, created_at, updated_at, '
- 'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
- 'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
- ':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
- ).bindparams(
+ sqlalchemy.insert(_CURRENT_KNOWLEDGE_BASE).values(
uuid=kb_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
name=name,
description=description,
emoji=emoji,
created_at=created_at,
updated_at=updated_at,
- plugin_id=LANGRAG_PLUGIN_ID,
+ knowledge_engine_plugin_id=LANGRAG_PLUGIN_ID,
collection_id=kb_uuid,
creation_settings=creation_settings,
retrieval_settings=retrieval_settings,
)
)
+ await self.ap.plugin_connector.require_workspace_context(execution_context)
try:
config = {'embedding_model_uuid': embedding_model_uuid}
await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)
@@ -221,9 +334,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
# Step 4: Restore external knowledge bases
task_context.trace('Restoring external knowledge bases...', action='restore-external')
if has_external:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
- )
+ result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(_LEGACY_EXTERNAL_KNOWLEDGE_BASE))
rows = result.fetchall()
columns = result.keys()
@@ -266,20 +377,15 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields}
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text(
- 'INSERT INTO knowledge_bases '
- '(uuid, name, description, emoji, created_at, updated_at, '
- 'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
- 'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
- ':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
- ).bindparams(
+ sqlalchemy.insert(_CURRENT_KNOWLEDGE_BASE).values(
uuid=kb_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
name=name,
description=description,
emoji=emoji,
created_at=created_at,
updated_at=created_at,
- plugin_id=external_plugin_id,
+ knowledge_engine_plugin_id=external_plugin_id,
collection_id=kb_uuid,
creation_settings=json.dumps(creation_settings_dict),
retrieval_settings=json.dumps(retrieval_settings_dict),
@@ -294,6 +400,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
warnings.append(warning)
task_context.trace(warning)
else:
+ await self.ap.plugin_connector.require_workspace_context(execution_context)
try:
await self.ap.plugin_connector.rag_on_kb_create(
external_plugin_id, kb_uuid, creation_settings_dict
@@ -307,16 +414,23 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
await self.ap.rag_mgr.load_knowledge_bases_from_db()
# Step 5: Clear migration flag
- await self._set_migration_flag('false')
+ await self._set_migration_flag(execution_context, 'false')
task_context.trace('RAG migration completed.', action='done')
if warnings:
task_context.trace(f'Completed with {len(warnings)} warning(s).')
async def initialize(self) -> None:
- @self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- needed = await self._get_migration_flag()
+ @self.route(
+ '/status',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ execution_context = ExecutionContext.from_request(request_context)
+ execution_context = await self._require_local_migration_context(execution_context)
+ needed = await self._get_migration_flag(execution_context)
internal_kb_count = 0
external_kb_count = 0
@@ -324,13 +438,13 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
if needed:
if await self._table_exists('knowledge_bases_backup'):
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;')
+ sqlalchemy.select(sqlalchemy.func.count()).select_from(_LEGACY_KNOWLEDGE_BASE_BACKUP)
)
internal_kb_count = result.scalar() or 0
if await self._table_exists('external_knowledge_bases'):
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
+ sqlalchemy.select(sqlalchemy.func.count()).select_from(_LEGACY_EXTERNAL_KNOWLEDGE_BASE)
)
external_kb_count = result.scalar() or 0
@@ -342,9 +456,16 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
}
)
- @self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- needed = await self._get_migration_flag()
+ @self.route(
+ '/execute',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ execution_context = ExecutionContext.from_request(request_context)
+ execution_context = await self._require_local_migration_context(execution_context)
+ needed = await self._get_migration_flag(execution_context)
if not needed:
return self.http_status(400, -1, 'RAG migration is not needed')
@@ -353,20 +474,34 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
- self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),
+ self._execute_rag_migration(
+ execution_context,
+ task_context=ctx,
+ install_plugin=install_plugin,
+ ),
kind='rag-migration',
name='rag-migration-execute',
label='Migrating knowledge bases to plugin architecture',
context=ctx,
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
)
return self.success(data={'task_id': wrapper.id})
- @self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- needed = await self._get_migration_flag()
+ @self.route(
+ '/dismiss',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ execution_context = ExecutionContext.from_request(request_context)
+ execution_context = await self._require_local_migration_context(execution_context)
+ needed = await self._get_migration_flag(execution_context)
if not needed:
return self.http_status(400, -1, 'RAG migration is not needed')
- await self._set_migration_flag('false')
+ await self._set_migration_flag(execution_context, 'false')
return self.success()
diff --git a/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py b/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py
index a5e853cb6..495539307 100644
--- a/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py
+++ b/src/langbot/pkg/api/http/controller/groups/knowledge/parsers.py
@@ -1,16 +1,24 @@
import quart
+
+from ....authz import Permission
+from ....context import RequestContext
from ... import group
@group.group_class('parsers', '/api/v1/knowledge/parsers')
class ParsersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def list_parsers() -> quart.Response:
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def list_parsers(request_context: RequestContext) -> quart.Response:
"""List all available parsers from plugins.
Optional query parameter `mime_type` to filter parsers by supported MIME type.
"""
mime_type = quart.request.args.get('mime_type')
- parsers = await self.ap.knowledge_service.list_parsers(mime_type)
+ parsers = await self.ap.knowledge_service.list_parsers(request_context, mime_type)
return self.success(data={'parsers': parsers})
diff --git a/src/langbot/pkg/api/http/controller/groups/logs.py b/src/langbot/pkg/api/http/controller/groups/logs.py
index e3bff9db4..7adc14883 100644
--- a/src/langbot/pkg/api/http/controller/groups/logs.py
+++ b/src/langbot/pkg/api/http/controller/groups/logs.py
@@ -3,14 +3,23 @@ from __future__ import annotations
import quart
+from ...authz import Permission
+from ...context import RequestContext
from .. import group
@group.group_class('logs', '/api/v1/logs')
class LogsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
+ @self.route('', methods=['GET'], permission=Permission.AUDIT_VIEW)
+ async def _(request_context: RequestContext) -> str:
+ # The process log is instance-global. It is safe to expose only in
+ # the OSS singleton Workspace; SaaS must use Workspace-scoped
+ # observability records instead of leaking another tenant's lines.
+ await self.ap.workspace_service.get_local_execution_binding(
+ request_context.workspace_uuid,
+ expected_generation=request_context.placement_generation,
+ )
start_page_number = int(quart.request.args.get('start_page_number', 0))
start_offset = int(quart.request.args.get('start_offset', 0))
diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py
index 29dedcafb..d3aa03c2e 100644
--- a/src/langbot/pkg/api/http/controller/groups/monitoring.py
+++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py
@@ -3,6 +3,8 @@ from __future__ import annotations
import datetime
import quart
+from ...authz import Permission
+from ...context import RequestContext
from .. import group
@@ -24,8 +26,8 @@ def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None:
@group.group_class('monitoring', '/api/v1/monitoring')
class MonitoringRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('/overview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_overview() -> str:
+ @self.route('/overview', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_overview(request_context: RequestContext) -> str:
"""Get overview metrics"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -38,6 +40,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
metrics = await self.ap.monitoring_service.get_overview_metrics(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -46,8 +49,8 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=metrics)
- @self.route('/token-statistics', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_token_statistics() -> str:
+ @self.route('/token-statistics', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_token_statistics(request_context: RequestContext) -> str:
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
@@ -61,6 +64,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
stats = await self.ap.monitoring_service.get_token_statistics(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -70,8 +74,8 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=stats)
- @self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_messages() -> str:
+ @self.route('/messages', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_messages(request_context: RequestContext) -> str:
"""Get message logs"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -87,6 +91,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
messages, total = await self.ap.monitoring_service.get_messages(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
session_ids=session_ids if session_ids else None,
@@ -105,8 +110,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
- @self.route('/llm-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_llm_calls() -> str:
+ @self.route('/llm-calls', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_llm_calls(request_context: RequestContext) -> str:
"""Get LLM call records"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -121,6 +126,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
llm_calls, total = await self.ap.monitoring_service.get_llm_calls(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -138,8 +144,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
- @self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_tool_calls() -> str:
+ @self.route('/tool-calls', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_tool_calls(request_context: RequestContext) -> str:
"""Get tool call records"""
bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId')
@@ -153,6 +159,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
tool_calls, total = await self.ap.monitoring_service.get_tool_calls(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
session_ids=session_ids if session_ids else None,
@@ -171,8 +178,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
- @self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_embedding_calls() -> str:
+ @self.route('/embedding-calls', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_embedding_calls(request_context: RequestContext) -> str:
"""Get embedding call records"""
# Parse query parameters
start_time_str = quart.request.args.get('startTime')
@@ -186,6 +193,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls(
+ request_context,
start_time=start_time,
end_time=end_time,
knowledge_base_id=knowledge_base_id if knowledge_base_id else None,
@@ -202,8 +210,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
- @self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_sessions() -> str:
+ @self.route('/sessions', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_sessions(request_context: RequestContext) -> str:
"""Get session information"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -224,6 +232,7 @@ class MonitoringRouterGroup(group.RouterGroup):
is_active = is_active_str.lower() == 'true'
sessions, total = await self.ap.monitoring_service.get_sessions(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -242,8 +251,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
- @self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_errors() -> str:
+ @self.route('/errors', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_errors(request_context: RequestContext) -> str:
"""Get error logs"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -258,6 +267,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
errors, total = await self.ap.monitoring_service.get_errors(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -275,8 +285,8 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
- @self.route('/data', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_all_data() -> str:
+ @self.route('/data', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_all_data(request_context: RequestContext) -> str:
"""Get all monitoring data in a single request"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -291,6 +301,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get overview metrics
overview = await self.ap.monitoring_service.get_overview_metrics(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -299,6 +310,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get messages
messages, messages_total = await self.ap.monitoring_service.get_messages(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -309,6 +321,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get LLM calls
llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -319,6 +332,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get tool calls
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -329,6 +343,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get sessions
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -340,6 +355,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get errors
errors, errors_total = await self.ap.monitoring_service.get_errors(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -350,6 +366,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get embedding calls
embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls(
+ request_context,
start_time=start_time,
end_time=end_time,
limit=limit,
@@ -376,27 +393,27 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
- @self.route('/sessions//analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_session_analysis(session_id: str) -> str:
+ @self.route('/sessions//analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session"""
- analysis = await self.ap.monitoring_service.get_session_analysis(session_id)
+ analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
# Always return success with the analysis data
# The frontend will handle the 'found: false' case
return self.success(data=analysis)
- @self.route('/messages//details', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_message_details(message_id: str) -> str:
+ @self.route('/messages//details', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_message_details(message_id: str, request_context: RequestContext) -> str:
"""Get detailed information for a specific message"""
- details = await self.ap.monitoring_service.get_message_details(message_id)
+ details = await self.ap.monitoring_service.get_message_details(request_context, message_id)
if not details.get('found'):
return self.error(message=f'Message {message_id} not found', code=404)
return self.success(data=details)
- @self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def export_data() -> tuple[str, int]:
+ @self.route('/export', methods=['GET'], permission=Permission.DATA_EXPORT)
+ async def export_data(request_context: RequestContext) -> tuple[str, int]:
"""Export monitoring data as CSV"""
# Parse query parameters
export_type = quart.request.args.get('type', 'messages')
@@ -413,6 +430,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Get data based on export type
if export_type == 'messages':
data = await self.ap.monitoring_service.export_messages(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -437,6 +455,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'llm-calls':
data = await self.ap.monitoring_service.export_llm_calls(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -463,6 +482,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'embedding-calls':
data = await self.ap.monitoring_service.export_embedding_calls(
+ request_context,
start_time=start_time,
end_time=end_time,
limit=limit,
@@ -485,6 +505,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'errors':
data = await self.ap.monitoring_service.export_errors(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -506,6 +527,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'sessions':
data = await self.ap.monitoring_service.export_sessions(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -527,6 +549,7 @@ class MonitoringRouterGroup(group.RouterGroup):
]
elif export_type == 'feedback':
data = await self.ap.monitoring_service.export_feedback(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -581,8 +604,8 @@ class MonitoringRouterGroup(group.RouterGroup):
return response, 200
- @self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_feedback_stats() -> str:
+ @self.route('/feedback/stats', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_feedback_stats(request_context: RequestContext) -> str:
"""Get feedback statistics"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -595,6 +618,7 @@ class MonitoringRouterGroup(group.RouterGroup):
end_time = parse_iso_datetime(end_time_str)
stats = await self.ap.monitoring_service.get_feedback_stats(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
start_time=start_time,
@@ -603,8 +627,8 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=stats)
- @self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def get_feedback() -> str:
+ @self.route('/feedback', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def get_feedback(request_context: RequestContext) -> str:
"""Get feedback list"""
# Parse query parameters
bot_ids = quart.request.args.getlist('botId')
@@ -623,6 +647,7 @@ class MonitoringRouterGroup(group.RouterGroup):
feedback_type = int(feedback_type_str) if feedback_type_str else None
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
+ request_context,
bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_ids else None,
feedback_type=feedback_type,
diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
index 50e9112b7..26f255e37 100644
--- a/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
+++ b/src/langbot/pkg/api/http/controller/groups/pipelines/embed.py
@@ -20,10 +20,12 @@ import httpx
import quart
from ... import group
-from ......utils import paths
-from ......platform.sources.websocket_manager import is_valid_session_id, ws_connection_manager
+from ......utils import httpclient, paths
+from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager
+from .websocket_chat import create_scoped_duplex_tasks, wait_for_duplex_tasks
logger = logging.getLogger(__name__)
+_AUTH_TIMEOUT_SECONDS = 10.0
# Cache the widget template content
_widget_template_cache: str | None = None
@@ -58,37 +60,31 @@ def _get_logo_bytes() -> bytes:
class EmbedRouterGroup(group.RouterGroup):
# -- helpers -------------------------------------------------------------
- def _resolve_bot(self, bot_uuid: str):
+ async def _resolve_bot(self, bot_uuid: str):
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
Returns ``(None, None)`` when the bot does not exist, is not a
``web_page_bot``, is disabled, or has no pipeline bound.
"""
- for bot in self.ap.platform_mgr.bots:
- if (
- bot.bot_entity.uuid == bot_uuid
- and bot.bot_entity.adapter == 'web_page_bot'
- and bot.bot_entity.enable
- and bot.bot_entity.use_pipeline_uuid
- ):
- return bot, bot.bot_entity.use_pipeline_uuid
+ bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
+ if (
+ bot is not None
+ and bot.bot_entity.adapter == 'web_page_bot'
+ and bot.bot_entity.enable
+ and bot.bot_entity.use_pipeline_uuid
+ ):
+ return bot, bot.bot_entity.use_pipeline_uuid
return None, None
- def _get_bot_config(self, bot_uuid: str) -> dict:
- for bot in self.ap.platform_mgr.bots:
- if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
- return bot.bot_entity.adapter_config
- return {}
+ @staticmethod
+ def _get_bot_config(runtime_bot) -> dict:
+ return runtime_bot.bot_entity.adapter_config
- async def _verify_session_token(self, request, bot_uuid: str) -> bool:
- config = self._get_bot_config(bot_uuid)
+ def _verify_session_token_value(self, token: str, runtime_bot) -> bool:
+ config = self._get_bot_config(runtime_bot)
secret = config.get('turnstile_secret_key', '')
if not secret:
return True
- auth_header = request.headers.get('Authorization', '')
- if not auth_header.startswith('Bearer '):
- return False
- token = auth_header[7:]
try:
ts_str, mac = token.split('.', 1)
ts = float(ts_str)
@@ -99,6 +95,50 @@ class EmbedRouterGroup(group.RouterGroup):
except Exception:
return False
+ async def _verify_session_token(self, request, runtime_bot) -> bool:
+ auth_header = request.headers.get('Authorization', '')
+ token = auth_header[7:] if auth_header.startswith('Bearer ') else ''
+ return self._verify_session_token_value(token, runtime_bot)
+
+ async def _authenticate_websocket(self, runtime_bot) -> None:
+ """Require the embed session token as the first WebSocket frame."""
+
+ raw_message = await asyncio.wait_for(quart.websocket.receive(), timeout=_AUTH_TIMEOUT_SECONDS)
+ payload = await asyncio.to_thread(json.loads, raw_message)
+ if not isinstance(payload, dict) or payload.get('type') != 'authenticate':
+ raise ValueError('Authentication is required')
+ token = str(payload.get('token') or '')
+ if not self._verify_session_token_value(token, runtime_bot):
+ raise ValueError('Authentication is required')
+
+ async def _assert_execution_active(self, runtime_bot) -> None:
+ context = runtime_bot.execution_context
+ await self.ap.workspace_service.get_execution_binding(
+ context.workspace_uuid,
+ expected_generation=context.placement_generation,
+ )
+
+ async def _resolve_connected_bot(self, owner_bot, pipeline_uuid: str):
+ """Re-resolve mutable bot state before every public message."""
+ current_bot, current_pipeline_uuid = await self._resolve_bot(owner_bot.bot_entity.uuid)
+ if current_bot is None or current_pipeline_uuid != pipeline_uuid:
+ raise RuntimeError('Bot is unavailable')
+
+ owner_context = owner_bot.execution_context
+ current_context = current_bot.execution_context
+ if (
+ current_context.instance_uuid,
+ current_context.workspace_uuid,
+ current_context.placement_generation,
+ ) != (
+ owner_context.instance_uuid,
+ owner_context.workspace_uuid,
+ owner_context.placement_generation,
+ ):
+ raise RuntimeError('Bot is unavailable')
+ await self._assert_execution_active(current_bot)
+ return current_bot
+
# -- routes --------------------------------------------------------------
async def initialize(self) -> None:
@@ -106,7 +146,7 @@ class EmbedRouterGroup(group.RouterGroup):
async def verify_turnstile(bot_uuid: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
- runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
+ runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
try:
@@ -115,18 +155,18 @@ class EmbedRouterGroup(group.RouterGroup):
if not token:
return self.http_status(400, -1, 'Token is required')
- config = self._get_bot_config(bot_uuid)
+ config = self._get_bot_config(runtime_bot)
secret = config.get('turnstile_secret_key', '')
if not secret:
ts = time.time()
return self.success(data={'token': f'{ts}.dummy'})
- async with httpx.AsyncClient() as client:
+ async with httpx.AsyncClient(event_hooks=httpclient.httpx_response_limit_hooks()) as client:
resp = await client.post(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
data={'secret': secret, 'response': token},
)
- result = resp.json()
+ result = await httpclient.parse_json_response(resp)
if not result.get('success'):
return self.http_status(403, -1, 'Turnstile verification failed')
@@ -146,7 +186,7 @@ class EmbedRouterGroup(group.RouterGroup):
"""Serve the embed widget JavaScript with injected configuration."""
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
- runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
+ runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return quart.Response(
'// Bot not found or not available', status=404, content_type='application/javascript'
@@ -164,7 +204,7 @@ class EmbedRouterGroup(group.RouterGroup):
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
base_url = quart.request.host_url.rstrip('/')
- config = self._get_bot_config(bot_uuid)
+ config = self._get_bot_config(runtime_bot)
site_key = config.get('turnstile_site_key', '')
locale = config.get('language', 'en_US') or 'en_US'
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
@@ -194,10 +234,10 @@ class EmbedRouterGroup(group.RouterGroup):
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
- runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
+ runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
- if not await self._verify_session_token(quart.request, bot_uuid):
+ if not await self._verify_session_token(quart.request, runtime_bot):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
if session_type not in ['person', 'group']:
@@ -207,7 +247,8 @@ class EmbedRouterGroup(group.RouterGroup):
if not is_valid_session_id(session_id):
return self.http_status(400, -1, 'Valid session_id is required')
- websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
+ proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
+ websocket_adapter = proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
@@ -222,10 +263,10 @@ class EmbedRouterGroup(group.RouterGroup):
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
- runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
+ runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
- if not await self._verify_session_token(quart.request, bot_uuid):
+ if not await self._verify_session_token(quart.request, runtime_bot):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
if session_type not in ['person', 'group']:
@@ -235,7 +276,8 @@ class EmbedRouterGroup(group.RouterGroup):
if not is_valid_session_id(session_id):
return self.http_status(400, -1, 'Valid session_id is required')
- websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
+ proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
+ websocket_adapter = proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
@@ -250,10 +292,10 @@ class EmbedRouterGroup(group.RouterGroup):
async def submit_feedback(bot_uuid: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
- runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
+ runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
- if not await self._verify_session_token(quart.request, bot_uuid):
+ if not await self._verify_session_token(quart.request, runtime_bot):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
data = await quart.request.get_json()
@@ -266,6 +308,7 @@ class EmbedRouterGroup(group.RouterGroup):
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
await self.ap.monitoring_service.record_feedback(
+ runtime_bot.execution_context,
feedback_id=feedback_id,
feedback_type=feedback_type,
bot_id=runtime_bot.bot_entity.uuid,
@@ -286,11 +329,12 @@ class EmbedRouterGroup(group.RouterGroup):
@self.quart_app.websocket(self.path + '//ws/connect')
async def embed_websocket_connect(bot_uuid: str):
"""WebSocket connection for embed widget, keyed by bot_uuid."""
+ await quart.websocket.accept()
if not _is_valid_uuid(bot_uuid):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
return
- runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
+ runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
if runtime_bot is None:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
return
@@ -307,18 +351,42 @@ class EmbedRouterGroup(group.RouterGroup):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
return
- websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
- if not websocket_adapter:
- await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
+ try:
+ await self._authenticate_websocket(runtime_bot)
+ await self._assert_execution_active(runtime_bot)
+ except Exception:
+ await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
return
try:
+ proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
+ websocket_adapter = proxy_bot.adapter
+ if not websocket_adapter:
+ await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
+ return
+
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
+ scope=WebSocketScope.from_context(runtime_bot.execution_context),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
+ send_queue_size=(
+ self.ap.instance_config.data.get('system', {})
+ .get('websocket_retention', {})
+ .get('send_queue_size', 100)
+ ),
+ max_connections=(
+ self.ap.instance_config.data.get('system', {})
+ .get('websocket_retention', {})
+ .get('max_connections', 1024)
+ ),
+ max_connections_per_workspace=(
+ self.ap.instance_config.data.get('system', {})
+ .get('websocket_retention', {})
+ .get('max_connections_per_workspace', 32)
+ ),
)
await quart.websocket.send(
@@ -338,11 +406,19 @@ class EmbedRouterGroup(group.RouterGroup):
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
)
- receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
- send_task = asyncio.create_task(self._handle_send(connection))
+ receive_task, send_task = create_scoped_duplex_tasks(
+ self._handle_receive(
+ connection,
+ websocket_adapter,
+ runtime_bot,
+ pipeline_uuid,
+ ),
+ self._handle_send(connection),
+ runtime_bot.execution_context.workspace_uuid,
+ )
try:
- await asyncio.gather(receive_task, send_task)
+ await wait_for_duplex_tasks(receive_task, send_task)
except Exception as e:
logger.error(f'Embed WebSocket task error: {e}')
finally:
@@ -357,14 +433,14 @@ class EmbedRouterGroup(group.RouterGroup):
# -- WebSocket receive/send helpers --------------------------------------
- async def _handle_receive(self, connection, websocket_adapter, owner_bot):
+ async def _handle_receive(self, connection, websocket_adapter, owner_bot, pipeline_uuid: str):
try:
while connection.is_active:
message = await quart.websocket.receive()
await ws_connection_manager.update_activity(connection.connection_id)
try:
- data = json.loads(message)
+ data = await asyncio.to_thread(json.loads, message)
message_type = data.get('type', 'message')
if message_type == 'ping':
@@ -372,7 +448,12 @@ class EmbedRouterGroup(group.RouterGroup):
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
)
elif message_type == 'message':
- await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
+ try:
+ current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid)
+ except Exception:
+ await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
+ break
+ await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
elif message_type == 'disconnect':
break
@@ -383,13 +464,20 @@ class EmbedRouterGroup(group.RouterGroup):
logger.error(f'Embed receive error: {e}', exc_info=True)
finally:
connection.is_active = False
+ try:
+ connection.send_queue.put_nowait(None)
+ except asyncio.QueueFull:
+ pass
async def _handle_send(self, connection):
try:
- while connection.is_active:
+ while connection.is_active or not connection.send_queue.empty():
try:
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
- await quart.websocket.send(json.dumps(message))
+ if message is None:
+ break
+ encoded = await asyncio.to_thread(json.dumps, message)
+ await quart.websocket.send(encoded)
except asyncio.TimeoutError:
continue
except Exception as e:
diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
index 2e45add77..69189d2ee 100644
--- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
+++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
@@ -2,120 +2,156 @@ from __future__ import annotations
import quart
+from ....authz import Permission, has_permission
+from ....context import RequestContext
+from ....service.secrets import redact_secrets
from ... import group
@group.group_class('pipelines', '/api/v1/pipelines')
class PipelinesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
- if quart.request.method == 'GET':
- sort_by = quart.request.args.get('sort_by', 'created_at')
- sort_order = quart.request.args.get('sort_order', 'DESC')
- return self.success(
- data={'pipelines': await self.ap.pipeline_service.get_pipelines(sort_by, sort_order)}
- )
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
-
- pipeline_uuid = await self.ap.pipeline_service.create_pipeline(json_data)
-
- return self.success(data={'uuid': pipeline_uuid})
-
- @self.route('/_/metadata', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
- return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata()})
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ sort_by = quart.request.args.get('sort_by', 'created_at')
+ sort_order = quart.request.args.get('sort_order', 'DESC')
+ include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
+ return self.success(
+ data={
+ 'pipelines': await self.ap.pipeline_service.get_pipelines(
+ request_context,
+ sort_by,
+ sort_order,
+ include_secret=include_secret,
+ )
+ }
+ )
@self.route(
- '/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
+ '',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
)
- async def _(pipeline_uuid: str) -> str:
- if quart.request.method == 'GET':
- pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
+ async def _(request_context: RequestContext) -> str:
+ pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
+ return self.success(data={'uuid': pipeline_uuid})
- if pipeline is None:
- return self.http_status(404, -1, 'pipeline not found')
+ @self.route(
+ '/_/metadata',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ return self.success(data={'configs': await self.ap.pipeline_service.get_pipeline_metadata(request_context)})
- return self.success(data={'pipeline': pipeline})
- elif quart.request.method == 'PUT':
- json_data = await quart.request.json
+ @self.route(
+ '/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
+ pipeline = await self.ap.pipeline_service.get_pipeline(
+ request_context,
+ pipeline_uuid,
+ include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
+ )
+ if pipeline is None:
+ return self.http_status(404, -1, 'pipeline not found')
+ return self.success(data={'pipeline': pipeline})
- await self.ap.pipeline_service.update_pipeline(pipeline_uuid, json_data)
+ @self.route(
+ '/',
+ methods=['PUT', 'DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
+ if quart.request.method == 'PUT':
+ try:
+ await self.ap.pipeline_service.update_pipeline(
+ request_context,
+ pipeline_uuid,
+ await quart.request.json,
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ else:
+ await self.ap.pipeline_service.delete_pipeline(request_context, pipeline_uuid)
+ return self.success()
- return self.success()
- elif quart.request.method == 'DELETE':
- await self.ap.pipeline_service.delete_pipeline(pipeline_uuid)
-
- return self.success()
-
- @self.route('//copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(pipeline_uuid: str) -> str:
+ @self.route(
+ '//copy',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
try:
- new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid)
+ new_uuid = await self.ap.pipeline_service.copy_pipeline(request_context, pipeline_uuid)
return self.success(data={'uuid': new_uuid})
except ValueError as e:
- return self.http_status(404, -1, str(e))
+ return self.http_status(400, -1, str(e))
@self.route(
- '//extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
+ '//extensions',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _(pipeline_uuid: str) -> str:
- if quart.request.method == 'GET':
- # Get current extensions and available plugins
- pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
- if pipeline is None:
- return self.http_status(404, -1, 'pipeline not found')
+ async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
+ pipeline = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
+ if pipeline is None:
+ return self.http_status(404, -1, 'pipeline not found')
- # Only include plugins with pipeline-related components (Command, EventListener, Tool)
- # Plugins that only have KnowledgeEngine components are not suitable for pipeline extensions
- pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
- plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
- mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
+ pipeline_component_kinds = ['Command', 'EventListener', 'Tool']
+ if self.ap.plugin_connector.is_enable_plugin:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
+ plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds)
+ mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True)
+ available_skills = await self.ap.skill_service.list_skills(request_context)
+ extensions_prefs = pipeline.get('extensions_preferences', {})
+ return self.success(
+ data={
+ 'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
+ 'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
+ 'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
+ 'bound_plugins': extensions_prefs.get('plugins', []),
+ 'available_plugins': redact_secrets(plugins),
+ 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
+ 'available_mcp_servers': mcp_servers,
+ 'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
+ 'mcp_resource_agent_read_enabled': extensions_prefs.get('mcp_resource_agent_read_enabled', True),
+ 'bound_skills': extensions_prefs.get('skills', []),
+ 'available_skills': available_skills,
+ }
+ )
- # Get available skills
- available_skills = await self.ap.skill_service.list_skills()
-
- extensions_prefs = pipeline.get('extensions_preferences', {})
- return self.success(
- data={
- 'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True),
- 'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True),
- 'enable_all_skills': extensions_prefs.get('enable_all_skills', True),
- 'bound_plugins': extensions_prefs.get('plugins', []),
- 'available_plugins': plugins,
- 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
- 'available_mcp_servers': mcp_servers,
- 'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
- 'mcp_resource_agent_read_enabled': extensions_prefs.get(
- 'mcp_resource_agent_read_enabled', True
- ),
- 'bound_skills': extensions_prefs.get('skills', []),
- 'available_skills': available_skills,
- }
- )
- elif quart.request.method == 'PUT':
- # Update bound plugins and MCP servers for this pipeline
- json_data = await quart.request.json
- enable_all_plugins = json_data.get('enable_all_plugins', True)
- enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True)
- enable_all_skills = json_data.get('enable_all_skills', True)
- bound_plugins = json_data.get('bound_plugins', [])
- bound_mcp_servers = json_data.get('bound_mcp_servers', [])
- bound_skills = json_data.get('bound_skills', [])
- bound_mcp_resources = json_data.get('bound_mcp_resources')
- mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled')
-
- await self.ap.pipeline_service.update_pipeline_extensions(
- pipeline_uuid,
- bound_plugins,
- bound_mcp_servers,
- enable_all_plugins,
- enable_all_mcp_servers,
- bound_skills=bound_skills,
- enable_all_skills=enable_all_skills,
- bound_mcp_resources=bound_mcp_resources,
- mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
- )
-
- return self.success()
+ @self.route(
+ '//extensions',
+ methods=['PUT'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
+ json_data = await quart.request.json
+ await self.ap.pipeline_service.update_pipeline_extensions(
+ request_context,
+ pipeline_uuid,
+ json_data.get('bound_plugins', []),
+ json_data.get('bound_mcp_servers', []),
+ json_data.get('enable_all_plugins', True),
+ json_data.get('enable_all_mcp_servers', True),
+ bound_skills=json_data.get('bound_skills', []),
+ enable_all_skills=json_data.get('enable_all_skills', True),
+ bound_mcp_resources=json_data.get('bound_mcp_resources'),
+ mcp_resource_agent_read_enabled=json_data.get('mcp_resource_agent_read_enabled'),
+ )
+ return self.success()
diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py
index ebe46b8fe..565107117 100644
--- a/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py
+++ b/src/langbot/pkg/api/http/controller/groups/pipelines/websocket_chat.py
@@ -1,64 +1,234 @@
-"""WebSocket聊天路由 - 支持双向实时通信"""
+"""Authenticated dashboard WebSocket chat routes."""
+
+from __future__ import annotations
import asyncio
import datetime
import json
import logging
+import typing
+import uuid
import quart
+from ....authz import Permission, permissions_for_role, require_permission
+from ....context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from ... import group
-from ......platform.sources.websocket_manager import ws_connection_manager
+from ......core.task_boundary import run_in_workspace_uow
+from ......platform.sources.websocket_manager import WebSocketScope, ws_connection_manager
+from ......utils import bounded_executor
logger = logging.getLogger(__name__)
+_AUTH_TIMEOUT_SECONDS = 10.0
+_DUPLEX_DRAIN_TIMEOUT_SECONDS = 0.25
+
+
+def create_scoped_duplex_tasks(
+ receive_coro: typing.Coroutine[typing.Any, typing.Any, None],
+ send_coro: typing.Coroutine[typing.Any, typing.Any, None],
+ workspace_uuid: str,
+) -> tuple[asyncio.Task[None], asyncio.Task[None]]:
+ """Create both socket directions under one trusted Workspace budget."""
+
+ return (
+ asyncio.create_task(
+ bounded_executor.run_in_blocking_work_scope(
+ receive_coro,
+ workspace_uuid,
+ )
+ ),
+ asyncio.create_task(
+ bounded_executor.run_in_blocking_work_scope(
+ send_coro,
+ workspace_uuid,
+ )
+ ),
+ )
+
+
+async def wait_for_duplex_tasks(
+ receive_task: asyncio.Task,
+ send_task: asyncio.Task,
+) -> None:
+ """Stop the peer direction as soon as either socket task terminates."""
+
+ try:
+ done, _ = await asyncio.wait(
+ {receive_task, send_task},
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ # A receive task may enqueue a terminal authorization/error frame and
+ # then finish. Give the sender a short deterministic drain window
+ # instead of cancelling it before that frame reaches the client.
+ if receive_task in done and not send_task.done():
+ await asyncio.wait(
+ {send_task},
+ timeout=_DUPLEX_DRAIN_TIMEOUT_SECONDS,
+ )
+ finally:
+ for task in (receive_task, send_task):
+ if not task.done():
+ task.cancel()
+ await asyncio.gather(
+ receive_task,
+ send_task,
+ return_exceptions=True,
+ )
@group.group_class('websocket_chat', '/api/v1/pipelines//ws')
class WebSocketChatRouterGroup(group.RouterGroup):
+ async def _authenticate_websocket(self) -> tuple[RequestContext, str]:
+ """Authenticate the first dashboard WebSocket message.
+
+ Browsers cannot attach the normal Authorization/X-Workspace-Id headers
+ to a WebSocket handshake. The client therefore sends one auth frame
+ immediately after opening the socket; no connection is registered and
+ no runtime object is resolved before this method succeeds.
+ """
+
+ raw_message = await asyncio.wait_for(quart.websocket.receive(), timeout=_AUTH_TIMEOUT_SECONDS)
+ payload = await asyncio.to_thread(json.loads, raw_message)
+ if not isinstance(payload, dict) or payload.get('type') != 'authenticate':
+ raise ValueError('Authentication is required')
+
+ token = str(payload.get('token') or '').strip()
+ workspace_uuid = str(payload.get('workspace_uuid') or '').strip()
+ if not token or not workspace_uuid:
+ raise ValueError('Authentication is required')
+
+ account, _ = await self._authenticate_account(token)
+ account_uuid = getattr(account, 'uuid', None)
+ collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
+ if not isinstance(account_uuid, str) or collaboration_service is None:
+ raise ValueError('Workspace authentication is unavailable')
+
+ access = await collaboration_service.resolve_account_workspace(account_uuid, workspace_uuid)
+ request_context = RequestContext(
+ instance_uuid=access.execution.instance_uuid,
+ placement_generation=access.execution.placement_generation,
+ request_id=quart.websocket.headers.get('X-Request-Id') or str(uuid.uuid4()),
+ auth_type=group.AuthType.USER_TOKEN.value,
+ principal=PrincipalContext(
+ principal_type=PrincipalType.ACCOUNT,
+ account_uuid=account_uuid,
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid=access.workspace.uuid,
+ membership_uuid=access.membership.uuid,
+ role=access.membership.role,
+ permissions=permissions_for_role(access.membership.role),
+ membership_revision=access.membership.projection_revision,
+ ),
+ )
+ require_permission(request_context, Permission.RUNTIME_OPERATE)
+ return request_context, token
+
+ async def _revalidate_websocket_authorization(
+ self,
+ request_context: RequestContext,
+ token: str,
+ ) -> RequestContext:
+ """Recheck revocable account, membership, permission, and placement state."""
+
+ account, _ = await self._authenticate_account(token)
+ account_uuid = getattr(account, 'uuid', None)
+ if account_uuid != request_context.account_uuid:
+ raise ValueError('WebSocket account changed')
+
+ collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
+ if collaboration_service is None or not isinstance(account_uuid, str):
+ raise ValueError('Workspace authentication is unavailable')
+ access = await collaboration_service.resolve_account_workspace(
+ account_uuid,
+ request_context.workspace_uuid,
+ )
+ if (
+ access.workspace.uuid != request_context.workspace_uuid
+ or access.membership.uuid != request_context.workspace.membership_uuid
+ or access.membership.projection_revision != request_context.workspace.membership_revision
+ or access.execution.instance_uuid != request_context.instance_uuid
+ or access.execution.placement_generation != request_context.placement_generation
+ ):
+ raise ValueError('WebSocket authorization changed')
+
+ current_context = RequestContext(
+ instance_uuid=access.execution.instance_uuid,
+ placement_generation=access.execution.placement_generation,
+ request_id=request_context.request_id,
+ auth_type=request_context.auth_type,
+ principal=request_context.principal,
+ workspace=WorkspaceContext(
+ workspace_uuid=access.workspace.uuid,
+ membership_uuid=access.membership.uuid,
+ role=access.membership.role,
+ permissions=permissions_for_role(access.membership.role),
+ membership_revision=access.membership.projection_revision,
+ ),
+ entitlement_revision=request_context.entitlement_revision,
+ )
+ require_permission(current_context, Permission.RUNTIME_OPERATE)
+ return current_context
+
+ async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
+ pipeline = await run_in_workspace_uow(
+ self.ap,
+ request_context.workspace_uuid,
+ lambda: self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid),
+ )
+ if pipeline is None:
+ return None
+ proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(request_context)
+ return proxy_bot.adapter
+
async def initialize(self) -> None:
- # 直接使用 quart_app 注册 WebSocket 路由
@self.quart_app.websocket(self.path + '/connect')
async def websocket_connect(pipeline_uuid: str):
- """
- 建立WebSocket连接
+ """Open one authenticated dashboard debug connection."""
- URL参数:
- - pipeline_uuid: 流水线UUID
- - session_type: 会话类型 (person/group)
- """
+ await quart.websocket.accept()
try:
- # 获取参数 - 在WebSocket上下文中使用 quart.websocket.args
- session_type = quart.websocket.args.get('session_type', 'person')
+ request_context, token = await self._authenticate_websocket()
+ except Exception:
+ await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
+ return
- if session_type not in ['person', 'group']:
- await quart.websocket.send(
- json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
- )
+ session_type = quart.websocket.args.get('session_type', 'person')
+ if session_type not in ['person', 'group']:
+ await quart.websocket.send(
+ json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
+ )
+ return
+
+ try:
+ websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
+ if websocket_adapter is None:
+ await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'}))
return
- # 获取WebSocket适配器
- websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
-
- if not websocket_adapter:
- await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
- return
-
- # Dashboard pipeline-debug sessions must always run under the
- # built-in websocket_proxy_bot identity. We deliberately do NOT
- # resolve a web_page_bot owner here — even if one is bound to
- # the same pipeline, debug requests must not be attributed to
- # it. The embed widget path (`/api/v1/embed//ws/connect`)
- # is the one that carries the page-bot identity.
-
- # 注册连接
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
+ scope=WebSocketScope.from_context(request_context),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
+ send_queue_size=(
+ self.ap.instance_config.data.get('system', {})
+ .get('websocket_retention', {})
+ .get('send_queue_size', 100)
+ ),
+ max_connections=(
+ self.ap.instance_config.data.get('system', {})
+ .get('websocket_retention', {})
+ .get('max_connections', 1024)
+ ),
+ max_connections_per_workspace=(
+ self.ap.instance_config.data.get('system', {})
+ .get('websocket_retention', {})
+ .get('max_connections_per_workspace', 32)
+ ),
)
- # 发送连接成功消息
await quart.websocket.send(
json.dumps(
{
@@ -72,182 +242,188 @@ class WebSocketChatRouterGroup(group.RouterGroup):
)
logger.debug(
- f'WebSocket connection established: {connection.connection_id} '
- f'(pipeline={pipeline_uuid}, session_type={session_type})'
+ f'Dashboard WebSocket connected: {connection.connection_id} '
+ f'(workspace={connection.workspace_uuid}, pipeline={pipeline_uuid}, '
+ f'session_type={session_type})'
)
- # 创建接收和发送任务
- receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
- send_task = asyncio.create_task(self._handle_send(connection))
-
- # 等待任务完成
+ receive_task, send_task = create_scoped_duplex_tasks(
+ self._handle_receive(
+ connection,
+ websocket_adapter,
+ request_context,
+ token,
+ ),
+ self._handle_send(connection),
+ request_context.workspace_uuid,
+ )
try:
- await asyncio.gather(receive_task, send_task)
- except Exception as e:
- logger.error(f'WebSocket task execution error: {e}')
+ await wait_for_duplex_tasks(receive_task, send_task)
+ except Exception as exc:
+ logger.error(f'WebSocket task execution error: {exc}')
finally:
- # 清理连接
await ws_connection_manager.remove_connection(connection.connection_id)
- logger.debug(f'WebSocket connection cleaned: {connection.connection_id}')
- except Exception as e:
- logger.error(f'WebSocket connection error: {e}', exc_info=True)
+ except Exception:
+ logger.error('Dashboard WebSocket connection error', exc_info=True)
try:
- await quart.websocket.send(json.dumps({'type': 'error', 'message': str(e)}))
- except:
+ await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
+ except Exception:
pass
- @self.route('/messages/', methods=['GET'])
- async def get_messages(pipeline_uuid: str, session_type: str) -> str:
- """获取消息历史"""
- try:
- if session_type not in ['person', 'group']:
- return self.http_status(400, -1, 'session_type must be person or group')
+ @self.route(
+ '/messages/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RUNTIME_OPERATE,
+ )
+ async def get_messages(
+ pipeline_uuid: str,
+ session_type: str,
+ request_context: RequestContext,
+ ) -> str:
+ if session_type not in ['person', 'group']:
+ return self.http_status(400, -1, 'session_type must be person or group')
- websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
+ websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
+ if websocket_adapter is None:
+ return self.http_status(404, -1, 'Pipeline not found')
+ messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
+ return self.success(data={'messages': messages})
- if not websocket_adapter:
- return self.http_status(404, -1, 'WebSocket adapter not found')
+ @self.route(
+ '/reset/',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RUNTIME_OPERATE,
+ )
+ async def reset_session(
+ pipeline_uuid: str,
+ session_type: str,
+ request_context: RequestContext,
+ ) -> str:
+ if session_type not in ['person', 'group']:
+ return self.http_status(400, -1, 'session_type must be person or group')
- messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
+ websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
+ if websocket_adapter is None:
+ return self.http_status(404, -1, 'Pipeline not found')
+ websocket_adapter.reset_session(pipeline_uuid, session_type)
+ return self.success(data={'message': 'Session reset successfully'})
- return self.success(data={'messages': messages})
+ @self.route(
+ '/connections',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RUNTIME_OPERATE,
+ )
+ async def get_connections(pipeline_uuid: str, request_context: RequestContext) -> str:
+ if await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) is None:
+ return self.http_status(404, -1, 'Pipeline not found')
- except Exception as e:
- return self.http_status(500, -1, f'Internal server error: {str(e)}')
-
- @self.route('/reset/', methods=['POST'])
- async def reset_session(pipeline_uuid: str, session_type: str) -> str:
- """重置会话"""
- try:
- if session_type not in ['person', 'group']:
- return self.http_status(400, -1, 'session_type must be person or group')
-
- websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
-
- if not websocket_adapter:
- return self.http_status(404, -1, 'WebSocket adapter not found')
-
- websocket_adapter.reset_session(pipeline_uuid, session_type)
-
- return self.success(data={'message': 'Session reset successfully'})
-
- except Exception as e:
- return self.http_status(500, -1, f'Internal server error: {str(e)}')
-
- @self.route('/connections', methods=['GET'])
- async def get_connections(pipeline_uuid: str) -> str:
- """获取当前连接统计"""
- try:
- stats = ws_connection_manager.get_stats()
- connections = await ws_connection_manager.get_connections_by_pipeline(pipeline_uuid)
-
- return self.success(
- data={
- 'stats': stats,
- 'connections': [
- {
- 'connection_id': conn.connection_id,
- 'session_type': conn.session_type,
- 'created_at': conn.created_at.isoformat(),
- 'last_active': conn.last_active.isoformat(),
- 'is_active': conn.is_active,
- }
- for conn in connections
- ],
- }
- )
-
- except Exception as e:
- return self.http_status(500, -1, f'Internal server error: {str(e)}')
-
- @self.route('/broadcast', methods=['POST'])
- async def broadcast_message(pipeline_uuid: str) -> str:
- """向所有连接广播消息(后端主动推送)"""
- try:
- data = await quart.request.get_json()
- message = data.get('message')
-
- if not message:
- return self.http_status(400, -1, 'message is required')
-
- # 广播消息
- broadcast_data = {
- 'type': 'broadcast',
- 'message': message,
- 'timestamp': datetime.datetime.now().isoformat(),
+ scope = WebSocketScope.from_context(request_context)
+ stats = ws_connection_manager.get_stats(scope=scope)
+ connections = await ws_connection_manager.get_connections_by_pipeline(
+ pipeline_uuid,
+ scope=scope,
+ )
+ return self.success(
+ data={
+ 'stats': stats,
+ 'connections': [
+ {
+ 'connection_id': connection.connection_id,
+ 'session_type': connection.session_type,
+ 'created_at': connection.created_at.isoformat(),
+ 'last_active': connection.last_active.isoformat(),
+ 'is_active': connection.is_active,
+ }
+ for connection in connections
+ ],
}
+ )
- await ws_connection_manager.broadcast_to_pipeline(pipeline_uuid, broadcast_data)
+ @self.route(
+ '/broadcast',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RUNTIME_OPERATE,
+ )
+ async def broadcast_message(pipeline_uuid: str, request_context: RequestContext) -> str:
+ if await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid) is None:
+ return self.http_status(404, -1, 'Pipeline not found')
- return self.success(data={'message': 'Broadcast sent successfully'})
+ data = await quart.request.get_json()
+ message = data.get('message')
+ if not message:
+ return self.http_status(400, -1, 'message is required')
- except Exception as e:
- return self.http_status(500, -1, f'Internal server error: {str(e)}')
+ broadcast_data = {
+ 'type': 'broadcast',
+ 'message': message,
+ 'timestamp': datetime.datetime.now().isoformat(),
+ }
+ await ws_connection_manager.broadcast_to_pipeline(
+ pipeline_uuid,
+ broadcast_data,
+ scope=WebSocketScope.from_context(request_context),
+ )
+ return self.success(data={'message': 'Broadcast sent successfully'})
- async def _handle_receive(self, connection, websocket_adapter):
- """处理接收消息的任务"""
+ async def _handle_receive(
+ self,
+ connection,
+ websocket_adapter,
+ request_context: RequestContext,
+ token: str,
+ ):
try:
while connection.is_active:
- # 接收消息
message = await quart.websocket.receive()
-
- # 更新活跃时间
await ws_connection_manager.update_activity(connection.connection_id)
try:
- data = json.loads(message)
+ data = await asyncio.to_thread(json.loads, message)
message_type = data.get('type', 'message')
-
if message_type == 'ping':
- # 心跳响应
await connection.send_queue.put(
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
)
-
elif message_type == 'message':
- # 处理用户消息
- logger.debug(f'收到消息: {data} from {connection.connection_id}')
-
- # 处理消息(不等待响应,响应会通过broadcast异步发送)
- # owner_bot is intentionally NOT passed: the dashboard
- # debug WebSocket must always run under the proxy bot,
- # never under a coincidentally-bound web_page_bot.
+ try:
+ await self._revalidate_websocket_authorization(request_context, token)
+ except Exception:
+ await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
+ break
await websocket_adapter.handle_websocket_message(connection, data)
-
elif message_type == 'disconnect':
- # 客户端主动断开
- logger.debug(f'Client disconnected: {connection.connection_id}')
break
-
else:
- logger.warning(f'Unknown message type: {message_type}')
-
+ logger.warning(f'Unknown WebSocket message type: {message_type}')
except json.JSONDecodeError:
- logger.error(f'Invalid JSON message: {message}')
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
- except Exception as e:
- logger.error(f'Receive message error: {e}', exc_info=True)
+ except Exception:
+ logger.error('Dashboard WebSocket receive error', exc_info=True)
finally:
connection.is_active = False
+ try:
+ connection.send_queue.put_nowait(None)
+ except asyncio.QueueFull:
+ pass
async def _handle_send(self, connection):
- """处理发送消息的任务"""
try:
- while connection.is_active:
- # 从队列获取消息
+ while connection.is_active or not connection.send_queue.empty():
try:
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
-
- # 发送消息
- await quart.websocket.send(json.dumps(message))
-
+ if message is None:
+ break
+ encoded = await asyncio.to_thread(json.dumps, message)
+ await quart.websocket.send(encoded)
except asyncio.TimeoutError:
- # 超时继续循环
continue
-
- except Exception as e:
- logger.error(f'Send message error: {e}', exc_info=True)
+ except Exception:
+ logger.error('Dashboard WebSocket send error', exc_info=True)
finally:
connection.is_active = False
diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py
index 0e32f9d29..edfbf9615 100644
--- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py
+++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py
@@ -1,8 +1,133 @@
-import quart
-import mimetypes
import asyncio
+import dataclasses
+import mimetypes
+
+import quart
+
+from langbot.pkg.api.http.authz import Permission
+from langbot.pkg.api.http.context import RequestContext
+from langbot.pkg.core.errors import TaskCapacityError
+from langbot.pkg.utils import httpclient, importutil
+
from ... import group
-from langbot.pkg.utils import importutil
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class _AdapterSessionScope:
+ """Immutable tenant and principal binding for a credential exchange."""
+
+ instance_uuid: str
+ workspace_uuid: str
+ placement_generation: int
+ principal_type: str
+ account_uuid: str | None
+ api_key_uuid: str | None
+
+ @classmethod
+ def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
+ principal = request_context.principal
+ return cls(
+ instance_uuid=request_context.instance_uuid,
+ workspace_uuid=request_context.workspace_uuid,
+ placement_generation=request_context.placement_generation,
+ principal_type=principal.principal_type.value,
+ account_uuid=principal.account_uuid,
+ api_key_uuid=principal.api_key_uuid,
+ )
+
+ def matches(self, request_context: RequestContext) -> bool:
+ """Return whether a request is from the exact initiating tenant principal."""
+
+ return self == self.from_request_context(request_context)
+
+
+def _bind_session_scope(session: dict, request_context: RequestContext) -> None:
+ session['scope'] = _AdapterSessionScope.from_request_context(request_context)
+
+
+def _get_owned_session(
+ sessions: dict[str, dict],
+ session_id: str,
+ request_context: RequestContext,
+) -> dict | None:
+ """Resolve a session without revealing sessions owned by another scope."""
+
+ session = sessions.get(session_id)
+ scope = session.get('scope') if session is not None else None
+ if not isinstance(scope, _AdapterSessionScope) or not scope.matches(request_context):
+ return None
+ return session
+
+
+def _pop_owned_session(
+ sessions: dict[str, dict],
+ session_id: str,
+ request_context: RequestContext,
+) -> dict | None:
+ """Remove an owned session without allowing cross-scope cancellation."""
+
+ session = _get_owned_session(sessions, session_id, request_context)
+ if session is None:
+ return None
+ return sessions.pop(session_id, None)
+
+
+_MAX_ADAPTER_SESSIONS = 100
+_MAX_ADAPTER_SESSIONS_PER_WORKSPACE = 10
+
+
+def _start_adapter_session_task(
+ ap,
+ coro,
+ *,
+ adapter: str,
+ session_id: str,
+ request_context: RequestContext,
+) -> asyncio.Task | None:
+ """Attach one credential exchange to tenant admission and app shutdown."""
+
+ try:
+ wrapper = ap.task_mgr.create_user_task(
+ coro,
+ kind='platform-adapter-credential-exchange',
+ name=f'{adapter}-credential-{session_id}',
+ label=f'{adapter} credential exchange',
+ instance_uuid=request_context.instance_uuid,
+ workspace_uuid=request_context.workspace_uuid,
+ placement_generation=request_context.placement_generation,
+ )
+ except TaskCapacityError:
+ coro.close()
+ return None
+ return wrapper.task
+
+
+def _make_room_for_session(
+ sessions: dict[str, dict],
+ request_context: RequestContext,
+) -> None:
+ """Bound credential-exchange sessions globally and per workspace."""
+
+ workspace_uuid = request_context.workspace_uuid
+ owned = [
+ (session_id, session)
+ for session_id, session in sessions.items()
+ if getattr(session.get('scope'), 'workspace_uuid', None) == workspace_uuid
+ ]
+ evict_workspace_session = len(owned) >= _MAX_ADAPTER_SESSIONS_PER_WORKSPACE
+ evict_global_session = len(sessions) >= _MAX_ADAPTER_SESSIONS
+ if not evict_workspace_session and not evict_global_session:
+ return
+
+ candidates = owned if evict_workspace_session else list(sessions.items())
+ session_id, _ = min(
+ candidates,
+ key=lambda item: float(item[1].get('created_at', 0.0)),
+ )
+ session = sessions.pop(session_id, None)
+ task = session.get('task') if session is not None else None
+ if task is not None and not task.done():
+ task.cancel()
def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
@@ -84,8 +209,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
- @self.route('/lark/create-app', methods=['POST'])
- async def _() -> str:
+ @self.route('/lark/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
+ async def _(request_context: RequestContext) -> str:
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
import uuid
import time
@@ -106,6 +231,8 @@ class AdaptersRouterGroup(group.RouterGroup):
'error': None,
'created_at': time.time(),
}
+ _bind_session_scope(session, request_context)
+ _make_room_for_session(_create_app_sessions, request_context)
_create_app_sessions[session_id] = session
def on_qr_code(info):
@@ -137,7 +264,16 @@ class AdaptersRouterGroup(group.RouterGroup):
session['status'] = 'error'
session['error'] = str(e)
- task = asyncio.create_task(run_registration())
+ task = _start_adapter_session_task(
+ self.ap,
+ run_registration(),
+ adapter='lark',
+ session_id=session_id,
+ request_context=request_context,
+ )
+ if task is None:
+ _create_app_sessions.pop(session_id, None)
+ return self.http_status(429, -1, 'Too many active credential exchanges')
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
@@ -160,10 +296,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
- @self.route('/lark/create-app/status/', methods=['GET'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/lark/create-app/status/',
+ methods=['GET'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll registration status."""
- session = _create_app_sessions.get(session_id)
+ _cleanup_expired_sessions()
+ session = _get_owned_session(_create_app_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -179,10 +320,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
- @self.route('/lark/create-app/', methods=['DELETE'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/lark/create-app/',
+ methods=['DELETE'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a registration session."""
- session = _create_app_sessions.pop(session_id, None)
+ session = _pop_owned_session(_create_app_sessions, session_id, request_context)
+ if session is None:
+ return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -206,8 +353,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
- @self.route('/weixin/login', methods=['POST'])
- async def _() -> str:
+ @self.route('/weixin/login', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
+ async def _(request_context: RequestContext) -> str:
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
import uuid
import time
@@ -229,6 +376,8 @@ class AdaptersRouterGroup(group.RouterGroup):
'error': None,
'created_at': time.time(),
}
+ _bind_session_scope(session, request_context)
+ _make_room_for_session(_weixin_login_sessions, request_context)
_weixin_login_sessions[session_id] = session
client = OpenClawWeixinClient(
@@ -267,7 +416,16 @@ class AdaptersRouterGroup(group.RouterGroup):
finally:
await client.close()
- task = asyncio.create_task(run_login())
+ task = _start_adapter_session_task(
+ self.ap,
+ run_login(),
+ adapter='weixin',
+ session_id=session_id,
+ request_context=request_context,
+ )
+ if task is None:
+ _weixin_login_sessions.pop(session_id, None)
+ return self.http_status(429, -1, 'Too many active credential exchanges')
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
@@ -290,10 +448,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
- @self.route('/weixin/login/status/', methods=['GET'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/weixin/login/status/',
+ methods=['GET'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll WeChat login status."""
- session = _weixin_login_sessions.get(session_id)
+ _cleanup_expired_weixin_sessions()
+ session = _get_owned_session(_weixin_login_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -317,10 +480,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
- @self.route('/weixin/login/', methods=['DELETE'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/weixin/login/',
+ methods=['DELETE'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a WeChat login session."""
- session = _weixin_login_sessions.pop(session_id, None)
+ session = _pop_owned_session(_weixin_login_sessions, session_id, request_context)
+ if session is None:
+ return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -344,8 +513,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
- @self.route('/dingtalk/create-app', methods=['POST'])
- async def _() -> str:
+ @self.route('/dingtalk/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
+ async def _(request_context: RequestContext) -> str:
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
import uuid
import time
@@ -368,6 +537,8 @@ class AdaptersRouterGroup(group.RouterGroup):
'device_code': None,
'interval': 5,
}
+ _bind_session_scope(session, request_context)
+ _make_room_for_session(_dingtalk_sessions, request_context)
_dingtalk_sessions[session_id] = session
async def run_device_flow():
@@ -380,7 +551,7 @@ class AdaptersRouterGroup(group.RouterGroup):
json={'source': 'langbot'},
) as resp:
try:
- data = await resp.json()
+ data = await httpclient.read_json_limited(resp)
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from DingTalk service'
@@ -397,7 +568,7 @@ class AdaptersRouterGroup(group.RouterGroup):
json={'nonce': nonce},
) as resp:
try:
- data = await resp.json()
+ data = await httpclient.read_json_limited(resp)
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from DingTalk service'
@@ -428,7 +599,7 @@ class AdaptersRouterGroup(group.RouterGroup):
json={'device_code': device_code},
) as poll_resp:
try:
- poll_data = await poll_resp.json()
+ poll_data = await httpclient.read_json_limited(poll_resp)
except (aiohttp.ContentTypeError, ValueError):
continue
@@ -464,7 +635,16 @@ class AdaptersRouterGroup(group.RouterGroup):
session['status'] = 'error'
session['error'] = str(e)
- task = asyncio.create_task(run_device_flow())
+ task = _start_adapter_session_task(
+ self.ap,
+ run_device_flow(),
+ adapter='dingtalk',
+ session_id=session_id,
+ request_context=request_context,
+ )
+ if task is None:
+ _dingtalk_sessions.pop(session_id, None)
+ return self.http_status(429, -1, 'Too many active credential exchanges')
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
@@ -491,11 +671,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
- @self.route('/dingtalk/create-app/status/', methods=['GET'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/dingtalk/create-app/status/',
+ methods=['GET'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll DingTalk Device Flow status."""
_cleanup_expired_dingtalk_sessions()
- session = _dingtalk_sessions.get(session_id)
+ session = _get_owned_session(_dingtalk_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -511,10 +695,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
- @self.route('/dingtalk/create-app/', methods=['DELETE'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/dingtalk/create-app/',
+ methods=['DELETE'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a DingTalk Device Flow session."""
- session = _dingtalk_sessions.pop(session_id, None)
+ session = _pop_owned_session(_dingtalk_sessions, session_id, request_context)
+ if session is None:
+ return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -538,8 +728,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
- @self.route('/wecombot/create-bot', methods=['POST'])
- async def _() -> str:
+ @self.route('/wecombot/create-bot', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
+ async def _(request_context: RequestContext) -> str:
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
import uuid
import time
@@ -563,6 +753,8 @@ class AdaptersRouterGroup(group.RouterGroup):
'scode': None,
'task': None,
}
+ _bind_session_scope(session, request_context)
+ _make_room_for_session(_wecombot_sessions, request_context)
_wecombot_sessions[session_id] = session
async def run_qr_flow():
@@ -574,7 +766,7 @@ class AdaptersRouterGroup(group.RouterGroup):
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
) as resp:
try:
- data = await resp.json()
+ data = await httpclient.read_json_limited(resp)
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from WeCom service'
@@ -601,7 +793,7 @@ class AdaptersRouterGroup(group.RouterGroup):
f'{WECOM_QC_QUERY_URL}?scode={scode}',
) as poll_resp:
try:
- poll_data = await poll_resp.json()
+ poll_data = await httpclient.read_json_limited(poll_resp)
except (aiohttp.ContentTypeError, ValueError):
continue
@@ -628,7 +820,16 @@ class AdaptersRouterGroup(group.RouterGroup):
session['status'] = 'error'
session['error'] = str(e)
- task = asyncio.create_task(run_qr_flow())
+ task = _start_adapter_session_task(
+ self.ap,
+ run_qr_flow(),
+ adapter='wecombot',
+ session_id=session_id,
+ request_context=request_context,
+ )
+ if task is None:
+ _wecombot_sessions.pop(session_id, None)
+ return self.http_status(429, -1, 'Too many active credential exchanges')
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
@@ -655,11 +856,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
- @self.route('/wecombot/create-bot/status/', methods=['GET'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/wecombot/create-bot/status/',
+ methods=['GET'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll WeComBot creation status."""
_cleanup_expired_wecombot_sessions()
- session = _wecombot_sessions.get(session_id)
+ session = _get_owned_session(_wecombot_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -675,10 +880,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
- @self.route('/wecombot/create-bot/', methods=['DELETE'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/wecombot/create-bot/',
+ methods=['DELETE'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a WeComBot creation session."""
- session = _wecombot_sessions.pop(session_id, None)
+ session = _pop_owned_session(_wecombot_sessions, session_id, request_context)
+ if session is None:
+ return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
@@ -702,8 +913,8 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
- @self.route('/qqofficial/bind', methods=['POST'])
- async def _() -> str:
+ @self.route('/qqofficial/bind', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
+ async def _(request_context: RequestContext) -> str:
"""Start QQ Official QR binding. Returns session_id + QR URL.
Flow: generate a local AES-256 key, register it with
@@ -739,6 +950,8 @@ class AdaptersRouterGroup(group.RouterGroup):
'bind_key_bytes': bind_key_bytes,
'interval': 2,
}
+ _bind_session_scope(session, request_context)
+ _make_room_for_session(_qqofficial_sessions, request_context)
_qqofficial_sessions[session_id] = session
async def run_qr_binding():
@@ -752,7 +965,7 @@ class AdaptersRouterGroup(group.RouterGroup):
headers={'Accept': 'application/json'},
) as resp:
try:
- data = await resp.json(content_type=None)
+ data = await httpclient.read_json_limited(resp)
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from QQ bind service'
@@ -790,7 +1003,7 @@ class AdaptersRouterGroup(group.RouterGroup):
headers={'Accept': 'application/json'},
) as poll_resp:
try:
- poll_data = await poll_resp.json(content_type=None)
+ poll_data = await httpclient.read_json_limited(poll_resp)
except (aiohttp.ContentTypeError, ValueError):
continue
@@ -843,7 +1056,16 @@ class AdaptersRouterGroup(group.RouterGroup):
session['status'] = 'error'
session['error'] = str(e)
- task = asyncio.create_task(run_qr_binding())
+ task = _start_adapter_session_task(
+ self.ap,
+ run_qr_binding(),
+ adapter='qqofficial',
+ session_id=session_id,
+ request_context=request_context,
+ )
+ if task is None:
+ _qqofficial_sessions.pop(session_id, None)
+ return self.http_status(429, -1, 'Too many active credential exchanges')
session['task'] = task
# Wait up to 10s for the QR URL to be ready before responding.
@@ -870,11 +1092,15 @@ class AdaptersRouterGroup(group.RouterGroup):
}
)
- @self.route('/qqofficial/bind/status/', methods=['GET'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/qqofficial/bind/status/',
+ methods=['GET'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Poll QQ Official QR binding status."""
_cleanup_expired_qqofficial_sessions()
- session = _qqofficial_sessions.get(session_id)
+ session = _get_owned_session(_qqofficial_sessions, session_id, request_context)
if not session:
return self.http_status(404, -1, 'Session not found')
@@ -892,10 +1118,16 @@ class AdaptersRouterGroup(group.RouterGroup):
return self.success(data=data)
- @self.route('/qqofficial/bind/', methods=['DELETE'])
- async def _(session_id: str) -> str:
+ @self.route(
+ '/qqofficial/bind/',
+ methods=['DELETE'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(session_id: str, request_context: RequestContext) -> str:
"""Cancel and clean up a QQ Official QR binding session."""
- session = _qqofficial_sessions.pop(session_id, None)
+ session = _pop_owned_session(_qqofficial_sessions, session_id, request_context)
+ if session is None:
+ return self.http_status(404, -1, 'Session not found')
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py
index e3a13b789..55867f189 100644
--- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py
+++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py
@@ -1,45 +1,95 @@
import quart
+from sqlalchemy.exc import IntegrityError
+from ....authz import Permission, has_permission
+from ....context import RequestContext
from ... import group
@group.group_class('bots', '/api/v1/platform/bots')
class BotsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
- if quart.request.method == 'GET':
- return self.success(data={'bots': await self.ap.bot_service.get_bots()})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- bot_uuid = await self.ap.bot_service.create_bot(json_data)
- return self.success(data={'uuid': bot_uuid})
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
+ return self.success(
+ data={
+ 'bots': await self.ap.bot_service.get_bots(
+ request_context,
+ include_secret=include_secret,
+ )
+ }
+ )
- @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(bot_uuid: str) -> str:
- if quart.request.method == 'GET':
- bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
- if bot is None:
- return self.http_status(404, -1, 'bot not found')
- return self.success(data={'bot': bot})
- elif quart.request.method == 'PUT':
- json_data = await quart.request.json
- await self.ap.bot_service.update_bot(bot_uuid, json_data)
- return self.success()
- elif quart.request.method == 'DELETE':
- await self.ap.bot_service.delete_bot(bot_uuid)
- return self.success()
+ @self.route(
+ '',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ json_data = await quart.request.json
+ bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data)
+ return self.success(data={'uuid': bot_uuid})
- @self.route('//logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(bot_uuid: str) -> str:
+ @self.route(
+ '/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(bot_uuid: str, request_context: RequestContext) -> str:
+ include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
+ bot = await self.ap.bot_service.get_runtime_bot_info(
+ request_context,
+ bot_uuid,
+ include_secret=include_secret,
+ )
+ if bot is None:
+ return self.http_status(404, -1, 'bot not found')
+ return self.success(data={'bot': bot})
+
+ @self.route(
+ '/',
+ methods=['PUT', 'DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(bot_uuid: str, request_context: RequestContext) -> str:
+ if quart.request.method == 'PUT':
+ json_data = await quart.request.json
+ await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data)
+ else:
+ await self.ap.bot_service.delete_bot(request_context, bot_uuid)
+ return self.success()
+
+ @self.route(
+ '//logs',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
from_index = json_data.get('from_index', -1)
max_count = json_data.get('max_count', 10)
- logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
+ logs, total_count = await self.ap.bot_service.list_event_logs(
+ request_context, bot_uuid, from_index, max_count
+ )
return self.success(data={'logs': logs, 'total_count': total_count})
- @self.route('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
- async def _(bot_uuid: str) -> str:
+ @self.route(
+ '//send_message',
+ methods=['POST'],
+ auth_type=group.AuthType.API_KEY,
+ permission=Permission.RUNTIME_OPERATE,
+ )
+ async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
target_type = json_data.get('target_type')
target_id = json_data.get('target_id')
@@ -54,37 +104,51 @@ class BotsRouterGroup(group.RouterGroup):
if target_type not in ['person', 'group']:
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
- try:
- await self.ap.bot_service.send_message(bot_uuid, target_type, target_id, message_chain_data)
- return self.success(data={'sent': True})
- except Exception as e:
- import traceback
-
- traceback.print_exc()
- return self.http_status(500, -1, f'Failed to send message: {str(e)}')
-
- # ============ Bot Admins ============
-
- @self.route('//admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(bot_uuid: str) -> str:
- if quart.request.method == 'GET':
- admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
- return self.success(data={'admins': admins})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- launcher_type = json_data.get('launcher_type', '').strip()
- launcher_id = str(json_data.get('launcher_id', '')).strip()
- if not launcher_type or not launcher_id:
- return self.http_status(400, -1, 'launcher_type and launcher_id are required')
- try:
- admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
- return self.success(data={'id': admin_id})
- except Exception as e:
- return self.http_status(409, -1, str(e))
+ await self.ap.bot_service.send_message(
+ request_context,
+ bot_uuid,
+ target_type,
+ target_id,
+ message_chain_data,
+ )
+ return self.success(data={'sent': True})
@self.route(
- '//admins/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
+ '//admins',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _(bot_uuid: str, admin_id: int) -> str:
- await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
+ async def _(bot_uuid: str, request_context: RequestContext) -> str:
+ admins = await self.ap.bot_service.get_bot_admins(request_context, bot_uuid)
+ return self.success(data={'admins': admins})
+
+ @self.route(
+ '//admins',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(bot_uuid: str, request_context: RequestContext) -> str:
+ json_data = await quart.request.json
+ launcher_type = json_data.get('launcher_type', '').strip()
+ launcher_id = str(json_data.get('launcher_id', '')).strip()
+ if not launcher_type or not launcher_id:
+ return self.http_status(400, -1, 'launcher_type and launcher_id are required')
+ try:
+ admin_id = await self.ap.bot_service.add_bot_admin(
+ request_context, bot_uuid, launcher_type, launcher_id
+ )
+ return self.success(data={'id': admin_id})
+ except IntegrityError as e:
+ return self.http_status(409, -1, str(e))
+
+ @self.route(
+ '//admins/',
+ methods=['DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(bot_uuid: str, admin_id: int, request_context: RequestContext) -> str:
+ await self.ap.bot_service.delete_bot_admin(request_context, bot_uuid, admin_id)
return self.success()
diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py
index c291c1232..bbcda6c6b 100644
--- a/src/langbot/pkg/api/http/controller/groups/plugins.py
+++ b/src/langbot/pkg/api/http/controller/groups/plugins.py
@@ -1,23 +1,158 @@
from __future__ import annotations
+import asyncio
import base64
-import io
+import collections.abc
+import copy
import quart
import re
import httpx
import uuid
import os
import zipfile
-import yaml
from urllib.parse import urlparse
import posixpath
import sqlalchemy
from .....core import taskmgr
+from .....core.task_boundary import run_in_workspace_uow
from .....entity.persistence import plugin as persistence_plugin
+from ...authz import Permission
+from ...context import ExecutionContext, RequestContext
from .. import group
+from .....workspace.errors import WorkspaceNotFoundError
+from .....plugin.github import validate_github_plugin_install_info
+from .....plugin.archive import inspect_plugin_archive_metadata
+from .....utils import httpclient
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
+
+_SECRET_MASK = '***'
+_MISSING_SECRET = object()
+_SENSITIVE_CONFIG_NAMES = frozenset(
+ {
+ 'api_key',
+ 'apikey',
+ 'auth',
+ 'authorization',
+ 'cookie',
+ 'credentials',
+ 'database_url',
+ 'dsn',
+ 'key',
+ 'proxy_authorization',
+ 'set_cookie',
+ }
+)
+_SENSITIVE_CONFIG_TOKENS = frozenset(
+ {
+ 'credential',
+ 'credentials',
+ 'passwd',
+ 'password',
+ 'secret',
+ 'token',
+ }
+)
+_SENSITIVE_KEY_QUALIFIERS = frozenset(
+ {
+ 'access',
+ 'api',
+ 'auth',
+ 'bearer',
+ 'client',
+ 'debug',
+ 'encryption',
+ 'private',
+ 'signing',
+ }
+)
+
+
+def _normalize_config_key(key: object) -> str:
+ value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
+ return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
+
+
+def _is_sensitive_config_key(key: object) -> bool:
+ normalized = _normalize_config_key(key)
+ if normalized in _SENSITIVE_CONFIG_NAMES:
+ return True
+ tokens = frozenset(token for token in normalized.split('_') if token)
+ if tokens & _SENSITIVE_CONFIG_TOKENS:
+ return True
+ return 'key' in tokens and bool(tokens & _SENSITIVE_KEY_QUALIFIERS)
+
+
+def _mask_secret_structure(value):
+ """Mask every non-empty leaf while preserving container structure."""
+
+ if isinstance(value, dict):
+ return {key: _mask_secret_structure(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [_mask_secret_structure(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(_mask_secret_structure(item) for item in value)
+ if value is None or value == '':
+ return value
+ return _SECRET_MASK
+
+
+def redact_plugin_secrets(value):
+ """Return a recursively redacted copy of plugin-facing data."""
+
+ if isinstance(value, dict):
+ return {
+ key: (_mask_secret_structure(item) if _is_sensitive_config_key(key) else redact_plugin_secrets(item))
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [redact_plugin_secrets(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_plugin_secrets(item) for item in value)
+ return value
+
+
+def restore_plugin_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
+ """Restore masked leaves from the current config before a management write."""
+
+ if sensitive and value == _SECRET_MASK:
+ if current_value is _MISSING_SECRET:
+ raise ValueError('Masked plugin secret has no existing value')
+ return copy.deepcopy(current_value)
+ if isinstance(value, dict):
+ current_mapping = current_value if isinstance(current_value, dict) else {}
+ return {
+ key: restore_plugin_secret_placeholders(
+ item,
+ current_mapping.get(key, _MISSING_SECRET),
+ sensitive=sensitive or _is_sensitive_config_key(key),
+ )
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ current_items = current_value if isinstance(current_value, (list, tuple)) else ()
+ return [
+ restore_plugin_secret_placeholders(
+ item,
+ current_items[index] if index < len(current_items) else _MISSING_SECRET,
+ sensitive=sensitive,
+ )
+ for index, item in enumerate(value)
+ ]
+ if isinstance(value, tuple):
+ current_items = current_value if isinstance(current_value, (list, tuple)) else ()
+ return tuple(
+ restore_plugin_secret_placeholders(
+ item,
+ current_items[index] if index < len(current_items) else _MISSING_SECRET,
+ sensitive=sensitive,
+ )
+ for index, item in enumerate(value)
+ )
+ return value
+
+
# Resolve the built-in page SDK JS from the langbot_plugin package
_PAGE_SDK_PATH = None
try:
@@ -148,18 +283,78 @@ class PluginsRouterGroup(group.RouterGroup):
'subdir': subdir,
}
- async def _check_extensions_limit(self) -> str | None:
+ async def _check_extensions_limit(self, request_context: RequestContext) -> str | None:
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
+ await self.ap.plugin_connector.require_workspace_context(request_context)
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_extensions = limitation.get('max_extensions', -1)
if max_extensions >= 0:
plugins = await self.ap.plugin_connector.list_plugins()
- mcp_servers = await self.ap.mcp_service.get_mcp_servers()
+ mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context)
total_extensions = len(plugins) + len(mcp_servers)
if total_extensions >= max_extensions:
return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached')
return None
+ @staticmethod
+ def _task_scope(request_context: RequestContext) -> dict[str, str | int]:
+ return {
+ 'instance_uuid': request_context.instance_uuid,
+ 'workspace_uuid': request_context.workspace_uuid,
+ 'placement_generation': request_context.placement_generation,
+ }
+
+ async def _run_fenced_plugin_operation(
+ self,
+ execution_context: ExecutionContext,
+ operation: collections.abc.Callable[[], collections.abc.Awaitable],
+ ):
+ """Revalidate a captured task context immediately before Runtime I/O."""
+
+ await run_in_workspace_uow(
+ self.ap,
+ execution_context.workspace_uuid,
+ lambda: self.ap.plugin_connector.require_workspace_context(execution_context),
+ )
+ return await operation()
+
+ async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
+ """Resolve public assets only for the OSS singleton Workspace.
+
+ Public image and iframe requests cannot carry the WebUI bearer token.
+ They therefore remain available for the one-Workspace Core deployment,
+ but fail closed instead of guessing a Workspace when multi-Workspace
+ policy is active.
+ """
+
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ policy = getattr(workspace_service, 'policy', None)
+ if workspace_service is None or policy is None or getattr(policy, 'multi_workspace_enabled', False):
+ raise WorkspaceNotFoundError('Plugin resource not found')
+ binding = await workspace_service.get_local_execution_binding()
+ execution_context = ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ )
+ return await self.ap.plugin_connector.require_workspace_context(execution_context)
+
+ async def _get_stored_plugin_config(
+ self,
+ request_context: RequestContext,
+ author: str,
+ plugin_name: str,
+ plugin: dict,
+ ):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_plugin.PluginSetting.config)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == request_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.plugin_author == author)
+ .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ )
+ persisted_config = result.scalar_one_or_none()
+ return persisted_config if persisted_config is not None else plugin['plugin_config']
+
async def initialize(self) -> None:
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> quart.Response:
@@ -170,15 +365,27 @@ class PluginsRouterGroup(group.RouterGroup):
return quart.Response(content, mimetype='application/javascript')
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
- @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
plugins = await self.ap.plugin_connector.list_plugins()
- return self.success(data={'plugins': plugins})
+ return self.success(data={'plugins': redact_plugin_secrets(plugins)})
- @self.route('/debug-info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
+ @self.route(
+ '/debug-info',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
"""Get plugin debug information including debug URL and key"""
+ await self.ap.plugin_connector.require_workspace_context(request_context)
debug_info = await self.ap.plugin_connector.get_debug_info()
# Get debug URL from config
@@ -196,77 +403,121 @@ class PluginsRouterGroup(group.RouterGroup):
'///upgrade',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
)
- async def _(author: str, plugin_name: str) -> str:
+ async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
+ execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
ctx = taskmgr.TaskContext.new()
wrapper = self.ap.task_mgr.create_user_task(
- self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx),
+ self._run_fenced_plugin_operation(
+ execution_context,
+ lambda: self.ap.plugin_connector.upgrade_plugin(author, plugin_name, task_context=ctx),
+ ),
kind='plugin-operation',
name=f'plugin-upgrade-{plugin_name}',
label=f'Upgrading plugin {plugin_name}',
context=ctx,
+ **self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
@self.route(
'//',
- methods=['GET', 'DELETE'],
+ methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _(author: str, plugin_name: str) -> str:
- if quart.request.method == 'GET':
- plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
- if plugin is None:
- return self.http_status(404, -1, 'plugin not found')
- return self.success(data={'plugin': plugin})
- elif quart.request.method == 'DELETE':
- delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
- ctx = taskmgr.TaskContext.new()
- wrapper = self.ap.task_mgr.create_user_task(
- self.ap.plugin_connector.delete_plugin(
- author, plugin_name, delete_data=delete_data, task_context=ctx
- ),
- kind='plugin-operation',
- name=f'plugin-remove-{plugin_name}',
- label=f'Removing plugin {plugin_name}',
- context=ctx,
- )
+ async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
+ plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
+ if plugin is None:
+ return self.http_status(404, -1, 'plugin not found')
+ return self.success(data={'plugin': redact_plugin_secrets(plugin)})
- return self.success(data={'task_id': wrapper.id})
+ @self.route(
+ '//',
+ methods=['DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
+ execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
+ delete_data = quart.request.args.get('delete_data', 'false').lower() == 'true'
+ ctx = taskmgr.TaskContext.new()
+ wrapper = self.ap.task_mgr.create_user_task(
+ self._run_fenced_plugin_operation(
+ execution_context,
+ lambda: self.ap.plugin_connector.delete_plugin(
+ author,
+ plugin_name,
+ delete_data=delete_data,
+ task_context=ctx,
+ ),
+ ),
+ kind='plugin-operation',
+ name=f'plugin-remove-{plugin_name}',
+ label=f'Removing plugin {plugin_name}',
+ context=ctx,
+ **self._task_scope(request_context),
+ )
+ return self.success(data={'task_id': wrapper.id})
@self.route(
'///config',
- methods=['GET', 'PUT'],
+ methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _(author: str, plugin_name: str) -> quart.Response:
+ async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
if plugin is None:
return self.http_status(404, -1, 'plugin not found')
- if quart.request.method == 'GET':
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_plugin.PluginSetting.config)
- .where(persistence_plugin.PluginSetting.plugin_author == author)
- .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ config = await self._get_stored_plugin_config(
+ request_context,
+ author,
+ plugin_name,
+ plugin,
+ )
+ return self.success(data={'config': redact_plugin_secrets(config)})
+
+ @self.route(
+ '///config',
+ methods=['PUT'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
+ plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
+ if plugin is None:
+ return self.http_status(404, -1, 'plugin not found')
+ current_config = await self._get_stored_plugin_config(
+ request_context,
+ author,
+ plugin_name,
+ plugin,
+ )
+ try:
+ config = restore_plugin_secret_placeholders(
+ await quart.request.json,
+ current_config,
)
- persisted_config = result.scalar_one_or_none()
-
- config = persisted_config if persisted_config is not None else plugin['plugin_config']
- return self.success(data={'config': config})
- elif quart.request.method == 'PUT':
- data = await quart.request.json
-
- await self.ap.plugin_connector.set_plugin_config(author, plugin_name, data)
-
- return self.success(data={})
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ await self.ap.plugin_connector.require_workspace_context(request_context)
+ await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
+ return self.success(data={})
@self.route(
'///readme',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _(author: str, plugin_name: str) -> quart.Response:
+ async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
language = quart.request.args.get('language', 'en')
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
return self.success(data={'readme': readme})
@@ -275,8 +526,10 @@ class PluginsRouterGroup(group.RouterGroup):
'///logs',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.AUDIT_VIEW,
)
- async def _(author: str, plugin_name: str) -> quart.Response:
+ async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
try:
limit = int(quart.request.args.get('limit', 200))
except (TypeError, ValueError):
@@ -291,11 +544,12 @@ class PluginsRouterGroup(group.RouterGroup):
auth_type=group.AuthType.NONE,
)
async def _(author: str, plugin_name: str) -> quart.Response:
+ await self._require_public_plugin_runtime_context()
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
icon_base64 = icon_data['plugin_icon_base64']
mime_type = icon_data['mime_type']
- icon_data = base64.b64decode(icon_base64)
+ icon_data = await asyncio.to_thread(base64.b64decode, icon_base64)
return quart.Response(icon_data, mimetype=mime_type)
@@ -305,6 +559,7 @@ class PluginsRouterGroup(group.RouterGroup):
auth_type=group.AuthType.NONE,
)
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
+ await self._require_public_plugin_runtime_context()
asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
@@ -312,7 +567,10 @@ class PluginsRouterGroup(group.RouterGroup):
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
if not asset_data.get('asset_base64'):
return quart.Response('Asset not found', status=404)
- asset_bytes = base64.b64decode(asset_data['asset_base64'])
+ asset_bytes = await asyncio.to_thread(
+ base64.b64decode,
+ asset_data['asset_base64'],
+ )
mime_type = asset_data['mime_type']
resp = quart.Response(asset_bytes, mimetype=mime_type)
# CSP for HTML pages served to sandboxed iframes (opaque origin).
@@ -334,9 +592,11 @@ class PluginsRouterGroup(group.RouterGroup):
'///page-api',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
)
- async def _(author: str, plugin_name: str) -> str:
+ async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
"""Forward a page API request to the plugin."""
+ await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body')
@@ -357,9 +617,15 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, result['error'])
return self.success(data=result.get('data'))
- @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
+ @self.route(
+ '/github/releases',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
"""Get releases from a GitHub repository URL"""
+ await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
repo_url = data.get('repo_url', '')
@@ -400,10 +666,11 @@ class PluginsRouterGroup(group.RouterGroup):
trust_env=True,
follow_redirects=True,
timeout=10,
+ event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.get(url)
response.raise_for_status()
- releases = response.json()
+ releases = await httpclient.parse_json_response(response)
# Format releases data for frontend
formatted_releases = []
@@ -427,16 +694,18 @@ class PluginsRouterGroup(group.RouterGroup):
'source_subdir': requested_subdir,
}
)
- except httpx.RequestError as e:
- return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}')
+ except httpx.RequestError:
+ raise
@self.route(
'/github/release-assets',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _() -> str:
+ async def _(request_context: RequestContext) -> str:
"""Get assets from a specific GitHub release"""
+ await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
owner = data.get('owner', '')
repo = data.get('repo', '')
@@ -452,12 +721,13 @@ class PluginsRouterGroup(group.RouterGroup):
trust_env=True,
follow_redirects=True,
timeout=10,
+ event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
response = await client.get(
url,
)
response.raise_for_status()
- release = response.json()
+ release = await httpclient.parse_json_response(response)
# Format assets data for frontend
formatted_assets = []
@@ -484,42 +754,61 @@ class PluginsRouterGroup(group.RouterGroup):
# )
return self.success(data={'assets': formatted_assets})
- except httpx.RequestError as e:
- return self.http_status(500, -1, f'Failed to fetch release assets: {str(e)}')
+ except httpx.RequestError:
+ raise
- @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
+ @self.route(
+ '/install/github',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
"""Install plugin from GitHub release asset"""
- limit_error = await self._check_extensions_limit()
+ limit_error = await self._check_extensions_limit(request_context)
if limit_error is not None:
return limit_error
- data = await quart.request.json
- asset_url = data.get('asset_url', '')
- owner = data.get('owner', '')
- repo = data.get('repo', '')
- release_tag = data.get('release_tag', '')
+ data = await quart.request.json or {}
+ try:
+ install_info = validate_github_plugin_install_info(
+ {
+ 'asset_url': data.get('asset_url'),
+ 'asset_id': data.get('asset_id'),
+ 'release_id': data.get('release_id'),
+ 'owner': data.get('owner'),
+ 'repo': data.get('repo'),
+ 'release_tag': data.get('release_tag'),
+ 'github_url': f'https://github.com/{data.get("owner", "")}/{data.get("repo", "")}',
+ }
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
- if not asset_url:
- return self.http_status(400, -1, 'Missing asset_url parameter')
+ owner = install_info['owner']
+ repo = install_info['repo']
+ release_tag = install_info['release_tag']
+
+ execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
ctx.metadata['install_source'] = 'github'
- install_info = {
- 'asset_url': asset_url,
- 'owner': owner,
- 'repo': repo,
- 'release_tag': release_tag,
- 'github_url': f'https://github.com/{owner}/{repo}',
- }
wrapper = self.ap.task_mgr.create_user_task(
- self.ap.plugin_connector.install_plugin(PluginInstallSource.GITHUB, install_info, task_context=ctx),
+ self._run_fenced_plugin_operation(
+ execution_context,
+ lambda: self.ap.plugin_connector.install_plugin(
+ PluginInstallSource.GITHUB,
+ install_info,
+ task_context=ctx,
+ ),
+ ),
kind='plugin-operation',
name='plugin-install-github',
label=f'Installing plugin from GitHub {owner}/{repo}@{release_tag}',
context=ctx,
+ **self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
@@ -528,9 +817,10 @@ class PluginsRouterGroup(group.RouterGroup):
'/install/marketplace',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
)
- async def _() -> str:
- limit_error = await self._check_extensions_limit()
+ async def _(request_context: RequestContext) -> str:
+ limit_error = await self._check_extensions_limit(request_context)
if limit_error is not None:
return limit_error
@@ -538,23 +828,37 @@ class PluginsRouterGroup(group.RouterGroup):
plugin_author = data.get('plugin_author', '')
plugin_name = data.get('plugin_name', '')
+ execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
ctx = taskmgr.TaskContext.new()
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
ctx.metadata['install_source'] = 'marketplace'
wrapper = self.ap.task_mgr.create_user_task(
- self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
+ self._run_fenced_plugin_operation(
+ execution_context,
+ lambda: self.ap.plugin_connector.install_plugin(
+ PluginInstallSource.MARKETPLACE,
+ data,
+ task_context=ctx,
+ ),
+ ),
kind='plugin-operation',
name='plugin-install-marketplace',
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
context=ctx,
+ **self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
- @self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
- limit_error = await self._check_extensions_limit()
+ @self.route(
+ '/install/local',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ limit_error = await self._check_extensions_limit(request_context)
if limit_error is not None:
return limit_error
@@ -563,6 +867,7 @@ class PluginsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'file is required')
file_bytes = file.read()
+ execution_context = await self.ap.plugin_connector.require_workspace_context(request_context)
data = {
'plugin_file': file_bytes,
@@ -572,74 +877,72 @@ class PluginsRouterGroup(group.RouterGroup):
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
ctx.metadata['install_source'] = 'local'
wrapper = self.ap.task_mgr.create_user_task(
- self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
+ self._run_fenced_plugin_operation(
+ execution_context,
+ lambda: self.ap.plugin_connector.install_plugin(
+ PluginInstallSource.LOCAL,
+ data,
+ task_context=ctx,
+ ),
+ ),
kind='plugin-operation',
name='plugin-install-local',
label=f'Installing plugin from local {file.filename}',
context=ctx,
+ **self._task_scope(request_context),
)
return self.success(data={'task_id': wrapper.id})
- @self.route('/install/local/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
+ @self.route(
+ '/install/local/preview',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
file_bytes = file.read()
try:
- with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
- names = [name for name in zf.namelist() if not name.endswith('/')]
- manifest_name = next(
- (
- name
- for name in names
- if name.replace('\\', '/').strip('/').lower() in ('manifest.yaml', 'manifest.yml')
- ),
- None,
- )
- if manifest_name is None:
- return self.http_status(400, -1, 'manifest.yaml is required')
+ manifest, requirements, names = await asyncio.to_thread(
+ inspect_plugin_archive_metadata,
+ file_bytes,
+ )
+ spec = manifest.get('spec') or {}
+ components = spec.get('components') or {}
+ component_counts = self._count_plugin_components(components, names)
+ component_types = list(component_counts.keys())
- manifest = yaml.safe_load(zf.read(manifest_name).decode('utf-8')) or {}
- requirements: list[str] = []
- requirements_name = next(
- (name for name in names if name.replace('\\', '/').strip('/').lower() == 'requirements.txt'),
- None,
- )
- if requirements_name is not None:
- requirements = [
- line.strip()
- for line in zf.read(requirements_name).decode('utf-8', errors='ignore').splitlines()
- if line.strip() and not line.strip().startswith('#')
- ]
+ return self.success(
+ data={
+ 'filename': file.filename or 'local plugin',
+ 'size': len(file_bytes),
+ 'manifest': manifest,
+ 'metadata': manifest.get('metadata') or {},
+ 'component_types': component_types,
+ 'component_counts': component_counts,
+ 'requirements': requirements,
+ 'file_count': len(names),
+ }
+ )
+ except (zipfile.BadZipFile, ValueError) as exc:
+ return self.http_status(400, -1, str(exc) or 'invalid .lbpkg file')
+ except Exception:
+ raise
- spec = manifest.get('spec') or {}
- components = spec.get('components') or {}
- component_counts = self._count_plugin_components(components, names)
- component_types = list(component_counts.keys())
-
- return self.success(
- data={
- 'filename': file.filename or 'local plugin',
- 'size': len(file_bytes),
- 'manifest': manifest,
- 'metadata': manifest.get('metadata') or {},
- 'component_types': component_types,
- 'component_counts': component_counts,
- 'requirements': requirements,
- 'file_count': len(names),
- }
- )
- except zipfile.BadZipFile:
- return self.http_status(400, -1, 'invalid .lbpkg file')
- except Exception as exc:
- return self.http_status(500, -1, f'Failed to preview plugin package: {exc}')
-
- @self.route('/config-files', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
+ @self.route(
+ '/config-files',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
"""Upload a file for plugin configuration"""
+ await self.ap.plugin_connector.require_workspace_context(request_context)
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
@@ -650,25 +953,37 @@ class PluginsRouterGroup(group.RouterGroup):
if len(file_bytes) > MAX_FILE_SIZE:
return self.http_status(400, -1, 'file size exceeds 10MB limit')
- # Generate unique file key with original extension
- original_filename = file.filename
+ original_filename = file.filename or 'config.bin'
_, ext = os.path.splitext(original_filename)
- file_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
-
- # Save file using storage manager
- await self.ap.storage_mgr.storage_provider.save(file_key, file_bytes)
+ logical_key = f'plugin_config_{uuid.uuid4().hex}{ext}'
+ file_key = await self.ap.storage_mgr.save_scoped(
+ request_context,
+ owner_type='plugin_config',
+ owner=request_context.workspace_uuid,
+ key=logical_key,
+ value=file_bytes,
+ )
return self.success(data={'file_key': file_key})
- @self.route('/config-files/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
- async def _(file_key: str) -> str:
+ @self.route(
+ '/config-files/',
+ methods=['DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(file_key: str, request_context: RequestContext) -> str:
"""Delete a plugin configuration file"""
- # Only allow deletion of files with plugin_config_ prefix for security
- if not file_key.startswith('plugin_config_'):
+ await self.ap.plugin_connector.require_workspace_context(request_context)
+ if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'):
return self.http_status(400, -1, 'invalid file key')
try:
- await self.ap.storage_mgr.storage_provider.delete(file_key)
+ await self.ap.storage_mgr.delete_scoped_object_key(
+ request_context,
+ file_key,
+ expected_owner_type='plugin_config',
+ )
return self.success(data={'deleted': True})
- except Exception as e:
- return self.http_status(500, -1, f'failed to delete file: {str(e)}')
+ except Exception:
+ raise
diff --git a/src/langbot/pkg/api/http/controller/groups/provider/models.py b/src/langbot/pkg/api/http/controller/groups/provider/models.py
index f683c98fc..236000d9f 100644
--- a/src/langbot/pkg/api/http/controller/groups/provider/models.py
+++ b/src/langbot/pkg/api/http/controller/groups/provider/models.py
@@ -1,147 +1,292 @@
import quart
+from ....authz import Permission, has_permission
+from ....context import RequestContext
from ... import group
@group.group_class('models/llm', '/api/v1/provider/models/llm')
class LLMModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
- if quart.request.method == 'GET':
- provider_uuid = quart.request.args.get('provider_uuid')
- if provider_uuid:
- return self.success(
- data={'models': await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)}
- )
- return self.success(data={'models': await self.ap.llm_model_service.get_llm_models()})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- model_uuid = await self.ap.llm_model_service.create_llm_model(json_data)
- return self.success(data={'uuid': model_uuid})
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ provider_uuid = quart.request.args.get('provider_uuid')
+ include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
+ if provider_uuid:
+ models = await self.ap.llm_model_service.get_llm_models_by_provider(
+ request_context,
+ provider_uuid,
+ include_secret=include_secret,
+ )
+ else:
+ models = await self.ap.llm_model_service.get_llm_models(
+ request_context,
+ include_secret=include_secret,
+ )
+ return self.success(data={'models': models})
- @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(model_uuid: str) -> str:
- if quart.request.method == 'GET':
- model = await self.ap.llm_model_service.get_llm_model(model_uuid)
+ @self.route(
+ '',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ try:
+ model_uuid = await self.ap.llm_model_service.create_llm_model(
+ request_context,
+ await quart.request.json,
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success(data={'uuid': model_uuid})
- if model is None:
- return self.http_status(404, -1, 'model not found')
+ @self.route(
+ '/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ model = await self.ap.llm_model_service.get_llm_model(
+ request_context,
+ model_uuid,
+ include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
+ )
+ if model is None:
+ return self.http_status(404, -1, 'model not found')
+ return self.success(data={'model': model})
- return self.success(data={'model': model})
- elif quart.request.method == 'PUT':
- json_data = await quart.request.json
+ @self.route(
+ '/',
+ methods=['PUT'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ try:
+ await self.ap.llm_model_service.update_llm_model(
+ request_context,
+ model_uuid,
+ await quart.request.json,
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success()
- await self.ap.llm_model_service.update_llm_model(model_uuid, json_data)
-
- return self.success()
- elif quart.request.method == 'DELETE':
- await self.ap.llm_model_service.delete_llm_model(model_uuid)
-
- return self.success()
-
- @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(model_uuid: str) -> str:
- json_data = await quart.request.json
-
- await self.ap.llm_model_service.test_llm_model(model_uuid, json_data)
+ @self.route(
+ '/',
+ methods=['DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ await self.ap.llm_model_service.delete_llm_model(request_context, model_uuid)
+ return self.success()
+ @self.route(
+ '//test',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ await self.ap.llm_model_service.test_llm_model(request_context, model_uuid, await quart.request.json)
return self.success()
@group.group_class('models/embedding', '/api/v1/provider/models/embedding')
class EmbeddingModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
- if quart.request.method == 'GET':
- provider_uuid = quart.request.args.get('provider_uuid')
- if provider_uuid:
- return self.success(
- data={
- 'models': await self.ap.embedding_models_service.get_embedding_models_by_provider(
- provider_uuid
- )
- }
- )
- return self.success(data={'models': await self.ap.embedding_models_service.get_embedding_models()})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- model_uuid = await self.ap.embedding_models_service.create_embedding_model(json_data)
- return self.success(data={'uuid': model_uuid})
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ provider_uuid = quart.request.args.get('provider_uuid')
+ include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
+ if provider_uuid:
+ models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
+ request_context,
+ provider_uuid,
+ include_secret=include_secret,
+ )
+ else:
+ models = await self.ap.embedding_models_service.get_embedding_models(
+ request_context,
+ include_secret=include_secret,
+ )
+ return self.success(data={'models': models})
- @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(model_uuid: str) -> str:
- if quart.request.method == 'GET':
- model = await self.ap.embedding_models_service.get_embedding_model(model_uuid)
+ @self.route(
+ '',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ try:
+ model_uuid = await self.ap.embedding_models_service.create_embedding_model(
+ request_context,
+ await quart.request.json,
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success(data={'uuid': model_uuid})
- if model is None:
- return self.http_status(404, -1, 'model not found')
+ @self.route(
+ '/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ model = await self.ap.embedding_models_service.get_embedding_model(
+ request_context,
+ model_uuid,
+ include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
+ )
+ if model is None:
+ return self.http_status(404, -1, 'model not found')
+ return self.success(data={'model': model})
- return self.success(data={'model': model})
- elif quart.request.method == 'PUT':
- json_data = await quart.request.json
+ @self.route(
+ '/',
+ methods=['PUT'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ try:
+ await self.ap.embedding_models_service.update_embedding_model(
+ request_context,
+ model_uuid,
+ await quart.request.json,
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success()
- await self.ap.embedding_models_service.update_embedding_model(model_uuid, json_data)
-
- return self.success()
- elif quart.request.method == 'DELETE':
- await self.ap.embedding_models_service.delete_embedding_model(model_uuid)
-
- return self.success()
-
- @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(model_uuid: str) -> str:
- json_data = await quart.request.json
-
- await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
+ @self.route(
+ '/',
+ methods=['DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ await self.ap.embedding_models_service.delete_embedding_model(request_context, model_uuid)
+ return self.success()
+ @self.route(
+ '//test',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ await self.ap.embedding_models_service.test_embedding_model(
+ request_context, model_uuid, await quart.request.json
+ )
return self.success()
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
class RerankModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
- if quart.request.method == 'GET':
- provider_uuid = quart.request.args.get('provider_uuid')
- if provider_uuid:
- return self.success(
- data={
- 'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
- }
- )
- return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
- return self.success(data={'uuid': model_uuid})
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ provider_uuid = quart.request.args.get('provider_uuid')
+ include_secret = has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE)
+ if provider_uuid:
+ models = await self.ap.rerank_models_service.get_rerank_models_by_provider(
+ request_context,
+ provider_uuid,
+ include_secret=include_secret,
+ )
+ else:
+ models = await self.ap.rerank_models_service.get_rerank_models(
+ request_context,
+ include_secret=include_secret,
+ )
+ return self.success(data={'models': models})
- @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(model_uuid: str) -> str:
- if quart.request.method == 'GET':
- model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
+ @self.route(
+ '',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ try:
+ model_uuid = await self.ap.rerank_models_service.create_rerank_model(
+ request_context,
+ await quart.request.json,
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success(data={'uuid': model_uuid})
- if model is None:
- return self.http_status(404, -1, 'model not found')
-
- return self.success(data={'model': model})
- elif quart.request.method == 'PUT':
- json_data = await quart.request.json
-
- await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
-
- return self.success()
- elif quart.request.method == 'DELETE':
- await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
-
- return self.success()
-
- @self.route('//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(model_uuid: str) -> str:
- json_data = await quart.request.json
-
- await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
+ @self.route(
+ '/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ model = await self.ap.rerank_models_service.get_rerank_model(
+ request_context,
+ model_uuid,
+ include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
+ )
+ if model is None:
+ return self.http_status(404, -1, 'model not found')
+ return self.success(data={'model': model})
+ @self.route(
+ '/',
+ methods=['PUT'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ try:
+ await self.ap.rerank_models_service.update_rerank_model(
+ request_context,
+ model_uuid,
+ await quart.request.json,
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success()
+
+ @self.route(
+ '/',
+ methods=['DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ await self.ap.rerank_models_service.delete_rerank_model(request_context, model_uuid)
+ return self.success()
+
+ @self.route(
+ '//test',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(model_uuid: str, request_context: RequestContext) -> str:
+ await self.ap.rerank_models_service.test_rerank_model(request_context, model_uuid, await quart.request.json)
return self.success()
diff --git a/src/langbot/pkg/api/http/controller/groups/provider/providers.py b/src/langbot/pkg/api/http/controller/groups/provider/providers.py
index fcea598f9..bf8a195ae 100644
--- a/src/langbot/pkg/api/http/controller/groups/provider/providers.py
+++ b/src/langbot/pkg/api/http/controller/groups/provider/providers.py
@@ -1,56 +1,102 @@
import quart
+from ....authz import Permission, has_permission
+from ....context import RequestContext
from ... import group
@group.group_class('models/providers', '/api/v1/provider/providers')
class ModelProvidersRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _() -> str:
- if quart.request.method == 'GET':
- providers = await self.ap.provider_service.get_providers()
- # Add model counts
- for provider in providers:
- counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
- provider['llm_count'] = counts['llm_count']
- provider['embedding_count'] = counts['embedding_count']
- provider['rerank_count'] = counts['rerank_count']
- return self.success(data={'providers': providers})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- provider_uuid = await self.ap.provider_service.create_provider(json_data)
- return self.success(data={'uuid': provider_uuid})
-
@self.route(
- '/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _(provider_uuid: str) -> str:
- if quart.request.method == 'GET':
- provider = await self.ap.provider_service.get_provider(provider_uuid)
- if provider is None:
- return self.http_status(404, -1, 'provider not found')
- counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
+ async def _(request_context: RequestContext) -> str:
+ providers = await self.ap.provider_service.get_providers(
+ request_context,
+ include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
+ )
+ for provider in providers:
+ counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider['uuid'])
provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
- return self.success(data={'provider': provider})
- elif quart.request.method == 'PUT':
- json_data = await quart.request.json
- await self.ap.provider_service.update_provider(provider_uuid, json_data)
- return self.success()
- elif quart.request.method == 'DELETE':
- try:
- await self.ap.provider_service.delete_provider(provider_uuid)
- return self.success()
- except ValueError as e:
- return self.http_status(400, -1, str(e))
+ return self.success(data={'providers': providers})
- @self.route('//scan-models', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def _(provider_uuid: str) -> str:
+ @self.route(
+ '',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(request_context: RequestContext) -> str:
+ json_data = await quart.request.json
+ try:
+ provider_uuid = await self.ap.provider_service.create_provider(request_context, json_data)
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success(data={'uuid': provider_uuid})
+
+ @self.route(
+ '/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(provider_uuid: str, request_context: RequestContext) -> str:
+ provider = await self.ap.provider_service.get_provider(
+ request_context,
+ provider_uuid,
+ include_secret=has_permission(request_context, Permission.PROVIDER_SECRET_MANAGE),
+ )
+ if provider is None:
+ return self.http_status(404, -1, 'provider not found')
+ counts = await self.ap.provider_service.get_provider_model_counts(request_context, provider_uuid)
+ provider['llm_count'] = counts['llm_count']
+ provider['embedding_count'] = counts['embedding_count']
+ provider['rerank_count'] = counts['rerank_count']
+ return self.success(data={'provider': provider})
+
+ @self.route(
+ '/',
+ methods=['PUT'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(provider_uuid: str, request_context: RequestContext) -> str:
+ json_data = await quart.request.json
+ try:
+ await self.ap.provider_service.update_provider(request_context, provider_uuid, json_data)
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success()
+
+ @self.route(
+ '/',
+ methods=['DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(provider_uuid: str, request_context: RequestContext) -> str:
+ try:
+ await self.ap.provider_service.delete_provider(request_context, provider_uuid)
+ return self.success()
+ except ValueError as e:
+ return self.http_status(400, -1, str(e))
+
+ @self.route(
+ '//scan-models',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.PROVIDER_SECRET_MANAGE,
+ )
+ async def _(provider_uuid: str, request_context: RequestContext) -> str:
try:
model_type = quart.request.args.get('type')
- result = await self.ap.provider_service.scan_provider_models(provider_uuid, model_type)
+ result = await self.ap.provider_service.scan_provider_models(request_context, provider_uuid, model_type)
return self.success(data=result)
except ValueError as e:
return self.http_status(400, -1, str(e))
diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py
index 27654e70e..9633fa58c 100644
--- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py
+++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py
@@ -1,103 +1,138 @@
from __future__ import annotations
import quart
-import traceback
from urllib.parse import unquote
-
+from ....authz import Permission
+from ....context import RequestContext
+from ......provider.tools.loaders.mcp_policy import MCPStdioDisabledError
from ... import group
@group.group_class('mcp', '/api/v1/mcp')
class MCPRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('/servers', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- """获取MCP服务器列表"""
- if quart.request.method == 'GET':
- servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True)
-
- return self.success(data={'servers': servers})
-
- elif quart.request.method == 'POST':
- data = await quart.request.json
-
- try:
- uuid = await self.ap.mcp_service.create_mcp_server(data)
- return self.success(data={'uuid': uuid})
- except Exception as e:
- traceback.print_exc()
- return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
+ @self.route(
+ '/servers',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ servers = await self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True)
+ return self.success(data={'servers': servers})
@self.route(
- '/servers/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN
+ '/servers',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
)
- async def _(server_name: str) -> str:
- """获取、更新或删除MCP服务器配置"""
- server_name = unquote(server_name)
+ async def _(request_context: RequestContext) -> str:
+ data = await quart.request.json
+ try:
+ server_uuid = await self.ap.mcp_service.create_mcp_server(request_context, data)
+ except MCPStdioDisabledError as exc:
+ return self.http_status(403, exc.code, str(exc))
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success(data={'uuid': server_uuid})
- server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
+ @self.route(
+ '/servers/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(server_name: str, request_context: RequestContext) -> str:
+ server_name = unquote(server_name)
+ server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
if server_data is None:
return self.http_status(404, -1, 'Server not found')
+ return self.success(data={'server': server_data})
- if quart.request.method == 'GET':
- return self.success(data={'server': server_data})
-
- elif quart.request.method == 'PUT':
+ @self.route(
+ '/servers/',
+ methods=['PUT', 'DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(server_name: str, request_context: RequestContext) -> str:
+ server_name = unquote(server_name)
+ server_data = await self.ap.mcp_service.get_mcp_server_by_name(request_context, server_name)
+ if server_data is None:
+ return self.http_status(404, -1, 'Server not found')
+ if quart.request.method == 'PUT':
data = await quart.request.json
try:
- await self.ap.mcp_service.update_mcp_server(server_data['uuid'], data)
- return self.success()
- except Exception as e:
- return self.http_status(500, -1, f'Failed to update MCP server: {str(e)}')
+ await self.ap.mcp_service.update_mcp_server(request_context, server_data['uuid'], data)
+ except MCPStdioDisabledError as exc:
+ return self.http_status(403, exc.code, str(exc))
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ else:
+ await self.ap.mcp_service.delete_mcp_server(request_context, server_data['uuid'])
+ return self.success()
- elif quart.request.method == 'DELETE':
- try:
- await self.ap.mcp_service.delete_mcp_server(server_data['uuid'])
- return self.success()
- except Exception as e:
- return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
-
- @self.route('/servers//test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _(server_name: str) -> str:
+ @self.route(
+ '/servers//test',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(server_name: str, request_context: RequestContext) -> str:
"""测试MCP服务器连接"""
server_name = unquote(server_name)
server_data = await quart.request.json
- task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
+ try:
+ task_id = await self.ap.mcp_service.test_mcp_server(
+ request_context,
+ server_name=server_name,
+ server_data=server_data,
+ )
+ except MCPStdioDisabledError as exc:
+ return self.http_status(403, exc.code, str(exc))
return self.success(data={'task_id': task_id})
- @self.route('/servers//resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(server_name: str) -> str:
+ @self.route(
+ '/servers//resources',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(server_name: str, request_context: RequestContext) -> str:
"""Get resources from an MCP server"""
server_name = unquote(server_name)
- try:
- resources = await self.ap.mcp_service.get_mcp_server_resources(server_name)
- templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
- runtime_info = await self.ap.mcp_service.get_runtime_info(server_name)
- return self.success(
- data={
- 'resources': resources,
- 'resource_templates': templates,
- 'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
- }
- )
- except Exception as e:
- return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
+ resources = await self.ap.mcp_service.get_mcp_server_resources(request_context, server_name)
+ templates = await self.ap.mcp_service.get_mcp_server_resource_templates(request_context, server_name)
+ runtime_info = await self.ap.mcp_service.get_runtime_info(request_context, server_name)
+ return self.success(
+ data={
+ 'resources': resources,
+ 'resource_templates': templates,
+ 'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
+ }
+ )
@self.route(
- '/servers//resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN
+ '/servers//resource-templates',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _(server_name: str) -> str:
+ async def _(server_name: str, request_context: RequestContext) -> str:
"""Get resource templates from an MCP server"""
server_name = unquote(server_name)
- try:
- templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
- return self.success(data={'resource_templates': templates})
- except Exception as e:
- return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
+ templates = await self.ap.mcp_service.get_mcp_server_resource_templates(request_context, server_name)
+ return self.success(data={'resource_templates': templates})
- @self.route('/servers//logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(server_name: str) -> str:
+ @self.route(
+ '/servers//logs',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.AUDIT_VIEW,
+ )
+ async def _(server_name: str, request_context: RequestContext) -> str:
"""Get logs from an MCP server"""
server_name = unquote(server_name)
try:
@@ -106,24 +141,32 @@ class MCPRouterGroup(group.RouterGroup):
limit = 200
limit = min(limit, 500)
level = quart.request.args.get('level') or None
- logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
+ logs = await self.ap.mcp_service.get_mcp_server_logs(
+ request_context,
+ server_name,
+ limit=limit,
+ level=level,
+ )
return self.success(data={'logs': logs})
- @self.route('/servers//resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _(server_name: str) -> str:
+ @self.route(
+ '/servers//resources/read',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(server_name: str, request_context: RequestContext) -> str:
"""Read a resource from an MCP server"""
server_name = unquote(server_name)
data = await quart.request.json
uri = data.get('uri')
if not uri:
return self.http_status(400, -1, 'URI is required')
- try:
- envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
- server_name,
- uri,
- max_bytes=data.get('max_bytes'),
- include_blob=bool(data.get('include_blob', False)),
- )
- return self.success(data=envelope)
- except Exception as e:
- return self.http_status(500, -1, f'Failed to read resource: {str(e)}')
+ envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
+ request_context,
+ server_name,
+ uri,
+ max_bytes=data.get('max_bytes'),
+ include_blob=bool(data.get('include_blob', False)),
+ )
+ return self.success(data=envelope)
diff --git a/src/langbot/pkg/api/http/controller/groups/resources/tools.py b/src/langbot/pkg/api/http/controller/groups/resources/tools.py
index 128a0647d..87ec2c1b6 100644
--- a/src/langbot/pkg/api/http/controller/groups/resources/tools.py
+++ b/src/langbot/pkg/api/http/controller/groups/resources/tools.py
@@ -2,21 +2,28 @@ from __future__ import annotations
import quart
+from ....authz import Permission
+from ....context import RequestContext
from ... import group
@group.group_class('tools', '/api/v1/tools')
class ToolsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
"""获取所有可用工具列表"""
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
bound_plugins: list[str] | None = None
bound_mcp_servers: list[str] | None = None
if pipeline_uuid:
- pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
+ pipeline = await self.ap.pipeline_service.get_pipeline(request_context, pipeline_uuid)
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
@@ -35,6 +42,7 @@ class ToolsRouterGroup(group.RouterGroup):
return self.success(
data={
'tools': await self.ap.tool_mgr.get_tool_catalog(
+ request_context,
bound_plugins,
bound_mcp_servers,
include_skill_authoring=True,
@@ -42,10 +50,15 @@ class ToolsRouterGroup(group.RouterGroup):
}
)
- @self.route('/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(tool_name: str) -> str:
+ @self.route(
+ '/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(tool_name: str, request_context: RequestContext) -> str:
"""获取特定工具详情"""
- tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True)
+ tools = await self.ap.tool_mgr.get_all_tools(request_context, include_skill_authoring=True)
for tool in tools:
if tool.name == tool_name:
diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py
index 946741d76..59c091917 100644
--- a/src/langbot/pkg/api/http/controller/groups/skills.py
+++ b/src/langbot/pkg/api/http/controller/groups/skills.py
@@ -2,8 +2,11 @@ from __future__ import annotations
import quart
+from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
from langbot_plugin.box.errors import BoxError
+from ...authz import Permission
+from ...context import RequestContext
from .. import group
@@ -12,58 +15,91 @@ class SkillsRouterGroup(group.RouterGroup):
"""Skills management API endpoints."""
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def list_or_create_skills() -> quart.Response:
- if quart.request.method == 'GET':
- try:
- skills = await self.ap.skill_service.list_skills()
- except (ValueError, BoxError) as exc:
- return self.http_status(400, -1, str(exc))
- return self.success(data={'skills': skills})
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def list_skills(request_context: RequestContext) -> quart.Response:
+ try:
+ skills = await self.ap.skill_service.list_skills(request_context)
+ except EntitlementFeatureUnavailableError:
+ # Plans without managed sandbox support have no runnable skills.
+ # Treat that capability absence as an empty collection so the
+ # shared UI can render normally instead of surfacing a 500.
+ return self.success(data={'skills': []})
+ except (ValueError, BoxError) as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success(data={'skills': skills})
+ @self.route(
+ '',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def create_skill(request_context: RequestContext) -> quart.Response:
data = await quart.request.json
if 'name' not in data or not data['name']:
return self.http_status(400, -1, 'Missing required field: name')
try:
- skill = await self.ap.skill_service.create_skill(data)
+ skill = await self.ap.skill_service.create_skill(request_context, data)
return self.success(data={'skill': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
- @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def get_update_delete_skill(skill_name: str) -> quart.Response:
- if quart.request.method == 'GET':
- try:
- skill = await self.ap.skill_service.get_skill(skill_name)
- except (ValueError, BoxError) as exc:
- return self.http_status(400, -1, str(exc))
- if not skill:
- return self.http_status(404, -1, 'Skill not found')
- return self.success(data={'skill': skill})
+ @self.route(
+ '/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
+ try:
+ skill = await self.ap.skill_service.get_skill(request_context, skill_name)
+ except (ValueError, BoxError) as exc:
+ return self.http_status(400, -1, str(exc))
+ if not skill:
+ return self.http_status(404, -1, 'Skill not found')
+ return self.success(data={'skill': skill})
+ @self.route(
+ '/',
+ methods=['PUT', 'DELETE'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def update_delete_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
if quart.request.method == 'PUT':
data = await quart.request.json
try:
- skill = await self.ap.skill_service.update_skill(skill_name, data)
+ skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
return self.success(data={'skill': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
try:
- await self.ap.skill_service.delete_skill(skill_name)
+ await self.ap.skill_service.delete_skill(request_context, skill_name)
return self.success()
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
- @self.route('//files', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def list_skill_files(skill_name: str) -> quart.Response:
+ @self.route(
+ '//files',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def list_skill_files(skill_name: str, request_context: RequestContext) -> quart.Response:
"""List files in skill package directory."""
path = quart.request.args.get('path', '.').strip()
include_hidden = quart.request.args.get('include_hidden', 'false').lower() == 'true'
try:
result = await self.ap.skill_service.list_skill_files(
+ request_context,
skill_name,
path=path,
include_hidden=include_hidden,
@@ -73,38 +109,55 @@ class SkillsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, str(exc))
@self.route(
- '//files/', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
+ '//files/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
)
- async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response:
- """Read or write a file in skill package."""
- if quart.request.method == 'GET':
- try:
- result = await self.ap.skill_service.read_skill_file(skill_name, path)
- return self.success(data=result)
- except (ValueError, BoxError) as exc:
- return self.http_status(400, -1, str(exc))
+ async def read_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response:
+ try:
+ result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
+ return self.success(data=result)
+ except (ValueError, BoxError) as exc:
+ return self.http_status(400, -1, str(exc))
- # PUT - write file
+ @self.route(
+ '//files/',
+ methods=['PUT'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def write_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response:
data = await quart.request.json
content = data.get('content', '')
if content is None:
return self.http_status(400, -1, 'Missing required field: content')
try:
- result = await self.ap.skill_service.write_skill_file(skill_name, path, content)
+ result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content)
return self.success(data=result)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
- @self.route('//preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def preview_skill(skill_name: str) -> quart.Response:
- skill = self.ap.skill_mgr.get_skill_by_name(skill_name)
+ @self.route(
+ '//preview',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def preview_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
+ skill = await self.ap.skill_service.get_skill(request_context, skill_name)
if not skill:
return self.http_status(404, -1, 'Skill not found')
return self.success(data={'instructions': skill.get('instructions', '')})
- @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def install_skill_from_github() -> quart.Response:
+ @self.route(
+ '/install/github',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def install_skill_from_github(request_context: RequestContext) -> quart.Response:
data = await quart.request.json
required_fields = ['asset_url', 'owner', 'repo']
for field in required_fields:
@@ -115,15 +168,20 @@ class SkillsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Missing required field: release_tag')
try:
- skill = await self.ap.skill_service.install_from_github(data)
+ skill = await self.ap.skill_service.install_from_github(request_context, data)
return self.success(data={'skills': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
- except Exception as exc:
- return self.http_status(500, -1, f'Failed to install skill: {exc}')
+ except Exception:
+ raise
- @self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def preview_skill_from_github() -> quart.Response:
+ @self.route(
+ '/install/github/preview',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def preview_skill_from_github(request_context: RequestContext) -> quart.Response:
data = await quart.request.json
required_fields = ['asset_url', 'owner', 'repo']
for field in required_fields:
@@ -134,15 +192,20 @@ class SkillsRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Missing required field: release_tag')
try:
- preview = await self.ap.skill_service.preview_install_from_github(data)
+ preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
return self.success(data={'skills': preview})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
- except Exception as exc:
- return self.http_status(500, -1, f'Failed to preview skill: {exc}')
+ except Exception:
+ raise
- @self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def install_skill_from_upload() -> quart.Response:
+ @self.route(
+ '/install/upload',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def install_skill_from_upload(request_context: RequestContext) -> quart.Response:
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
@@ -150,6 +213,7 @@ class SkillsRouterGroup(group.RouterGroup):
try:
skill = await self.ap.skill_service.install_from_zip_upload(
+ request_context,
file_bytes=file.read(),
filename=file.filename or '',
source_paths=form.getlist('source_paths'),
@@ -157,34 +221,45 @@ class SkillsRouterGroup(group.RouterGroup):
return self.success(data={'skills': skill})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
- except Exception as exc:
- return self.http_status(500, -1, f'Failed to install skill: {exc}')
+ except Exception:
+ raise
- @self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def preview_skill_from_upload() -> quart.Response:
+ @self.route(
+ '/install/upload/preview',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def preview_skill_from_upload(request_context: RequestContext) -> quart.Response:
file = (await quart.request.files).get('file')
if file is None:
return self.http_status(400, -1, 'file is required')
try:
preview = await self.ap.skill_service.preview_install_from_zip_upload(
+ request_context,
file_bytes=file.read(),
filename=file.filename or '',
)
return self.success(data={'skills': preview})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
- except Exception as exc:
- return self.http_status(500, -1, f'Failed to preview skill: {exc}')
+ except Exception:
+ raise
- @self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
- async def scan_skill_directory() -> quart.Response:
+ @self.route(
+ '/scan',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def scan_skill_directory(request_context: RequestContext) -> quart.Response:
path = quart.request.args.get('path', '').strip()
if not path:
return self.http_status(400, -1, 'Missing required parameter: path')
try:
- result = await self.ap.skill_service.scan_directory_async(path)
+ result = await self.ap.skill_service.scan_directory_async(request_context, path)
return self.success(data=result)
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
diff --git a/src/langbot/pkg/api/http/controller/groups/stats.py b/src/langbot/pkg/api/http/controller/groups/stats.py
index 8c8e9113c..e4c8dbdae 100644
--- a/src/langbot/pkg/api/http/controller/groups/stats.py
+++ b/src/langbot/pkg/api/http/controller/groups/stats.py
@@ -1,19 +1,39 @@
from .. import group
+from ...authz import Permission
+from ...context import ExecutionContext, RequestContext
+
+
+def collect_basic_stats(ap, request_context: RequestContext) -> dict[str, int]:
+ """Collect runtime counters only from the selected Workspace placement."""
+
+ execution_context = ExecutionContext.from_request(request_context)
+ sessions = [
+ session
+ for session in ap.sess_mgr.session_list
+ if (
+ getattr(session, 'instance_uuid', None) == execution_context.instance_uuid
+ and getattr(session, 'workspace_uuid', None) == execution_context.workspace_uuid
+ and getattr(session, 'placement_generation', None) == execution_context.placement_generation
+ )
+ ]
+ conversation_count = sum(
+ len(session.conversations if session.conversations is not None else []) for session in sessions
+ )
+ return {
+ 'active_session_count': len(sessions),
+ 'conversation_count': conversation_count,
+ 'query_count': ap.query_pool.get_query_count(execution_context),
+ }
@group.group_class('stats', '/api/v1/stats')
class StatsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('/basic', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- conv_count = 0
- for session in self.ap.sess_mgr.session_list:
- conv_count += len(session.conversations if session.conversations is not None else [])
-
- return self.success(
- data={
- 'active_session_count': len(self.ap.sess_mgr.session_list),
- 'conversation_count': conv_count,
- 'query_count': self.ap.query_pool.query_id_counter,
- }
- )
+ @self.route(
+ '/basic',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ return self.success(data=collect_basic_stats(self.ap, request_context))
diff --git a/src/langbot/pkg/api/http/controller/groups/survey.py b/src/langbot/pkg/api/http/controller/groups/survey.py
index a65d51a85..3e73f1f83 100644
--- a/src/langbot/pkg/api/http/controller/groups/survey.py
+++ b/src/langbot/pkg/api/http/controller/groups/survey.py
@@ -1,3 +1,4 @@
+import asyncio
import base64
import quart
@@ -59,7 +60,14 @@ class SurveyRouterGroup(group.RouterGroup):
continue
try:
payload = data_url.split(',', 1)[1]
- if len(base64.b64decode(payload, validate=True)) > 1024 * 1024:
+ if len(payload) > 4 * ((1024 * 1024 + 2) // 3) + 4:
+ return self.fail(5, 'attachment too large')
+ decoded = await asyncio.to_thread(
+ base64.b64decode,
+ payload,
+ validate=True,
+ )
+ if len(decoded) > 1024 * 1024:
return self.fail(5, 'attachment too large')
except Exception:
return self.fail(5, 'attachment too large')
diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py
index 236a23582..330799335 100644
--- a/src/langbot/pkg/api/http/controller/groups/system.py
+++ b/src/langbot/pkg/api/http/controller/groups/system.py
@@ -5,7 +5,11 @@ import sqlalchemy
from .. import group
from .....utils import constants
-from .....entity.persistence.metadata import Metadata
+from .....entity.persistence.metadata import WorkspaceMetadata
+from ...authz import Permission
+from ...context import RequestContext
+from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
+from .....workspace.invitation_delivery import InvitationDeliveryService
@group.group_class('system', '/api/v1/system')
@@ -17,17 +21,46 @@ class SystemRouterGroup(group.RouterGroup):
wizard_status = 'none'
wizard_progress = None
try:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
- )
- for row in result:
- if row.key == 'wizard_status':
- wizard_status = row.value
- elif row.key == 'wizard_progress':
- try:
- wizard_progress = json.loads(row.value)
- except (json.JSONDecodeError, TypeError):
- wizard_progress = None
+ authorization = quart.request.headers.get('Authorization', '')
+ if authorization.startswith('Bearer '):
+ account, _ = await self._authenticate_account(authorization.removeprefix('Bearer '))
+ request_context = await self._resolve_account_context(account, group.AuthType.USER_TOKEN)
+ if request_context is not None:
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+
+ async def load_workspace_metadata():
+ return await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ WorkspaceMetadata.key,
+ WorkspaceMetadata.value,
+ ).where(
+ WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
+ WorkspaceMetadata.key.in_(['wizard_status', 'wizard_progress']),
+ )
+ )
+
+ cloud_runtime = (
+ getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ )
+ if cloud_runtime:
+ if not callable(tenant_uow):
+ raise RuntimeError('Cloud system metadata requires an explicit tenant UoW')
+ async with tenant_uow(request_context.workspace_uuid):
+ result = await load_workspace_metadata()
+ else:
+ result = await load_workspace_metadata()
+ # ``execute_async`` deliberately preserves its historical
+ # AsyncConnection result shape. Selecting the two fields
+ # explicitly keeps this reader independent of ORM Session
+ # scalar semantics inside a tenant UoW.
+ for row in result:
+ if row.key == 'wizard_status':
+ wizard_status = row.value
+ elif row.key == 'wizard_progress':
+ try:
+ wizard_progress = json.loads(row.value)
+ except (json.JSONDecodeError, TypeError):
+ wizard_progress = None
except Exception:
pass
@@ -43,6 +76,10 @@ class SystemRouterGroup(group.RouterGroup):
else:
outbound_ips = []
+ invitation_delivery_service = getattr(self.ap, 'invitation_delivery_service', None)
+ if invitation_delivery_service is None:
+ invitation_delivery_service = InvitationDeliveryService(self.ap)
+
return self.success(
data={
'version': constants.semantic_version,
@@ -60,15 +97,24 @@ class SystemRouterGroup(group.RouterGroup):
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
'disable_models_service', False
),
+ # Exposed independently of Box status so the WebUI cannot
+ # infer stdio permission from sandbox availability.
+ 'mcp_stdio_enabled': stdio_mcp_enabled(self.ap),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'outbound_ips': outbound_ips,
+ 'invitation_delivery': invitation_delivery_service.capability(),
'wizard_status': wizard_status,
'wizard_progress': wizard_progress,
}
)
- @self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
+ @self.route(
+ '/wizard/completed',
+ methods=['POST'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.WORKSPACE_UPDATE,
+ )
+ async def _(request_context: RequestContext) -> str:
"""Mark wizard status in metadata table and clear progress.
Accepts JSON body: { "status": "skipped" | "completed" }
@@ -80,28 +126,48 @@ class SystemRouterGroup(group.RouterGroup):
try:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
+ sqlalchemy.select(WorkspaceMetadata).where(
+ WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
+ WorkspaceMetadata.key == 'wizard_status',
+ )
)
if result.first():
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
+ sqlalchemy.update(WorkspaceMetadata)
+ .where(
+ WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
+ WorkspaceMetadata.key == 'wizard_status',
+ )
+ .values(value=status)
)
else:
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
+ sqlalchemy.insert(WorkspaceMetadata).values(
+ workspace_uuid=request_context.workspace_uuid,
+ key='wizard_status',
+ value=status,
+ )
)
# Clear wizard progress when wizard is completed/skipped
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
+ sqlalchemy.delete(WorkspaceMetadata).where(
+ WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
+ WorkspaceMetadata.key == 'wizard_progress',
+ )
)
- except Exception as e:
- return self.http_status(500, 500, f'Failed to update wizard status: {e}')
+ except Exception:
+ raise
return self.success(data={})
- @self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
+ @self.route(
+ '/wizard/progress',
+ methods=['PUT'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.WORKSPACE_UPDATE,
+ )
+ async def _(request_context: RequestContext) -> str:
"""Save wizard progress to metadata table.
Accepts JSON body with wizard state fields:
@@ -113,23 +179,40 @@ class SystemRouterGroup(group.RouterGroup):
try:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
+ sqlalchemy.select(WorkspaceMetadata).where(
+ WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
+ WorkspaceMetadata.key == 'wizard_progress',
+ )
)
if result.first():
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
+ sqlalchemy.update(WorkspaceMetadata)
+ .where(
+ WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
+ WorkspaceMetadata.key == 'wizard_progress',
+ )
+ .values(value=progress_json)
)
else:
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
+ sqlalchemy.insert(WorkspaceMetadata).values(
+ workspace_uuid=request_context.workspace_uuid,
+ key='wizard_progress',
+ value=progress_json,
+ )
)
- except Exception as e:
- return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
+ except Exception:
+ raise
return self.success(data={})
- @self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
+ @self.route(
+ '/tasks',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
task_type = quart.request.args.get('type')
task_kind = quart.request.args.get('kind')
@@ -138,30 +221,56 @@ class SystemRouterGroup(group.RouterGroup):
if task_kind == '':
task_kind = None
- return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
+ return self.success(
+ data=self.ap.task_mgr.get_tasks_dict(
+ task_type,
+ task_kind,
+ instance_uuid=request_context.instance_uuid,
+ workspace_uuid=request_context.workspace_uuid,
+ placement_generation=request_context.placement_generation,
+ )
+ )
- @self.route('/tasks/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(task_id: str) -> str:
- task = self.ap.task_mgr.get_task_by_id(int(task_id))
+ @self.route(
+ '/tasks/',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
+ )
+ async def _(task_id: str, request_context: RequestContext) -> str:
+ task = self.ap.task_mgr.get_task_by_id(
+ int(task_id),
+ instance_uuid=request_context.instance_uuid,
+ workspace_uuid=request_context.workspace_uuid,
+ placement_generation=request_context.placement_generation,
+ )
if task is None:
return self.http_status(404, 404, 'Task not found')
return self.success(data=task.to_dict())
- @self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _() -> str:
- return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
+ @self.route(
+ '/storage-analysis',
+ methods=['GET'],
+ auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.AUDIT_VIEW,
+ )
+ async def _(request_context: RequestContext) -> str:
+ return self.success(data=await self.ap.maintenance_service.get_storage_analysis(request_context))
@self.route(
'/debug/plugin/action',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RUNTIME_OPERATE,
)
- async def _() -> str:
+ async def _(request_context: RequestContext) -> str:
if not constants.debug_mode:
return self.http_status(403, 403, 'Forbidden')
+ await self.ap.plugin_connector.require_workspace_context(request_context)
+
data = await quart.request.json
class AnoymousAction:
@@ -174,6 +283,7 @@ class SystemRouterGroup(group.RouterGroup):
AnoymousAction(data['action']),
data['data'],
timeout=data.get('timeout', 10),
+ action_context=self.ap.plugin_connector.handler.require_bound_action_context().without_installation(),
)
return self.success(data=resp)
@@ -182,8 +292,10 @@ class SystemRouterGroup(group.RouterGroup):
'/status/plugin-system',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
+ permission=Permission.RESOURCE_VIEW,
)
- async def _() -> str:
+ async def _(request_context: RequestContext) -> str:
+ await self.ap.plugin_connector.require_workspace_context(request_context)
plugin_connector_error = 'ok'
is_connected = True
diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py
index 886dc5d0d..8fa38bf73 100644
--- a/src/langbot/pkg/api/http/controller/groups/user.py
+++ b/src/langbot/pkg/api/http/controller/groups/user.py
@@ -1,14 +1,55 @@
import quart
import argon2
import asyncio
-import traceback
+import uuid
+from urllib.parse import parse_qs, urlsplit
from .. import group
from .....entity.errors import account as account_errors
+from ...context import RequestContext
+from .....cloud.launch import SpaceLaunchError
+from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
+ @staticmethod
+ def _origin(value: str) -> tuple[str, str, int | None] | None:
+ parsed = urlsplit(value)
+ if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
+ return None
+ return parsed.scheme, parsed.hostname.casefold(), parsed.port
+
+ def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
+ parsed = urlsplit(redirect_uri)
+ if (
+ parsed.scheme not in {'http', 'https'}
+ or not parsed.hostname
+ or parsed.username is not None
+ or parsed.password is not None
+ or parsed.fragment
+ or parsed.path != '/auth/space/callback'
+ ):
+ raise ValueError('Invalid redirect_uri parameter')
+
+ query = parse_qs(parsed.query, keep_blank_values=True)
+ if bind:
+ if query != {'mode': ['bind']}:
+ raise ValueError('Invalid Space binding redirect_uri')
+ elif query:
+ raise ValueError('Invalid Space login redirect_uri')
+
+ redirect_origin = self._origin(redirect_uri)
+ api_config = self.ap.instance_config.data.get('api', {})
+ trusted_origins = {
+ self._origin(str(api_config.get(config_key, '') or '').strip())
+ for config_key in ('webui_url', 'webhook_prefix')
+ }
+ trusted_origins.discard(None)
+ if redirect_origin not in trusted_origins:
+ raise ValueError('Untrusted redirect_uri origin')
+ return redirect_uri
+
async def initialize(self) -> None:
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
@@ -23,12 +64,19 @@ class UserRouterGroup(group.RouterGroup):
user_email = json_data['user']
password = json_data['password']
- await self.ap.user_service.create_user(user_email, password)
+ try:
+ await self.ap.user_service.create_user(user_email, password)
+ except ControlPlaneDirectoryRequiredError as exc:
+ return self.http_status(409, exc.code, str(exc))
+ except PublicRegistrationClosedError:
+ return self.http_status(409, 'registration_closed', 'System already initialized')
return self.success()
@self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
+ if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
+ return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
json_data = await quart.request.json
try:
@@ -40,9 +88,9 @@ class UserRouterGroup(group.RouterGroup):
return self.success(data={'token': token})
- @self.route('/check-token', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str) -> str:
- token = await self.ap.user_service.generate_jwt_token(user_email)
+ @self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> str:
+ token = await self.ap.user_service.generate_jwt_token(account)
return self.success(data={'token': token})
@@ -101,15 +149,50 @@ class UserRouterGroup(group.RouterGroup):
async def _() -> str:
"""Get Space OAuth authorization URL for redirect"""
redirect_uri = quart.request.args.get('redirect_uri', '')
- state = quart.request.args.get('state', '')
if not redirect_uri:
return self.fail(1, 'Missing redirect_uri parameter')
+ if 'state' in quart.request.args:
+ return self.fail(1, 'Caller-supplied OAuth state is not allowed')
try:
+ redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
+ launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
+ if launch_workspace_uuid:
+ if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
+ return self.fail(1, 'Space launch requires Cloud mode')
+ try:
+ uuid.UUID(launch_workspace_uuid)
+ except ValueError:
+ return self.fail(1, 'Invalid launch Workspace')
+ state = await self.ap.user_service.issue_space_oauth_state(
+ 'login',
+ launch_workspace_uuid=launch_workspace_uuid,
+ )
+ else:
+ state = await self.ap.user_service.issue_space_oauth_state('login')
authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
return self.success(data={'authorize_url': authorize_url})
- except Exception as e:
+ except ValueError as e:
+ return self.fail(1, str(e))
+
+ @self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
+ async def _(request_context: RequestContext) -> str:
+ """Issue an account-bound, one-time Space OAuth redirect."""
+ redirect_uri = quart.request.args.get('redirect_uri', '')
+ if not redirect_uri:
+ return self.fail(1, 'Missing redirect_uri parameter')
+ if not request_context.account_uuid:
+ return self.http_status(403, 'account_required', 'An Account is required')
+ try:
+ redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=True)
+ state = await self.ap.user_service.issue_space_oauth_state(
+ 'bind',
+ account_uuid=request_context.account_uuid,
+ )
+ authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state)
+ return self.success(data={'authorize_url': authorize_url})
+ except ValueError as e:
return self.fail(1, str(e))
@self.route('/space/callback', methods=['POST'], auth_type=group.AuthType.NONE)
@@ -117,11 +200,23 @@ class UserRouterGroup(group.RouterGroup):
"""Handle OAuth callback - exchange code for tokens and authenticate"""
json_data = await quart.request.json
code = json_data.get('code')
+ state = json_data.get('state')
+ launch_assertion = json_data.get('launch_assertion')
+ workspace_uuid = json_data.get('workspace_uuid')
+
+ if launch_assertion:
+ return await self._handle_space_direct_launch(
+ str(launch_assertion),
+ str(workspace_uuid or '') or None,
+ )
if not code:
return self.fail(1, 'Missing authorization code')
+ if not state:
+ return self.fail(1, 'Missing state parameter')
try:
+ consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
@@ -136,61 +231,80 @@ class UserRouterGroup(group.RouterGroup):
access_token, refresh_token, expires_in
)
+ launch_workspace_uuid = consumed_state.launch_workspace_uuid
+ if launch_workspace_uuid:
+ try:
+ access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
+ user_obj.uuid,
+ launch_workspace_uuid,
+ )
+ except Exception:
+ self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
+ return self.fail(1, 'Space OAuth failed')
+ return self.success(
+ data={
+ 'token': jwt_token,
+ 'user': user_obj.user,
+ 'workspace_uuid': access.workspace.uuid,
+ }
+ )
+
return self.success(
data={
'token': jwt_token,
'user': user_obj.user,
}
)
+ except ControlPlaneDirectoryRequiredError as e:
+ return self.http_status(409, e.code, str(e))
except account_errors.AccountEmailMismatchError as e:
- return self.fail(3, str(e))
- except ValueError as e:
- traceback.print_exc()
- self.ap.logger.warning(f'Space OAuth callback failed: {e}')
- return self.fail(1, str(e))
- except Exception as e:
- traceback.print_exc()
- return self.fail(2, f'OAuth callback failed: {str(e)}')
-
- @self.route('/info', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str) -> str:
- """Get current user information including account type"""
- user_obj = await self.ap.user_service.get_user_by_email(user_email)
-
- if user_obj is None:
- return self.http_status(404, -1, 'User not found')
+ return self.fail(getattr(e, 'code', 3), str(e))
+ except ValueError:
+ self.ap.logger.exception('Space OAuth callback failed')
+ return self.fail(1, 'Space OAuth failed')
+ except Exception:
+ raise
+ @self.route('/info', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> str:
+ """Get current Account information without re-querying under Workspace RLS."""
return self.success(
data={
- 'user': user_obj.user,
- 'account_type': user_obj.account_type,
- 'has_password': bool(user_obj.password and user_obj.password.strip()),
+ 'account_uuid': account.uuid,
+ 'user': account.user,
+ 'account_type': account.account_type,
+ 'has_password': bool(account.password and account.password.strip()),
}
)
@self.route('/space-credits', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
- async def _(user_email: str) -> str:
- """Get Space credits balance for current user"""
- credits = await self.ap.space_service.get_credits(user_email)
- return self.success(data={'credits': credits})
+ async def _(request_context: RequestContext) -> str:
+ """Get Space credits using only the selected Workspace owner's credentials."""
+ access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
+ request_context.account_uuid,
+ request_context.workspace_uuid,
+ )
+ owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid)
+ owner_space_bound = bool(owner and owner.space_account_uuid)
+ credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None
+ return self.success(
+ data={
+ 'credits': credits,
+ 'owner_space_bound': owner_space_bound,
+ 'is_workspace_owner': access.membership.role == 'owner',
+ }
+ )
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
- """Get account info for login page (account type and has_password)"""
+ """Return instance login capabilities without disclosing an account."""
if not await self.ap.user_service.is_initialized():
return self.success(data={'initialized': False})
- user_obj = await self.ap.user_service.get_first_user()
- if user_obj is None:
- return self.success(data={'initialized': False})
-
- return self.success(
- data={
- 'initialized': True,
- 'account_type': user_obj.account_type,
- 'has_password': bool(user_obj.password and user_obj.password.strip()),
- }
- )
+ capabilities = await self.ap.user_service.get_login_capabilities()
+ if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
+ capabilities['password_login_enabled'] = False
+ return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
@@ -233,7 +347,7 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
code = json_data.get('code')
- state = json_data.get('state') # JWT token passed as state
+ state = json_data.get('state')
if not code:
return self.http_status(400, -1, 'Missing authorization code')
@@ -241,13 +355,10 @@ class UserRouterGroup(group.RouterGroup):
if not state:
return self.http_status(400, -1, 'Missing state parameter')
- # Verify state is a valid JWT token
try:
- user_email = await self.ap.user_service.verify_jwt_token(state)
+ user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
except Exception:
return self.http_status(401, -1, 'Invalid or expired state')
-
- user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
@@ -255,8 +366,8 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
- updated_user = await self.ap.user_service.bind_space_account(user_email, code)
- jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)
+ updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
+ jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
return self.success(
data={
'token': jwt_token,
@@ -264,7 +375,46 @@ class UserRouterGroup(group.RouterGroup):
'account_type': updated_user.account_type,
}
)
- except ValueError as e:
- return self.http_status(400, -1, str(e))
- except Exception as e:
- return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')
+ except account_errors.AccountEmailMismatchError:
+ return self.http_status(
+ 409,
+ 'space_account_email_mismatch',
+ 'Bind the LangBot Account with the same email as this local Account',
+ )
+ except ValueError:
+ return self.http_status(400, -1, 'Space account binding failed')
+ except Exception:
+ raise
+
+ async def _handle_space_direct_launch(
+ self,
+ launch_assertion: str,
+ workspace_uuid: str | None,
+ ) -> str:
+ try:
+ launch = await self.ap.space_launch_service.consume_assertion(
+ launch_assertion,
+ expected_workspace_uuid=workspace_uuid,
+ )
+ account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
+ if account is None:
+ raise SpaceLaunchError('Launch Account is not projected into Core')
+ self.ap.user_service._require_active_account(account)
+ access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
+ account.uuid,
+ launch['workspace_uuid'],
+ )
+ token = await self.ap.user_service.generate_jwt_token(account)
+ return self.success(
+ data={
+ 'token': token,
+ 'user': account.user,
+ 'workspace_uuid': access.workspace.uuid,
+ }
+ )
+ except SpaceLaunchError:
+ self.ap.logger.warning('Rejected Space direct-launch assertion')
+ return self.fail(1, 'Space launch failed')
+ except Exception:
+ self.ap.logger.exception('Space direct launch failed')
+ return self.fail(1, 'Space launch failed')
diff --git a/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py b/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py
index f82184c18..727a3540d 100644
--- a/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py
+++ b/src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py
@@ -1,49 +1,80 @@
+from __future__ import annotations
+
import quart
+from ...authz import Permission, has_permission
+from ...context import RequestContext
from .. import group
@group.group_class('webhook_mgmt', '/api/v1/webhooks')
class WebhookManagementRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
- @self.route('', methods=['GET', 'POST'])
- async def _() -> str:
- if quart.request.method == 'GET':
- webhooks = await self.ap.webhook_service.get_webhooks()
- return self.success(data={'webhooks': webhooks})
- elif quart.request.method == 'POST':
- json_data = await quart.request.json
- name = json_data.get('name', '')
- url = json_data.get('url', '')
- description = json_data.get('description', '')
- enabled = json_data.get('enabled', True)
+ @self.route('', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def _(request_context: RequestContext) -> str:
+ webhooks = await self.ap.webhook_service.get_webhooks(
+ request_context,
+ include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
+ )
+ return self.success(data={'webhooks': webhooks})
- if not name:
- return self.http_status(400, -1, 'Name is required')
- if not url:
- return self.http_status(400, -1, 'URL is required')
+ @self.route('', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
+ async def _(request_context: RequestContext) -> str:
+ json_data = await quart.request.get_json(silent=True) or {}
+ name = json_data.get('name', '')
+ url = json_data.get('url', '')
+ description = json_data.get('description', '')
+ enabled = json_data.get('enabled', True)
- webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
- return self.success(data={'webhook': webhook})
+ if not name:
+ return self.http_status(400, -1, 'Name is required')
+ if not url:
+ return self.http_status(400, -1, 'URL is required')
- @self.route('/', methods=['GET', 'PUT', 'DELETE'])
- async def _(webhook_id: int) -> str:
- if quart.request.method == 'GET':
- webhook = await self.ap.webhook_service.get_webhook(webhook_id)
- if webhook is None:
+ try:
+ webhook = await self.ap.webhook_service.create_webhook(
+ request_context,
+ name,
+ url,
+ description,
+ enabled,
+ )
+ except ValueError as exc:
+ return self.http_status(400, -1, str(exc))
+ return self.success(data={'webhook': webhook})
+
+ @self.route('/', methods=['GET'], permission=Permission.RESOURCE_VIEW)
+ async def _(webhook_id: int, request_context: RequestContext) -> str:
+ webhook = await self.ap.webhook_service.get_webhook(
+ request_context,
+ webhook_id,
+ include_secret=has_permission(request_context, Permission.RESOURCE_MANAGE),
+ )
+ if webhook is None:
+ return self.http_status(404, -1, 'Webhook not found')
+ return self.success(data={'webhook': webhook})
+
+ @self.route(
+ '/',
+ methods=['PUT', 'DELETE'],
+ permission=Permission.RESOURCE_MANAGE,
+ )
+ async def _(webhook_id: int, request_context: RequestContext) -> str:
+ if quart.request.method == 'PUT':
+ json_data = await quart.request.get_json(silent=True) or {}
+ updated = await self.ap.webhook_service.update_webhook(
+ request_context,
+ webhook_id,
+ json_data.get('name'),
+ json_data.get('url'),
+ json_data.get('description'),
+ json_data.get('enabled'),
+ )
+ if not updated:
return self.http_status(404, -1, 'Webhook not found')
- return self.success(data={'webhook': webhook})
-
- elif quart.request.method == 'PUT':
- json_data = await quart.request.json
- name = json_data.get('name')
- url = json_data.get('url')
- description = json_data.get('description')
- enabled = json_data.get('enabled')
-
- await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
return self.success()
- elif quart.request.method == 'DELETE':
- await self.ap.webhook_service.delete_webhook(webhook_id)
- return self.success()
+ deleted = await self.ap.webhook_service.delete_webhook(request_context, webhook_id)
+ if not deleted:
+ return self.http_status(404, -1, 'Webhook not found')
+ return self.success()
diff --git a/src/langbot/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py
index ec46c7447..dbb9626cf 100644
--- a/src/langbot/pkg/api/http/controller/groups/webhooks.py
+++ b/src/langbot/pkg/api/http/controller/groups/webhooks.py
@@ -4,6 +4,7 @@ import quart
import traceback
from .. import group
+from .....utils import bounded_executor
@group.group_class('webhooks', '/bots')
@@ -30,7 +31,10 @@ class WebhookRouterGroup(group.RouterGroup):
适配器返回的响应
"""
try:
- runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
+ # Public ingress never accepts X-Workspace-Id. The opaque bot UUID
+ # is resolved against the already-bound runtime resource, which
+ # carries the trusted Workspace and placement generation.
+ runtime_bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
if not runtime_bot:
return quart.jsonify({'error': 'Bot not found'}), 404
@@ -41,14 +45,40 @@ class WebhookRouterGroup(group.RouterGroup):
if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'):
return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501
- response = await runtime_bot.adapter.handle_unified_webhook(
- bot_uuid=bot_uuid,
- path=path,
- request=quart.request,
- )
+ async def dispatch():
+ await self.ap.workspace_service.get_execution_binding(
+ runtime_bot.workspace_uuid,
+ expected_generation=runtime_bot.placement_generation,
+ )
+ return await runtime_bot.adapter.handle_unified_webhook(
+ bot_uuid=bot_uuid,
+ path=path,
+ request=quart.request,
+ )
+
+ with bounded_executor.blocking_work_scope(runtime_bot.workspace_uuid):
+ persistence_mgr = self.ap.persistence_mgr
+ cloud_runtime = getattr(getattr(persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ tenant_scope = getattr(persistence_mgr, 'tenant_scope', None)
+ if not callable(tenant_scope):
+ raise RuntimeError('Cloud webhook dispatch requires an explicit tenant scope')
+ async with tenant_scope(runtime_bot.workspace_uuid):
+ response = await dispatch()
+ else:
+ response = await dispatch()
return response
- except Exception as e:
- self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}')
- return quart.jsonify({'error': str(e)}), 500
+ except bounded_executor.BlockingWorkCapacityError as exc:
+ return self.http_status(
+ 429,
+ 'blocking_work_capacity_exceeded',
+ str(exc),
+ )
+ except Exception:
+ request_id = self.request_id()
+ self.ap.logger.error(
+ f'Webhook dispatch error request_id={request_id} bot={bot_uuid}: {traceback.format_exc()}'
+ )
+ return self.internal_error_response(request_id)
diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py
new file mode 100644
index 000000000..5c43cf64b
--- /dev/null
+++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py
@@ -0,0 +1,363 @@
+from __future__ import annotations
+
+import typing
+
+import quart
+
+from ...authz import Permission, permissions_for_role
+from ...context import RequestContext
+from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
+from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
+from .....entity.persistence.workspace import WorkspaceSource
+from .....workspace.collaboration import WorkspaceMemberView
+from .....workspace.errors import WorkspaceNotFoundError
+from .....workspace.invitation_delivery import InvitationDeliveryService
+from .. import group
+
+
+def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
+ return {
+ 'uuid': workspace.uuid,
+ 'instance_uuid': workspace.instance_uuid,
+ 'name': workspace.name,
+ 'slug': workspace.slug,
+ 'type': workspace.type,
+ 'status': workspace.status,
+ 'source': workspace.source,
+ }
+
+
+def _membership_payload(
+ membership: WorkspaceMembership,
+ *,
+ email: str,
+) -> dict[str, typing.Any]:
+ return {
+ 'uuid': membership.uuid,
+ 'workspace_uuid': membership.workspace_uuid,
+ 'account_uuid': membership.account_uuid,
+ 'email': email,
+ 'role': membership.role,
+ 'status': membership.status,
+ 'joined_at': membership.joined_at.isoformat() if membership.joined_at else None,
+ 'created_at': membership.created_at.isoformat() if membership.created_at else None,
+ }
+
+
+def _invitation_payload(invitation: WorkspaceInvitation) -> dict[str, typing.Any]:
+ """Serialize an invitation without its bearer-secret hash."""
+
+ return {
+ 'uuid': invitation.uuid,
+ 'workspace_uuid': invitation.workspace_uuid,
+ 'normalized_email': invitation.normalized_email,
+ 'role': invitation.role,
+ 'status': invitation.status,
+ 'expires_at': invitation.expires_at.isoformat(),
+ 'created_at': invitation.created_at.isoformat() if invitation.created_at else None,
+ }
+
+
+@group.group_class('workspaces', '/api/v1/workspaces')
+class WorkspacesRouterGroup(group.RouterGroup):
+ async def _run_in_workspace_uow(
+ self, workspace_uuid: str, operation: typing.Callable[[], typing.Awaitable[typing.Any]]
+ ):
+ """Bind collaboration persistence to the selected tenant in Cloud."""
+ cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ async with self.ap.persistence_mgr.tenant_uow(workspace_uuid):
+ return await operation()
+ return await operation()
+
+ async def initialize(self) -> None:
+ @self.route('/bootstrap', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> typing.Any:
+ """List the active Workspaces available to an authenticated Account.
+
+ This account-only endpoint intentionally runs before Workspace
+ selection. It never accepts a selector as authority and does not
+ choose a default Workspace for a multi-membership Account.
+ """
+
+ accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
+ resolver = getattr(self.ap, 'entitlement_resolver', None)
+ workspaces: list[dict[str, typing.Any]] = []
+ for access in accesses:
+ plan_name: str | None = None
+ if access.workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None:
+ entitlement = await resolver.resolve(
+ access.workspace.uuid,
+ minimum_revision=access.membership.projection_revision,
+ )
+ plan_name = entitlement.plan_name
+ workspaces.append(
+ {
+ 'workspace': _workspace_payload(access.workspace),
+ 'membership': _membership_payload(access.membership, email=account.user),
+ 'permissions': sorted(permissions_for_role(access.membership.role)),
+ 'placement_generation': access.execution.placement_generation,
+ 'plan_name': plan_name,
+ }
+ )
+ return self.success(data={'workspaces': workspaces})
+
+ @self.route('', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
+ async def _(account) -> typing.Any:
+ accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
+ return self.success(data={'workspaces': [_workspace_payload(access.workspace) for access in accesses]})
+
+ @self.route('', methods=['POST'], permission=Permission.WORKSPACE_VIEW)
+ async def _(request_context: RequestContext) -> typing.Any:
+ if self.ap.workspace_service.policy.multi_workspace_enabled:
+ return self.http_status(
+ 409,
+ 'control_plane_required',
+ 'Cloud Workspaces are created by the SaaS control plane',
+ )
+ return self.http_status(403, 'edition_limit', 'This edition supports one Workspace per instance')
+
+ @self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
+ async def _(request_context: RequestContext) -> typing.Any:
+ membership = quart.g.workspace_membership
+ account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
+ if account is None:
+ return self.http_status(401, 'invalid_authentication', 'Account not found')
+ workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
+ plan_name: str | None = None
+ resolver = getattr(self.ap, 'entitlement_resolver', None)
+ if workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None:
+ entitlement = await resolver.resolve(
+ workspace.uuid,
+ minimum_revision=request_context.entitlement_revision,
+ )
+ plan_name = entitlement.plan_name
+ return self.success(
+ data={
+ 'workspace': _workspace_payload(workspace),
+ 'membership': _membership_payload(membership, email=account.user),
+ 'permissions': sorted(request_context.workspace.permissions),
+ 'placement_generation': request_context.placement_generation,
+ 'plan_name': plan_name,
+ }
+ )
+
+ @self.route('/', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
+ async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
+ self._require_current_workspace(workspace_uuid, request_context)
+ workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
+ return self.success(data={'workspace': _workspace_payload(workspace)})
+
+ @self.route('//members', methods=['GET'], permission=Permission.MEMBER_VIEW)
+ async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
+ self._require_current_workspace(workspace_uuid, request_context)
+
+ async def list_members():
+ return await self.ap.workspace_collaboration_service.list_members(
+ workspace_uuid, quart.g.workspace_membership
+ )
+
+ members = await self._run_in_workspace_uow(workspace_uuid, list_members)
+ return self.success(data={'members': [self._member_view_payload(item) for item in members]})
+
+ @self.route(
+ '//invitations',
+ methods=['GET', 'POST'],
+ permission=Permission.MEMBER_INVITE,
+ )
+ async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any:
+ self._require_current_workspace(workspace_uuid, request_context)
+ if quart.request.method == 'GET':
+
+ async def list_invitations():
+ return await self.ap.workspace_collaboration_service.list_invitations(
+ workspace_uuid, quart.g.workspace_membership
+ )
+
+ invitations = await self._run_in_workspace_uow(workspace_uuid, list_invitations)
+ return self.success(data={'invitations': [_invitation_payload(item) for item in invitations]})
+
+ data = await quart.request.get_json(silent=True) or {}
+
+ async def create_invitation():
+ return await self.ap.workspace_collaboration_service.create_invitation(
+ workspace_uuid,
+ quart.g.workspace_membership,
+ str(data.get('email', '')),
+ str(data.get('role', 'viewer')),
+ )
+
+ created = await self._run_in_workspace_uow(workspace_uuid, create_invitation)
+ delivery_service = self._invitation_delivery_service()
+ link = delivery_service.build_invitation_link(created.token)
+ workspace = await self.ap.workspace_service.get_workspace(workspace_uuid)
+ delivery = await delivery_service.deliver_invitation(
+ recipient_email=created.invitation.normalized_email,
+ workspace_name=workspace.name,
+ invitation_link=link,
+ )
+ return self.success(
+ data={
+ 'invitation': _invitation_payload(created.invitation),
+ 'token': created.token,
+ 'link': link,
+ 'delivery': delivery.to_public_dict(),
+ }
+ )
+
+ @self.route(
+ '//invitations/',
+ methods=['DELETE'],
+ permission=Permission.MEMBER_INVITE,
+ )
+ async def _(
+ workspace_uuid: str,
+ invitation_uuid: str,
+ request_context: RequestContext,
+ ) -> typing.Any:
+ self._require_current_workspace(workspace_uuid, request_context)
+
+ async def revoke_invitation():
+ return await self.ap.workspace_collaboration_service.revoke_invitation(
+ workspace_uuid, invitation_uuid, quart.g.workspace_membership
+ )
+
+ invitation = await self._run_in_workspace_uow(workspace_uuid, revoke_invitation)
+ return self.success(data={'invitation': _invitation_payload(invitation)})
+
+ @self.route(
+ '//members/',
+ methods=['PATCH', 'DELETE'],
+ permission=Permission.MEMBER_UPDATE_ROLE,
+ )
+ async def _(
+ workspace_uuid: str,
+ account_uuid: str,
+ request_context: RequestContext,
+ ) -> typing.Any:
+ self._require_current_workspace(workspace_uuid, request_context)
+ if quart.request.method == 'DELETE':
+ if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions:
+ return self.http_status(403, 'permission_denied', 'Member removal permission is required')
+
+ async def remove_member():
+ return await self.ap.workspace_collaboration_service.remove_member(
+ workspace_uuid, account_uuid, quart.g.workspace_membership
+ )
+
+ member = await self._run_in_workspace_uow(workspace_uuid, remove_member)
+ return self.success(data={'account_uuid': member.account_uuid})
+
+ data = await quart.request.get_json(silent=True) or {}
+
+ async def update_member_role():
+ return await self.ap.workspace_collaboration_service.update_member_role(
+ workspace_uuid,
+ account_uuid,
+ str(data.get('role', '')),
+ quart.g.workspace_membership,
+ )
+
+ member = await self._run_in_workspace_uow(workspace_uuid, update_member_role)
+ account = await self.ap.user_service.get_user_by_uuid(member.account_uuid)
+ return self.success(
+ data={
+ 'member': _membership_payload(
+ member,
+ email=account.user if account is not None else '',
+ )
+ }
+ )
+
+ @staticmethod
+ def _require_current_workspace(workspace_uuid: str, request_context: RequestContext) -> None:
+ if workspace_uuid != request_context.workspace_uuid:
+ raise WorkspaceNotFoundError('Workspace not found')
+
+ def _invitation_delivery_service(self) -> InvitationDeliveryService:
+ service = getattr(self.ap, 'invitation_delivery_service', None)
+ if service is None:
+ service = InvitationDeliveryService(self.ap)
+ self.ap.invitation_delivery_service = service
+ return service
+
+ @staticmethod
+ def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
+ return _membership_payload(view.membership, email=view.email)
+
+
+@group.group_class('invitations', '/api/v1/invitations')
+class InvitationsRouterGroup(group.RouterGroup):
+ async def initialize(self) -> None:
+ @self.route('/inspect', methods=['POST'], auth_type=group.AuthType.NONE)
+ async def _() -> typing.Any:
+ data = await quart.request.get_json(silent=True) or {}
+ invitation, workspace = await self.ap.workspace_collaboration_service.inspect_invitation(
+ str(data.get('token', ''))
+ )
+ return self.success(
+ data={
+ 'invitation': _invitation_payload(invitation),
+ 'workspace': _workspace_payload(workspace),
+ }
+ )
+
+ @self.route('/accept', methods=['POST'], auth_type=group.AuthType.NONE)
+ async def _() -> typing.Any:
+ data = await quart.request.get_json(silent=True) or {}
+ invitation_token = str(data.get('token', ''))
+ if not invitation_token:
+ return self.http_status(400, 'invitation_invalid', 'Invitation token is required')
+
+ authorization = quart.request.headers.get('Authorization', '')
+ if authorization.startswith('Bearer '):
+ if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
+ return self.http_status(
+ 409,
+ 'invitation_logout_required',
+ 'Sign out before creating the invited local Account',
+ )
+ try:
+ account = await self.ap.user_service.get_authenticated_account(
+ authorization.removeprefix('Bearer ')
+ )
+ if isinstance(account, str):
+ account = await self.ap.user_service.get_user_by_email(account)
+ except Exception as exc:
+ return self._auth_error_response(exc)
+ if account is None:
+ return self.http_status(401, 'invalid_authentication', 'Account not found')
+ membership = await self.ap.workspace_collaboration_service.accept_invitation(
+ invitation_token,
+ account.uuid,
+ )
+ token = await self.ap.user_service.generate_jwt_token(account)
+ return self.success(data={'token': token, 'workspace_uuid': membership.workspace_uuid})
+
+ registration = data.get('registration')
+ if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
+ return self.http_status(
+ 401,
+ 'account_exists_login_required',
+ 'Login with your LangBot Account to accept this invitation',
+ )
+ if not isinstance(registration, dict):
+ return self.http_status(
+ 401,
+ 'account_exists_login_required',
+ 'Sign in or provide registration details to accept this invitation',
+ )
+ password = registration.get('password')
+ if not isinstance(password, str) or len(password) < 8:
+ return self.http_status(400, 'invalid_password', 'Password must contain at least 8 characters')
+ try:
+ _, membership = await self.ap.user_service.register_invited_account(
+ invitation_token,
+ str(registration.get('email', '')),
+ password,
+ )
+ except ControlPlaneDirectoryRequiredError as exc:
+ return self.http_status(409, exc.code, str(exc))
+ except AccountExistsLoginRequiredError as exc:
+ return self.http_status(409, exc.code, str(exc))
+ return self.success(data={'workspace_uuid': membership.workspace_uuid, 'login_required': True})
diff --git a/src/langbot/pkg/api/http/controller/main.py b/src/langbot/pkg/api/http/controller/main.py
index 835617e16..3d20c4e71 100644
--- a/src/langbot/pkg/api/http/controller/main.py
+++ b/src/langbot/pkg/api/http/controller/main.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import os
+import typing
import quart
import quart_cors
@@ -27,6 +28,37 @@ importutil.import_modules_in_pkg(groups_knowledge)
importutil.import_modules_in_pkg(groups_resources)
+class BoundedJSONRequest(quart.Request):
+ """Parse bounded HTTP JSON bodies outside the shared event loop."""
+
+ async def get_json(
+ self,
+ force: bool = False,
+ silent: bool = False,
+ cache: bool = True,
+ ) -> typing.Any:
+ # Keep Quart's cache and error semantics, changing only where the
+ # potentially 10 MiB JSON decoder runs. The RouterGroup establishes a
+ # trusted Workspace blocking-work scope before calling route handlers.
+ if cache and self._cached_json[silent] is not Ellipsis:
+ return self._cached_json[silent]
+ if not (force or self.is_json):
+ return None
+
+ data = await self.get_data(cache=cache, as_text=False)
+ try:
+ result = await asyncio.to_thread(self.json_module.loads, data)
+ except ValueError as error:
+ if silent:
+ result = None
+ else:
+ result = self.on_json_loading_failed(error)
+
+ if cache:
+ self._cached_json[silent] = result
+ return result
+
+
class HTTPController:
ap: app.Application
@@ -35,6 +67,7 @@ class HTTPController:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self.quart_app = quart.Quart(__name__)
+ self.quart_app.request_class = BoundedJSONRequest
quart_cors.cors(self.quart_app, allow_origin='*')
# Set maximum content length to prevent large file uploads
@@ -103,6 +136,7 @@ class HTTPController:
config.accesslog = '-'
config.bind = [f'{host}:{port}']
config.errorlog = config.accesslog
+ config.websocket_max_message_size = group.MAX_FILE_SIZE
asgi_app = self.quart_app
if self.mcp_mount is not None:
@@ -113,7 +147,16 @@ class HTTPController:
async def register_routes(self) -> None:
@self.quart_app.route('/healthz')
async def healthz():
- return {'code': 0, 'msg': 'ok'}
+ get_resource_stats = getattr(
+ self.ap,
+ 'get_runtime_resource_stats',
+ None,
+ )
+ return {
+ 'code': 0,
+ 'msg': 'ok',
+ 'resources': (get_resource_stats() if callable(get_resource_stats) else {}),
+ }
for g in group.preregistered_groups:
ginst = g(self.ap, self.quart_app)
diff --git a/src/langbot/pkg/api/http/service/apikey.py b/src/langbot/pkg/api/http/service/apikey.py
index 207254351..5c9c006fc 100644
--- a/src/langbot/pkg/api/http/service/apikey.py
+++ b/src/langbot/pkg/api/http/service/apikey.py
@@ -1,97 +1,304 @@
from __future__ import annotations
+import dataclasses
+import datetime
+import hashlib
import secrets
+import typing
+import uuid
+
import sqlalchemy
-from ....core import app
from ....entity.persistence import apikey
+from ....workspace.errors import WorkspaceNotFoundError
+from ..authz import Permission, PermissionDeniedError
+from .tenant import TenantContext, require_workspace_uuid, scope_statement
+
+if typing.TYPE_CHECKING:
+ from ....core.app import Application
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class ApiKeyIdentity:
+ """Trusted Workspace identity derived from an API-key secret."""
+
+ instance_uuid: str
+ workspace_uuid: str
+ placement_generation: int
+ api_key_uuid: str
+ permissions: frozenset[str]
class ApiKeyService:
- ap: app.Application
+ """Manage hashed, Workspace-bound API keys."""
- def __init__(self, ap: app.Application) -> None:
+ def __init__(self, ap: Application) -> None:
self.ap = ap
- async def get_api_keys(self) -> list[dict]:
- """Get all API keys"""
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(apikey.ApiKey))
+ @staticmethod
+ def _hash_secret(secret: str) -> str:
+ return hashlib.sha256(secret.encode('utf-8')).hexdigest()
- keys = result.all()
- return [self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key) for key in keys]
+ @staticmethod
+ def _utcnow() -> datetime.datetime:
+ return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
- async def create_api_key(self, name: str, description: str = '') -> dict:
- """Create a new API key"""
- # Generate a secure random API key
- key = f'lbk_{secrets.token_urlsafe(32)}'
+ @staticmethod
+ def _normalize_scopes(
+ scopes: typing.Iterable[str] | None,
+ *,
+ default: typing.Iterable[str] = (),
+ ) -> list[str]:
+ requested = list(default if scopes is None else scopes)
+ valid = {permission.value for permission in Permission}
+ normalized: list[str] = []
+ for scope in requested:
+ if not isinstance(scope, str):
+ raise ValueError('API key scopes must be strings')
+ value = scope.strip()
+ if value not in valid:
+ raise ValueError(f'Unknown API key scope: {value}')
+ if value not in normalized:
+ normalized.append(value)
+ return normalized
- key_data = {'name': name, 'key': key, 'description': description}
+ def _serialize(self, row: typing.Any) -> dict[str, typing.Any]:
+ value = self.ap.persistence_mgr.serialize_model(apikey.ApiKey, row)
+ value.pop('key_hash', None)
+ # The secret is deliberately unrecoverable after creation.
+ value['secret_available'] = False
+ return value
- await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(apikey.ApiKey).values(**key_data))
-
- # Retrieve the created key
+ async def get_api_keys(self, context: TenantContext) -> list[dict[str, typing.Any]]:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
+ scope_statement(
+ sqlalchemy.select(apikey.ApiKey).order_by(apikey.ApiKey.created_at, apikey.ApiKey.id),
+ apikey.ApiKey,
+ context,
+ )
)
- created_key = result.first()
+ return [self._serialize(key) for key in result.all()]
- return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, created_key)
+ async def create_api_key(
+ self,
+ context: TenantContext,
+ name: str,
+ description: str = '',
+ *,
+ scopes: typing.Iterable[str] | None = None,
+ expires_at: datetime.datetime | None = None,
+ ) -> dict[str, typing.Any]:
+ workspace_uuid = require_workspace_uuid(context)
+ normalized_name = name.strip()
+ if not normalized_name:
+ raise ValueError('Name is required')
+ if expires_at is not None:
+ if expires_at.tzinfo is not None:
+ expires_at = expires_at.astimezone(datetime.UTC).replace(tzinfo=None)
+ if expires_at <= self._utcnow():
+ raise ValueError('API key expiry must be in the future')
- async def get_api_key(self, key_id: int) -> dict | None:
- """Get a specific API key by ID"""
+ default_scopes = getattr(getattr(context, 'workspace', None), 'permissions', frozenset())
+ normalized_scopes = self._normalize_scopes(scopes, default=default_scopes)
+ allowed_scopes = frozenset(default_scopes)
+ unauthorized_scopes = sorted(set(normalized_scopes) - allowed_scopes)
+ if unauthorized_scopes:
+ # API-key management delegates the caller's authority; it must not
+ # become a path for minting a stronger principal.
+ raise PermissionDeniedError(unauthorized_scopes[0])
+ secret = f'lbk_{secrets.token_urlsafe(32)}'
+ key_uuid = str(uuid.uuid4())
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(apikey.ApiKey).values(
+ uuid=key_uuid,
+ workspace_uuid=workspace_uuid,
+ created_by_account_uuid=getattr(context, 'account_uuid', None),
+ name=normalized_name,
+ key_hash=self._hash_secret(secret),
+ scopes=normalized_scopes,
+ status=apikey.ApiKeyStatus.ACTIVE.value,
+ expires_at=expires_at,
+ description=description.strip(),
+ )
+ )
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id)
+ scope_statement(
+ sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.uuid == key_uuid),
+ apikey.ApiKey,
+ workspace_uuid,
+ )
)
+ created = result.first()
+ if created is None:
+ raise RuntimeError('Created API key could not be loaded')
+ value = self._serialize(created)
+ value['key'] = secret
+ value['secret_available'] = True
+ return value
+ async def get_api_key(self, context: TenantContext, key_id: int) -> dict[str, typing.Any] | None:
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.id == key_id),
+ apikey.ApiKey,
+ context,
+ )
+ )
key = result.first()
+ return None if key is None else self._serialize(key)
- if key is None:
+ async def authenticate_api_key(self, secret: str) -> ApiKeyIdentity | None:
+ """Authenticate a secret and derive its Workspace without trusting headers."""
+
+ if not isinstance(secret, str) or not secret.strip():
return None
- return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key)
+ global_secret = self.ap.instance_config.data.get('api', {}).get('global_api_key', '')
+ if global_secret and secrets.compare_digest(secret, global_secret):
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ if workspace_service is None or workspace_service.policy.multi_workspace_enabled:
+ return None
+ binding = await workspace_service.get_local_execution_binding()
+ return ApiKeyIdentity(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ api_key_uuid='global-oss-api-key',
+ permissions=frozenset(permission.value for permission in Permission),
+ )
- async def verify_api_key(self, key: str) -> bool:
- """Verify if an API key is valid.
+ if not secret.startswith('lbk_'):
+ return None
+ secret_hash = self._hash_secret(secret)
+ current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
+ discovery_uow = getattr(self.ap.persistence_mgr, 'api_key_discovery_uow', None)
+ if current_session() is None and callable(discovery_uow):
+ async with discovery_uow(secret_hash) as discovery:
+ key = await discovery.session.scalar(
+ sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key_hash == secret_hash)
+ )
+ else:
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key_hash == secret_hash)
+ )
+ key = result.first()
+ if key is None:
+ return None
+ discovered_workspace_uuid = key.workspace_uuid
+ discovered_key_id = key.id
+ now = self._utcnow()
- A key is accepted if it matches the global API key configured in
- ``config.yaml`` (``api.global_api_key``) — which requires no login
- session and no database record — or if it matches a key created via
- the web UI (stored in the database, prefixed with ``lbk_``).
- """
- if not isinstance(key, str) or not key:
- return False
+ async def bind_and_record_use() -> tuple[typing.Any, typing.Any] | None:
+ # Re-read inside the tenant transaction. A revoke/expiry racing
+ # discovery must not result in an authenticated identity.
+ active_session = current_session()
+ if active_session is not None:
+ scoped_key = await active_session.scalar(
+ sqlalchemy.select(apikey.ApiKey).where(
+ apikey.ApiKey.id == discovered_key_id,
+ apikey.ApiKey.workspace_uuid == discovered_workspace_uuid,
+ apikey.ApiKey.key_hash == secret_hash,
+ )
+ )
+ else: # compatibility for isolated service tests
+ scoped_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(apikey.ApiKey).where(
+ apikey.ApiKey.id == discovered_key_id,
+ apikey.ApiKey.workspace_uuid == discovered_workspace_uuid,
+ apikey.ApiKey.key_hash == secret_hash,
+ )
+ )
+ scoped_key = scoped_result.first()
+ if scoped_key is None or scoped_key.status != apikey.ApiKeyStatus.ACTIVE.value:
+ return None
+ if scoped_key.expires_at is not None and scoped_key.expires_at <= now:
+ return None
- # 1. Global API key from config.yaml (no DB lookup, no login state).
- # Note: config completion only backfills top-level keys, so existing
- # installs may not have this key — access it defensively.
- global_api_key = self.ap.instance_config.data.get('api', {}).get('global_api_key', '')
- if global_api_key and secrets.compare_digest(key, global_api_key):
- return True
+ binding = await self.ap.workspace_service.get_execution_binding(discovered_workspace_uuid)
+ updated = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(apikey.ApiKey)
+ .where(
+ apikey.ApiKey.id == scoped_key.id,
+ apikey.ApiKey.workspace_uuid == discovered_workspace_uuid,
+ apikey.ApiKey.key_hash == secret_hash,
+ apikey.ApiKey.status == apikey.ApiKeyStatus.ACTIVE.value,
+ )
+ .values(last_used_at=now)
+ .returning(apikey.ApiKey.id)
+ )
+ # Authentication and revocation race on this atomic predicate. If
+ # revoke won, no active row is returned and the stale object read
+ # above must never become an authenticated identity.
+ if updated.scalar_one_or_none() is None:
+ return None
+ return binding, scoped_key
- # 2. Web-UI-created keys are stored in the database and prefixed lbk_.
- if not key.startswith('lbk_'):
- return False
-
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(apikey.ApiKey).where(apikey.ApiKey.key == key)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if current_session() is None and callable(tenant_uow):
+ async with tenant_uow(discovered_workspace_uuid):
+ bound = await bind_and_record_use()
+ else:
+ bound = await bind_and_record_use()
+ if bound is None:
+ return None
+ binding, scoped_key = bound
+ raw_scopes = list(scoped_key.scopes or [])
+ permissions = (
+ frozenset(permission.value for permission in Permission)
+ if '*' in raw_scopes
+ else frozenset(self._normalize_scopes(raw_scopes))
+ )
+ return ApiKeyIdentity(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ api_key_uuid=scoped_key.uuid,
+ permissions=permissions,
)
- key_obj = result.first()
- return key_obj is not None
+ async def verify_api_key(self, secret: str) -> bool:
+ try:
+ return await self.authenticate_api_key(secret) is not None
+ except Exception:
+ return False
- async def delete_api_key(self, key_id: int) -> None:
- """Delete an API key"""
- await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(apikey.ApiKey).where(apikey.ApiKey.id == key_id))
-
- async def update_api_key(self, key_id: int, name: str = None, description: str = None) -> None:
- """Update an API key's metadata (name, description)"""
- update_data = {}
- if name is not None:
- update_data['name'] = name
- if description is not None:
- update_data['description'] = description
-
- if update_data:
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data)
+ async def delete_api_key(self, context: TenantContext, key_id: int) -> None:
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(apikey.ApiKey)
+ .where(apikey.ApiKey.id == key_id)
+ .values(status=apikey.ApiKeyStatus.REVOKED.value),
+ apikey.ApiKey,
+ context,
)
+ )
+ if getattr(result, 'rowcount', 0) == 0:
+ raise WorkspaceNotFoundError('API key not found')
+
+ async def update_api_key(
+ self,
+ context: TenantContext,
+ key_id: int,
+ name: str | None = None,
+ description: str | None = None,
+ ) -> None:
+ update_data: dict[str, typing.Any] = {}
+ if name is not None:
+ normalized_name = name.strip()
+ if not normalized_name:
+ raise ValueError('Name is required')
+ update_data['name'] = normalized_name
+ if description is not None:
+ update_data['description'] = description.strip()
+ if not update_data:
+ return
+
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.id == key_id).values(**update_data),
+ apikey.ApiKey,
+ context,
+ )
+ )
+ if getattr(result, 'rowcount', 0) == 0:
+ raise WorkspaceNotFoundError('API key not found')
diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py
index 995267cf5..9d9211be3 100644
--- a/src/langbot/pkg/api/http/service/bot.py
+++ b/src/langbot/pkg/api/http/service/bot.py
@@ -2,11 +2,12 @@ from __future__ import annotations
import uuid
import sqlalchemy
-import typing
from ....core import app
from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
+from ....workspace.errors import WorkspaceNotFoundError
+from .tenant import TenantContext, require_workspace_uuid, scope_statement
class BotService:
@@ -17,9 +18,11 @@ class BotService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def get_bots(self, include_secret: bool = True) -> list[dict]:
+ async def get_bots(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""获取所有机器人"""
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(sqlalchemy.select(persistence_bot.Bot), persistence_bot.Bot, context)
+ )
bots = result.all()
@@ -29,10 +32,14 @@ class BotService:
return [self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns) for bot in bots]
- async def get_bot(self, bot_uuid: str, include_secret: bool = True) -> dict | None:
+ async def get_bot(self, context: TenantContext, bot_uuid: str, include_secret: bool = False) -> dict | None:
"""获取机器人"""
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
+ scope_statement(
+ sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid),
+ persistence_bot.Bot,
+ context,
+ )
)
bot = result.first()
@@ -46,15 +53,20 @@ class BotService:
return self.ap.persistence_mgr.serialize_model(persistence_bot.Bot, bot, masked_columns)
- async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True) -> dict:
+ async def get_runtime_bot_info(
+ self,
+ context: TenantContext,
+ bot_uuid: str,
+ include_secret: bool = False,
+ ) -> dict:
"""获取机器人运行时信息"""
- persistence_bot = await self.get_bot(bot_uuid, include_secret)
+ persistence_bot = await self.get_bot(context, bot_uuid, include_secret)
if persistence_bot is None:
- raise Exception('Bot not found')
+ raise WorkspaceNotFoundError('Bot not found')
adapter_runtime_values = {}
- runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
+ runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
@@ -86,22 +98,29 @@ class BotService:
return persistence_bot
- async def create_bot(self, bot_data: dict) -> str:
+ async def create_bot(self, context: TenantContext, bot_data: dict) -> str:
"""Create bot"""
+ workspace_uuid = require_workspace_uuid(context)
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_bots = limitation.get('max_bots', -1)
if max_bots >= 0:
- existing_bots = await self.get_bots()
+ existing_bots = await self.get_bots(context)
if len(existing_bots) >= max_bots:
raise ValueError(f'Maximum number of bots ({max_bots}) reached')
# TODO: 检查配置信息格式
+ bot_data = bot_data.copy()
bot_data['uuid'] = str(uuid.uuid4())
+ bot_data['workspace_uuid'] = workspace_uuid
# bind the most recently updated pipeline if any exist
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_pipeline.LegacyPipeline)
+ scope_statement(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline),
+ persistence_pipeline.LegacyPipeline,
+ context,
+ )
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
.limit(1)
)
@@ -112,61 +131,84 @@ class BotService:
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
- bot = await self.get_bot(bot_data['uuid'])
+ bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
- await self.ap.platform_mgr.load_bot(bot)
+ await self.ap.platform_mgr.load_bot(context, bot)
return bot_data['uuid']
- async def update_bot(self, bot_uuid: str, bot_data: dict) -> None:
+ async def update_bot(self, context: TenantContext, bot_uuid: str, bot_data: dict) -> None:
"""Update bot"""
+ workspace_uuid = require_workspace_uuid(context)
update_data = bot_data.copy()
- if 'uuid' in update_data:
- del update_data['uuid']
+ update_data.pop('uuid', None)
+ update_data.pop('workspace_uuid', None)
# set use_pipeline_name
if 'use_pipeline_uuid' in update_data:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
- persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
+ scope_statement(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
+ ),
+ persistence_pipeline.LegacyPipeline,
+ workspace_uuid,
)
)
pipeline = result.first()
if pipeline is not None:
update_data['use_pipeline_name'] = pipeline.name
else:
- raise Exception('Pipeline not found')
+ raise WorkspaceNotFoundError('Pipeline not found')
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid),
+ persistence_bot.Bot,
+ workspace_uuid,
+ )
)
- await self.ap.platform_mgr.remove_bot(bot_uuid)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Bot not found')
+ await self.ap.platform_mgr.remove_bot(context, bot_uuid)
# select from db
- bot = await self.get_bot(bot_uuid)
+ bot = await self.get_bot(context, bot_uuid, include_secret=True)
- runtime_bot = await self.ap.platform_mgr.load_bot(bot)
+ runtime_bot = await self.ap.platform_mgr.load_bot(context, bot)
if runtime_bot.enable:
await runtime_bot.run()
# update all conversation that use this bot
for session in self.ap.sess_mgr.session_list:
- if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid:
+ if (
+ session.using_conversation is not None
+ and session.using_conversation.bot_uuid == bot_uuid
+ and getattr(session, 'workspace_uuid', workspace_uuid) == workspace_uuid
+ ):
session.using_conversation = None
- async def delete_bot(self, bot_uuid: str) -> None:
+ async def delete_bot(self, context: TenantContext, bot_uuid: str) -> None:
"""Delete bot"""
- await self.ap.platform_mgr.remove_bot(bot_uuid)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid),
+ persistence_bot.Bot,
+ context,
+ )
)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Bot not found')
+ await self.ap.platform_mgr.remove_bot(context, bot_uuid)
async def list_event_logs(
- self, bot_uuid: str, from_index: int, max_count: int
- ) -> typing.Tuple[list[dict], int, int, int]:
- runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
+ self, context: TenantContext, bot_uuid: str, from_index: int, max_count: int
+ ) -> tuple[list[dict], int]:
+ if await self.get_bot(context, bot_uuid, include_secret=False) is None:
+ raise WorkspaceNotFoundError('Bot not found')
+ runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
@@ -174,7 +216,14 @@ class BotService:
return [log.to_json() for log in logs], total_count
- async def send_message(self, bot_uuid: str, target_type: str, target_id: str, message_chain_data: dict) -> None:
+ async def send_message(
+ self,
+ context: TenantContext,
+ bot_uuid: str,
+ target_type: str,
+ target_id: str,
+ message_chain_data: dict,
+ ) -> None:
"""Send message to a specific target via bot
Args:
@@ -183,11 +232,14 @@ class BotService:
target_id: The ID of the target
message_chain_data: The message chain data in dict format
"""
+ if await self.get_bot(context, bot_uuid, include_secret=False) is None:
+ raise WorkspaceNotFoundError('Bot not found')
+
# Import here to avoid circular imports
import langbot_plugin.api.entities.builtin.platform.message as platform_message
# Get runtime bot
- runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
+ runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is None:
raise Exception(f'Bot not found: {bot_uuid}')
@@ -202,19 +254,29 @@ class BotService:
# ============ Bot Admins ============
- async def get_bot_admins(self, bot_uuid: str) -> list[dict]:
+ async def get_bot_admins(self, context: TenantContext, bot_uuid: str) -> list[dict]:
from ....entity.persistence import bot as persistence_bot
+ if await self.get_bot(context, bot_uuid, include_secret=False) is None:
+ raise WorkspaceNotFoundError('Bot not found')
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid)
+ scope_statement(
+ sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid),
+ persistence_bot.BotAdmin,
+ context,
+ )
)
return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()]
- async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
+ async def add_bot_admin(self, context: TenantContext, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
from ....entity.persistence import bot as persistence_bot
+ workspace_uuid = require_workspace_uuid(context)
+ if await self.get_bot(context, bot_uuid, include_secret=False) is None:
+ raise WorkspaceNotFoundError('Bot not found')
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_bot.BotAdmin).values(
+ workspace_uuid=workspace_uuid,
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
@@ -222,12 +284,18 @@ class BotService:
)
return result.inserted_primary_key[0]
- async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None:
+ async def delete_bot_admin(self, context: TenantContext, bot_uuid: str, admin_id: int) -> None:
from ....entity.persistence import bot as persistence_bot
+ if await self.get_bot(context, bot_uuid, include_secret=False) is None:
+ raise WorkspaceNotFoundError('Bot not found')
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_bot.BotAdmin).where(
- persistence_bot.BotAdmin.bot_uuid == bot_uuid,
- persistence_bot.BotAdmin.id == admin_id,
+ scope_statement(
+ sqlalchemy.delete(persistence_bot.BotAdmin).where(
+ persistence_bot.BotAdmin.bot_uuid == bot_uuid,
+ persistence_bot.BotAdmin.id == admin_id,
+ ),
+ persistence_bot.BotAdmin,
+ context,
)
)
diff --git a/src/langbot/pkg/api/http/service/knowledge.py b/src/langbot/pkg/api/http/service/knowledge.py
index 48cb7cace..06ba047b0 100644
--- a/src/langbot/pkg/api/http/service/knowledge.py
+++ b/src/langbot/pkg/api/http/service/knowledge.py
@@ -2,8 +2,13 @@ from __future__ import annotations
import sqlalchemy
+from ....api.http.authz import WorkspaceRequiredError
+from ....api.http.context import ExecutionContext, RequestContext
from ....core import app
from ....entity.persistence import rag as persistence_rag
+from ....workspace.errors import WorkspaceNotFoundError
+from .secrets import redact_secrets, restore_secret_placeholders
+from .tenant import TenantContext, require_workspace_uuid
class KnowledgeService:
@@ -14,34 +19,69 @@ class KnowledgeService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def get_knowledge_bases(self) -> list[dict]:
+ @staticmethod
+ def _execution_context(context: RequestContext | ExecutionContext) -> ExecutionContext:
+ if isinstance(context, RequestContext):
+ return ExecutionContext.from_request(context)
+ if isinstance(context, ExecutionContext):
+ return context
+ raise WorkspaceRequiredError('RequestContext or ExecutionContext is required')
+
+ async def get_knowledge_bases(self, context: TenantContext, *, include_secret: bool = False) -> list[dict]:
"""获取所有知识库"""
- return await self.ap.rag_mgr.get_all_knowledge_base_details()
+ require_workspace_uuid(context)
+ knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
+ return knowledge_bases if include_secret else [redact_secrets(base) for base in knowledge_bases]
- async def get_knowledge_base(self, kb_uuid: str) -> dict | None:
+ async def get_knowledge_base(
+ self,
+ context: TenantContext,
+ kb_uuid: str,
+ *,
+ include_secret: bool = False,
+ ) -> dict | None:
"""获取知识库"""
- return await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid)
+ require_workspace_uuid(context)
+ knowledge_base = await self.ap.rag_mgr.get_knowledge_base_details(context, kb_uuid)
+ if knowledge_base is None or include_secret:
+ return knowledge_base
+ return redact_secrets(knowledge_base)
- async def create_knowledge_base(self, kb_data: dict) -> str:
+ async def create_knowledge_base(
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_data: dict,
+ ) -> str:
"""创建知识库"""
+ require_workspace_uuid(context)
# In new architecture, we delegate entirely to RAGManager which uses plugins.
# Legacy internal KB creation is removed.
+ limitation = (
+ getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {})
+ )
+ max_knowledge_bases = limitation.get('max_knowledge_bases', -1)
+ if max_knowledge_bases >= 0:
+ knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
+ if len(knowledge_bases) >= max_knowledge_bases:
+ raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached')
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
if not knowledge_engine_plugin_id:
raise ValueError('knowledge_engine_plugin_id is required')
- creation_settings = kb_data.get('creation_settings', {})
+ creation_settings = restore_secret_placeholders(kb_data.get('creation_settings', {}))
retrieval_settings = kb_data.get('retrieval_settings', {})
# Validate required fields based on plugin's creation_schema and retrieval_schema
await self._validate_schema_required_fields(
+ context,
knowledge_engine_plugin_id,
creation_settings,
retrieval_settings,
)
kb = await self.ap.rag_mgr.create_knowledge_base(
+ context,
name=kb_data.get('name', 'Untitled'),
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
creation_settings=creation_settings,
@@ -52,6 +92,7 @@ class KnowledgeService:
async def _validate_schema_required_fields(
self,
+ context: RequestContext | ExecutionContext,
plugin_id: str,
creation_settings: dict,
retrieval_settings: dict,
@@ -69,7 +110,11 @@ class KnowledgeService:
Raises:
ValueError: If any required field is missing or empty.
"""
+ if not self.ap.plugin_connector.is_enable_plugin:
+ return
+
# Validate creation_schema
+ await self.ap.plugin_connector.require_workspace_context(context)
try:
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
@@ -79,6 +124,7 @@ class KnowledgeService:
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
# Validate retrieval_schema
+ await self.ap.plugin_connector.require_workspace_context(context)
try:
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
@@ -151,8 +197,16 @@ class KnowledgeService:
)
raise ValueError(f'{field_label} is required ({context}.{field_name})')
- async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
+ async def update_knowledge_base(
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_uuid: str,
+ kb_data: dict,
+ ) -> None:
"""更新知识库"""
+ workspace_uuid = require_workspace_uuid(context)
+ if await self.get_knowledge_base(context, kb_uuid) is None:
+ raise WorkspaceNotFoundError('Knowledge base not found')
# Filter to only mutable fields
filtered_data = {k: v for k, v in kb_data.items() if k in persistence_rag.KnowledgeBase.MUTABLE_FIELDS}
@@ -162,17 +216,18 @@ class KnowledgeService:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.KnowledgeBase)
.values(filtered_data)
+ .where(persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid)
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
- await self.ap.rag_mgr.remove_knowledge_base_from_runtime(kb_uuid)
+ await self.ap.rag_mgr.remove_knowledge_base_from_runtime(context, kb_uuid)
- kb = await self.get_knowledge_base(kb_uuid)
+ kb = await self.get_knowledge_base(context, kb_uuid, include_secret=True)
if kb is None:
- raise Exception('Knowledge base not found after update')
+ raise WorkspaceNotFoundError('Knowledge base not found')
- await self.ap.rag_mgr.load_knowledge_base(kb)
+ await self.ap.rag_mgr.load_knowledge_base(context, kb)
- async def _check_doc_capability(self, kb_uuid: str, operation: str) -> None:
+ async def _check_doc_capability(self, context: TenantContext, kb_uuid: str, operation: str) -> None:
"""Check if the KB's Knowledge Engine supports document operations.
Args:
@@ -182,104 +237,145 @@ class KnowledgeService:
Raises:
Exception: If the KB does not support doc_ingestion.
"""
- kb_info = await self.ap.rag_mgr.get_knowledge_base_details(kb_uuid)
+ kb_info = await self.ap.rag_mgr.get_knowledge_base_details(context, kb_uuid)
if not kb_info:
- raise Exception('Knowledge base not found')
+ raise WorkspaceNotFoundError('Knowledge base not found')
capabilities = kb_info.get('knowledge_engine', {}).get('capabilities', [])
if 'doc_ingestion' not in capabilities:
raise Exception(f'This knowledge base does not support {operation}')
- async def store_file(self, kb_uuid: str, file_id: str, parser_plugin_id: str | None = None) -> str:
+ async def store_file(
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_uuid: str,
+ file_id: str,
+ parser_plugin_id: str | None = None,
+ ) -> str:
"""存储文件"""
- runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
+ execution_context = self._execution_context(context)
+ runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
if runtime_kb is None:
- raise Exception('Knowledge base not found')
+ raise WorkspaceNotFoundError('Knowledge base not found')
- await self._check_doc_capability(kb_uuid, 'document upload')
+ await self._check_doc_capability(context, kb_uuid, 'document upload')
- result = await runtime_kb.store_file(file_id, parser_plugin_id=parser_plugin_id)
+ result = await runtime_kb.store_file(execution_context, file_id, parser_plugin_id=parser_plugin_id)
# Update the KB's updated_at timestamp
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.KnowledgeBase)
.values(updated_at=sqlalchemy.func.now())
+ .where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
return result
async def retrieve_knowledge_base(
- self, kb_uuid: str, query: str, retrieval_settings: dict | None = None
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_uuid: str,
+ query: str,
+ retrieval_settings: dict | None = None,
) -> list[dict]:
"""检索知识库"""
- runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
+ execution_context = self._execution_context(context)
+ runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
if runtime_kb is None:
- raise Exception('Knowledge base not found')
+ raise WorkspaceNotFoundError('Knowledge base not found')
# Pass retrieval_settings
- results = await runtime_kb.retrieve(query, settings=retrieval_settings)
+ results = await runtime_kb.retrieve(execution_context, query, settings=retrieval_settings)
return [result.model_dump() for result in results]
- async def get_files_by_knowledge_base(self, kb_uuid: str) -> list[dict]:
+ async def get_files_by_knowledge_base(self, context: TenantContext, kb_uuid: str) -> list[dict]:
"""获取知识库文件"""
+ workspace_uuid = require_workspace_uuid(context)
+ if await self.get_knowledge_base(context, kb_uuid) is None:
+ raise WorkspaceNotFoundError('Knowledge base not found')
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid)
+ sqlalchemy.select(persistence_rag.File)
+ .where(persistence_rag.File.workspace_uuid == workspace_uuid)
+ .where(persistence_rag.File.kb_id == kb_uuid)
)
files = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_rag.File, file) for file in files]
- async def delete_file(self, kb_uuid: str, file_id: str) -> None:
+ async def delete_file(
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_uuid: str,
+ file_id: str,
+ ) -> None:
"""删除文件"""
- runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
+ execution_context = self._execution_context(context)
+ runtime_kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
if runtime_kb is None:
- raise Exception('Knowledge base not found')
+ raise WorkspaceNotFoundError('Knowledge base not found')
- await self._check_doc_capability(kb_uuid, 'document deletion')
+ await self._check_doc_capability(context, kb_uuid, 'document deletion')
- await runtime_kb.delete_file(file_id)
+ await runtime_kb.delete_file(execution_context, file_id)
# Update the KB's updated_at timestamp
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_rag.KnowledgeBase)
.values(updated_at=sqlalchemy.func.now())
+ .where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
.where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
- async def delete_knowledge_base(self, kb_uuid: str) -> None:
+ async def delete_knowledge_base(
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_uuid: str,
+ ) -> None:
"""删除知识库"""
- # Delete from DB first to commit the deletion, then clean up runtime/plugin (best-effort)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
- )
+ workspace_uuid = require_workspace_uuid(context)
+ if await self.get_knowledge_base(context, kb_uuid) is None:
+ raise WorkspaceNotFoundError('Knowledge base not found')
# delete files
# NOTE: Chunk cleanup is for legacy (pre-plugin) KBs that stored chunks locally.
# For plugin-based Knowledge Engines, the Chunk table is not populated, so this is a no-op.
files = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_rag.File).where(persistence_rag.File.kb_id == kb_uuid)
+ sqlalchemy.select(persistence_rag.File)
+ .where(persistence_rag.File.workspace_uuid == workspace_uuid)
+ .where(persistence_rag.File.kb_id == kb_uuid)
)
for file in files:
# delete chunks
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_rag.Chunk).where(persistence_rag.Chunk.file_id == file.uuid)
+ sqlalchemy.delete(persistence_rag.Chunk)
+ .where(persistence_rag.Chunk.workspace_uuid == workspace_uuid)
+ .where(persistence_rag.Chunk.file_id == file.uuid)
)
# delete file
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file.uuid)
+ sqlalchemy.delete(persistence_rag.File)
+ .where(persistence_rag.File.workspace_uuid == workspace_uuid)
+ .where(persistence_rag.File.uuid == file.uuid)
)
- # Remove from runtime and notify plugin (best-effort, DB is already cleaned up)
- await self.ap.rag_mgr.delete_knowledge_base(kb_uuid)
+ # Remove from runtime and notify plugin before deleting the owning row.
+ await self.ap.rag_mgr.delete_knowledge_base(context, kb_uuid)
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.delete(persistence_rag.KnowledgeBase)
+ .where(persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid)
+ .where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
+ )
# ================= Knowledge Engine Discovery =================
- async def list_knowledge_engines(self) -> list[dict]:
+ async def list_knowledge_engines(self, context: TenantContext) -> list[dict]:
"""List all available Knowledge Engines from plugins."""
+ require_workspace_uuid(context)
engines = []
if not self.ap.plugin_connector.is_enable_plugin:
return engines
+ await self.ap.plugin_connector.require_workspace_context(context)
# Get KnowledgeEngine plugins
try:
@@ -290,10 +386,12 @@ class KnowledgeService:
return engines
- async def list_parsers(self, mime_type: str | None = None) -> list[dict]:
+ async def list_parsers(self, context: TenantContext, mime_type: str | None = None) -> list[dict]:
"""List available parsers, optionally filtered by MIME type."""
+ require_workspace_uuid(context)
if not self.ap.plugin_connector.is_enable_plugin:
return []
+ await self.ap.plugin_connector.require_workspace_context(context)
try:
parsers = await self.ap.plugin_connector.list_parsers()
if mime_type:
@@ -303,16 +401,24 @@ class KnowledgeService:
self.ap.logger.warning(f'Failed to list parsers: {e}')
return []
- async def get_engine_creation_schema(self, plugin_id: str) -> dict:
+ async def get_engine_creation_schema(self, context: TenantContext, plugin_id: str) -> dict:
"""Get creation settings schema for a specific Knowledge Engine."""
+ require_workspace_uuid(context)
+ if not self.ap.plugin_connector.is_enable_plugin:
+ return {}
+ await self.ap.plugin_connector.require_workspace_context(context)
try:
return await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
except Exception as e:
self.ap.logger.warning(f'Failed to get creation schema for {plugin_id}: {e}')
return {}
- async def get_engine_retrieval_schema(self, plugin_id: str) -> dict:
+ async def get_engine_retrieval_schema(self, context: TenantContext, plugin_id: str) -> dict:
"""Get retrieval settings schema for a specific Knowledge Engine."""
+ require_workspace_uuid(context)
+ if not self.ap.plugin_connector.is_enable_plugin:
+ return {}
+ await self.ap.plugin_connector.require_workspace_context(context)
try:
return await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
except Exception as e:
diff --git a/src/langbot/pkg/api/http/service/maintenance.py b/src/langbot/pkg/api/http/service/maintenance.py
index fa7359cba..0c61618c6 100644
--- a/src/langbot/pkg/api/http/service/maintenance.py
+++ b/src/langbot/pkg/api/http/service/maintenance.py
@@ -1,6 +1,8 @@
from __future__ import annotations
+import asyncio
import datetime
+import functools
import os
import re
from pathlib import Path
@@ -11,11 +13,36 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import bstorage as persistence_bstorage
from ....entity.persistence import monitoring as persistence_monitoring
+from ..authz import WorkspaceRequiredError
+from ..context import ExecutionContext
+from .tenant import TenantContext, require_workspace_uuid
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
DEFAULT_LOG_RETENTION_DAYS = 3
+DEFAULT_MAX_FILES_PER_RUN = 1000
+HARD_MAX_FILES_PER_RUN = 10000
+UPLOAD_OWNER_TYPES = ('upload_image', 'upload_document', 'upload')
+
+
+def _workspace_scope(method):
+ """Bind maintenance work to a Workspace without spanning external I/O."""
+
+ @functools.wraps(method)
+ async def wrapped(self, context, *args, **kwargs):
+ workspace_uuid = require_workspace_uuid(context)
+ persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
+ tenant_scope = getattr(persistence_mgr, 'tenant_scope', None)
+ cloud_runtime = getattr(getattr(persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ if not callable(tenant_scope):
+ raise RuntimeError('Cloud maintenance requires an explicit tenant scope')
+ async with tenant_scope(workspace_uuid):
+ return await method(self, context, *args, **kwargs)
+ return await method(self, context, *args, **kwargs)
+
+ return wrapped
class MaintenanceService:
@@ -26,7 +53,22 @@ class MaintenanceService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def cleanup_expired_files(self) -> dict[str, int]:
+ def _max_files_per_run(self) -> int:
+ cleanup_cfg = (
+ getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('storage', {}).get('cleanup', {})
+ )
+ value = self._positive_int(
+ cleanup_cfg.get('max_files_per_run', DEFAULT_MAX_FILES_PER_RUN),
+ DEFAULT_MAX_FILES_PER_RUN,
+ 'storage.cleanup.max_files_per_run',
+ )
+ return min(value, HARD_MAX_FILES_PER_RUN)
+
+ @_workspace_scope
+ async def cleanup_expired_files(self, context: ExecutionContext) -> dict[str, int]:
+ if not isinstance(context, ExecutionContext):
+ raise WorkspaceRequiredError('Storage cleanup requires an ExecutionContext')
+ require_workspace_uuid(context)
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
upload_retention_days = self._positive_int(
cleanup_cfg.get('uploaded_file_retention_days'),
@@ -40,11 +82,17 @@ class MaintenanceService:
)
return {
- 'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
- 'log_files': self._cleanup_expired_log_files(log_retention_days),
+ 'uploaded_files': await self._cleanup_expired_uploaded_files(context, upload_retention_days),
+ 'log_files': await asyncio.to_thread(
+ self._cleanup_expired_log_files,
+ log_retention_days,
+ )
+ if await self._is_oss_singleton(context)
+ else 0,
}
- async def get_storage_analysis(self) -> dict[str, Any]:
+ async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]:
+ require_workspace_uuid(context)
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
upload_retention_days = self._positive_int(
cleanup_cfg.get('uploaded_file_retention_days'),
@@ -62,32 +110,34 @@ class MaintenanceService:
database_path = (
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
)
- roots: list[tuple[str, Path | None]] = [
- ('database', database_path),
- ('logs', Path('data/logs')),
- ('storage', Path('data/storage')),
- ('vector_store', Path('data/chroma')),
- ('plugins', Path('data/plugins')),
- ('mcp', Path('data/mcp')),
- ('temp', Path('data/temp')),
- ]
+ is_oss_singleton = await self._is_oss_singleton(context)
+ if is_oss_singleton:
+ roots: list[tuple[str, Path | None]] = [
+ ('database', database_path),
+ ('logs', Path('data/logs')),
+ ('storage', Path('data/storage')),
+ ('vector_store', Path('data/chroma')),
+ ('plugins', Path('data/plugins')),
+ ('mcp', Path('data/mcp')),
+ ('temp', Path('data/temp')),
+ ]
+ else:
+ scoped_storage_path = Path('data/storage') / self.ap.storage_mgr.scoped_prefix(context)
+ roots = [('storage', scoped_storage_path)]
- sections = []
- for key, path in roots:
- sections.append(
- {
- 'key': key,
- 'path': str(path) if path else '',
- 'exists': path.exists() if path else False,
- 'size_bytes': self._path_size(path) if path else 0,
- 'file_count': self._file_count(path) if path else 0,
- }
+ sections = await asyncio.to_thread(self._collect_sections, roots)
+
+ monitoring_counts = await self._monitoring_counts(context)
+ binary_storage = await self._binary_storage_stats(context)
+ upload_candidates = await self._expired_uploaded_candidates(context, upload_retention_days)
+ log_candidates = (
+ await asyncio.to_thread(
+ self._expired_log_candidates,
+ log_retention_days,
)
-
- monitoring_counts = await self._monitoring_counts()
- binary_storage = await self._binary_storage_stats()
- upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
- log_candidates = self._expired_log_candidates(log_retention_days)
+ if is_oss_singleton
+ else []
+ )
return {
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
@@ -105,70 +155,156 @@ class MaintenanceService:
'uploaded_files': upload_candidates,
'log_files': log_candidates,
},
- 'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
+ 'tasks': self.ap.task_mgr.get_stats() if is_oss_singleton and self.ap.task_mgr else {},
}
- async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
+ def _collect_sections(
+ self,
+ roots: list[tuple[str, Path | None]],
+ ) -> list[dict[str, Any]]:
+ sections = []
+ for key, path in roots:
+ sections.append(
+ {
+ 'key': key,
+ 'path': str(path) if path else '',
+ 'exists': path.exists() if path else False,
+ 'size_bytes': self._path_size(path) if path else 0,
+ 'file_count': self._file_count(path) if path else 0,
+ }
+ )
+ return sections
+
+ async def _is_oss_singleton(self, context: TenantContext) -> bool:
+ try:
+ await self.ap.workspace_service.get_local_execution_binding(
+ require_workspace_uuid(context),
+ expected_generation=getattr(context, 'placement_generation', None),
+ )
+ except Exception:
+ return False
+ return True
+
+ async def _cleanup_expired_uploaded_files(
+ self,
+ context: ExecutionContext,
+ retention_days: int,
+ ) -> int:
provider = self.ap.storage_mgr.storage_provider
provider_name = provider.__class__.__name__
if provider_name == 'LocalStorageProvider':
- candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
- deleted = 0
- for item in candidates:
- try:
- os.remove(item['path'])
- deleted += 1
- except FileNotFoundError:
- pass
- except Exception as e:
- self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
- return deleted
+ candidates = await asyncio.to_thread(
+ self._expired_local_upload_candidates,
+ context,
+ retention_days,
+ True,
+ )
+ return await asyncio.to_thread(
+ self._delete_local_candidates,
+ candidates,
+ )
if provider_name == 'S3StorageProvider':
- return await self._cleanup_expired_s3_uploaded_files(retention_days)
+ return await self._cleanup_expired_s3_uploaded_files(context, retention_days)
return 0
- async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
+ async def _expired_uploaded_candidates(
+ self,
+ context: TenantContext,
+ retention_days: int,
+ ) -> list[dict[str, Any]]:
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
if provider_name == 'LocalStorageProvider':
- return self._expired_local_upload_candidates(retention_days)
+ return await asyncio.to_thread(
+ self._expired_local_upload_candidates,
+ context,
+ retention_days,
+ )
if provider_name == 'S3StorageProvider':
- return await self._expired_s3_upload_candidates(retention_days)
+ return await self._expired_s3_upload_candidates(context, retention_days)
return []
- async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
+ async def _cleanup_expired_s3_uploaded_files(
+ self,
+ context: ExecutionContext,
+ retention_days: int,
+ ) -> int:
provider = self.ap.storage_mgr.storage_provider
- candidates = await self._expired_s3_upload_candidates(retention_days)
+ candidates = await self._expired_s3_upload_candidates(context, retention_days)
deleted = 0
for item in candidates:
await provider.delete(item['key'])
deleted += 1
return deleted
- async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
+ async def _expired_s3_upload_candidates(
+ self,
+ context: TenantContext,
+ retention_days: int,
+ ) -> list[dict[str, Any]]:
+ provider = self.ap.storage_mgr.storage_provider
+ run_io = getattr(provider, '_run_io', None)
+ if callable(run_io):
+ return await run_io(
+ self._expired_s3_upload_candidates_sync,
+ context,
+ retention_days,
+ )
+ return await asyncio.to_thread(
+ self._expired_s3_upload_candidates_sync,
+ context,
+ retention_days,
+ )
+
+ def _expired_s3_upload_candidates_sync(
+ self,
+ context: TenantContext,
+ retention_days: int,
+ ) -> list[dict[str, Any]]:
provider = self.ap.storage_mgr.storage_provider
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
candidates = []
+ max_candidates = self._max_files_per_run()
paginator = provider.s3_client.get_paginator('list_objects_v2')
- for page in paginator.paginate(Bucket=provider.bucket_name):
- for obj in page.get('Contents', []):
- key = obj.get('Key', '')
- last_modified = obj.get('LastModified')
- if not self._is_uploaded_file_key(key):
- continue
- if last_modified and last_modified < cutoff:
- candidates.append(
- {
- 'key': key,
- 'size_bytes': obj.get('Size', 0),
- 'modified_at': last_modified.isoformat(),
- }
- )
+ seen_prefixes: set[str] = set()
+ for owner_type in UPLOAD_OWNER_TYPES:
+ prefix = self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type)
+ if prefix in seen_prefixes:
+ continue
+ seen_prefixes.add(prefix)
+ for page in paginator.paginate(Bucket=provider.bucket_name, Prefix=prefix):
+ for obj in page.get('Contents', []):
+ key = obj.get('Key', '')
+ last_modified = obj.get('LastModified')
+ if not self._is_uploaded_file_key(context, key):
+ continue
+ if last_modified and last_modified < cutoff:
+ candidates.append(
+ {
+ 'key': key,
+ 'size_bytes': obj.get('Size', 0),
+ 'modified_at': last_modified.isoformat(),
+ }
+ )
+ if len(candidates) >= max_candidates:
+ return candidates
return candidates
+ def _delete_local_candidates(self, candidates: list[dict[str, Any]]) -> int:
+ deleted = 0
+ for item in candidates:
+ try:
+ os.remove(item['path'])
+ deleted += 1
+ except FileNotFoundError:
+ pass
+ except Exception as e:
+ self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
+ return deleted
+
def _cleanup_expired_log_files(self, retention_days: int) -> int:
deleted = 0
for item in self._expired_log_candidates(retention_days, include_paths=True):
@@ -182,28 +318,42 @@ class MaintenanceService:
return deleted
def _expired_local_upload_candidates(
- self, retention_days: int, include_paths: bool = False
+ self,
+ context: TenantContext,
+ retention_days: int,
+ include_paths: bool = False,
) -> list[dict[str, Any]]:
storage_root = Path('data/storage')
- if not storage_root.exists():
- return []
-
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
candidates = []
- for entry in storage_root.iterdir():
- if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
+ max_candidates = self._max_files_per_run()
+ seen_roots: set[Path] = set()
+ for owner_type in UPLOAD_OWNER_TYPES:
+ scoped_root = storage_root / self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type)
+ if scoped_root in seen_roots:
continue
- stat = entry.stat()
- if stat.st_mtime >= cutoff:
+ seen_roots.add(scoped_root)
+ if not scoped_root.exists():
continue
- item = {
- 'key': entry.name,
- 'size_bytes': stat.st_size,
- 'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
- }
- if include_paths:
- item['path'] = str(entry)
- candidates.append(item)
+ for entry in scoped_root.rglob('*'):
+ if not entry.is_file():
+ continue
+ stat = entry.stat()
+ if stat.st_mtime >= cutoff:
+ continue
+ item = {
+ 'key': entry.relative_to(storage_root).as_posix(),
+ 'size_bytes': stat.st_size,
+ 'modified_at': datetime.datetime.fromtimestamp(
+ stat.st_mtime,
+ datetime.timezone.utc,
+ ).isoformat(),
+ }
+ if include_paths:
+ item['path'] = str(entry)
+ candidates.append(item)
+ if len(candidates) >= max_candidates:
+ return candidates
return candidates
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
@@ -236,33 +386,51 @@ class MaintenanceService:
candidates.append(item)
return candidates
- def _is_uploaded_file_key(self, key: str) -> bool:
- return '/' not in key and not key.startswith('plugin_config_')
+ def _is_uploaded_file_key(self, context: TenantContext, key: str) -> bool:
+ return any(
+ key.startswith(self.ap.storage_mgr.scoped_prefix(context, owner_type=owner_type))
+ and self.ap.storage_mgr.is_scoped_object_key(key, expected_owner_type=owner_type)
+ for owner_type in UPLOAD_OWNER_TYPES
+ )
- async def _monitoring_counts(self) -> dict[str, int]:
+ async def _monitoring_counts(self, context: TenantContext) -> dict[str, int]:
+ workspace_uuid = require_workspace_uuid(context)
tables = {
- 'messages': persistence_monitoring.MonitoringMessage.id,
- 'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
- 'tool_calls': persistence_monitoring.MonitoringToolCall.id,
- 'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
- 'errors': persistence_monitoring.MonitoringError.id,
- 'sessions': persistence_monitoring.MonitoringSession.session_id,
- 'feedback': persistence_monitoring.MonitoringFeedback.id,
+ 'messages': (persistence_monitoring.MonitoringMessage, persistence_monitoring.MonitoringMessage.id),
+ 'llm_calls': (persistence_monitoring.MonitoringLLMCall, persistence_monitoring.MonitoringLLMCall.id),
+ 'tool_calls': (persistence_monitoring.MonitoringToolCall, persistence_monitoring.MonitoringToolCall.id),
+ 'embedding_calls': (
+ persistence_monitoring.MonitoringEmbeddingCall,
+ persistence_monitoring.MonitoringEmbeddingCall.id,
+ ),
+ 'errors': (persistence_monitoring.MonitoringError, persistence_monitoring.MonitoringError.id),
+ 'sessions': (
+ persistence_monitoring.MonitoringSession,
+ persistence_monitoring.MonitoringSession.session_id,
+ ),
+ 'feedback': (persistence_monitoring.MonitoringFeedback, persistence_monitoring.MonitoringFeedback.id),
}
counts: dict[str, int] = {}
- for key, column in tables.items():
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
+ for key, (model, column) in tables.items():
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(sqlalchemy.func.count(column)).where(model.workspace_uuid == workspace_uuid)
+ )
counts[key] = result.scalar() or 0
return counts
- async def _binary_storage_stats(self) -> dict[str, Any]:
+ async def _binary_storage_stats(self, context: TenantContext) -> dict[str, Any]:
+ workspace_uuid = require_workspace_uuid(context)
count_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
+ sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key)).where(
+ persistence_bstorage.BinaryStorage.workspace_uuid == workspace_uuid
+ )
)
size_bytes = None
try:
size_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
+ sqlalchemy.select(
+ sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value))
+ ).where(persistence_bstorage.BinaryStorage.workspace_uuid == workspace_uuid)
)
size_bytes = size_result.scalar() or 0
except Exception as e:
diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py
index 1dbceb6e5..09bc185f7 100644
--- a/src/langbot/pkg/api/http/service/mcp.py
+++ b/src/langbot/pkg/api/http/service/mcp.py
@@ -1,198 +1,451 @@
from __future__ import annotations
-import sqlalchemy
+import copy
+import re
import uuid
-import asyncio
-from ....core import app
+import sqlalchemy
+
+from ....core import app, taskmgr
+from ....core.task_boundary import create_detached_task
from ....entity.persistence import mcp as persistence_mcp
-from ....core import taskmgr
-from ....provider.tools.loaders.mcp import RuntimeMCPSession, MCPSessionStatus
+from ....entity.persistence import plugin as persistence_plugin
+from ....provider.tools.loaders.mcp import MCPSessionStatus, RuntimeMCPSession
+from ....provider.tools.loaders.mcp_policy import require_stdio_mcp_enabled
+from ....workspace.errors import WorkspaceNotFoundError
+from ..context import ExecutionContext
+from .secrets import is_url_key, redact_url_secrets, restore_url_secret_placeholders
+from .tenant import TenantContext, require_workspace_uuid, scope_statement
+
+
+_SECRET_MASK = '***'
+_MISSING_SECRET = object()
+_SENSITIVE_CONFIG_NAMES = frozenset(
+ {
+ 'api_key',
+ 'apikey',
+ 'auth',
+ 'authorization',
+ 'cookie',
+ 'credentials',
+ 'database_url',
+ 'dsn',
+ 'key',
+ 'proxy_authorization',
+ 'set_cookie',
+ }
+)
+_SENSITIVE_CONFIG_TOKENS = frozenset(
+ {
+ 'credential',
+ 'credentials',
+ 'passwd',
+ 'password',
+ 'secret',
+ 'token',
+ }
+)
+_SENSITIVE_KEY_QUALIFIERS = frozenset(
+ {
+ 'access',
+ 'api',
+ 'auth',
+ 'bearer',
+ 'client',
+ 'debug',
+ 'encryption',
+ 'private',
+ 'signing',
+ }
+)
+
+
+def _normalize_config_key(key: object) -> str:
+ value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
+ return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
+
+
+def _is_sensitive_config_key(key: object) -> bool:
+ normalized = _normalize_config_key(key)
+ if normalized in _SENSITIVE_CONFIG_NAMES:
+ return True
+ tokens = frozenset(token for token in normalized.split('_') if token)
+ if tokens & _SENSITIVE_CONFIG_TOKENS:
+ return True
+ return 'key' in tokens and bool(tokens & _SENSITIVE_KEY_QUALIFIERS)
+
+
+def _mask_secret_structure(value):
+ if isinstance(value, dict):
+ return {key: _mask_secret_structure(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [_mask_secret_structure(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(_mask_secret_structure(item) for item in value)
+ if value is None or value == '':
+ return value
+ return _SECRET_MASK
+
+
+def redact_mcp_secrets(value):
+ """Return a recursively redacted copy of MCP configuration data."""
+
+ if isinstance(value, dict):
+ return {
+ key: (
+ _mask_secret_structure(item)
+ if _is_sensitive_config_key(key)
+ else redact_url_secrets(item)
+ if is_url_key(key)
+ else redact_mcp_secrets(item)
+ )
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [redact_mcp_secrets(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_mcp_secrets(item) for item in value)
+ return value
+
+
+def restore_mcp_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
+ """Restore masked leaves from the current MCP config before a write."""
+
+ if sensitive and value == _SECRET_MASK:
+ if current_value is _MISSING_SECRET:
+ raise ValueError('Masked MCP secret has no existing value')
+ return copy.deepcopy(current_value)
+ if isinstance(value, dict):
+ current_mapping = current_value if isinstance(current_value, dict) else {}
+ return {
+ key: (
+ restore_url_secret_placeholders(
+ item,
+ current_mapping.get(key, _MISSING_SECRET),
+ )
+ if not sensitive and not _is_sensitive_config_key(key) and is_url_key(key)
+ else restore_mcp_secret_placeholders(
+ item,
+ current_mapping.get(key, _MISSING_SECRET),
+ sensitive=sensitive or _is_sensitive_config_key(key),
+ )
+ )
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ current_items = current_value if isinstance(current_value, (list, tuple)) else ()
+ return [
+ restore_mcp_secret_placeholders(
+ item,
+ current_items[index] if index < len(current_items) else _MISSING_SECRET,
+ sensitive=sensitive,
+ )
+ for index, item in enumerate(value)
+ ]
+ if isinstance(value, tuple):
+ current_items = current_value if isinstance(current_value, (list, tuple)) else ()
+ return tuple(
+ restore_mcp_secret_placeholders(
+ item,
+ current_items[index] if index < len(current_items) else _MISSING_SECRET,
+ sensitive=sensitive,
+ )
+ for index, item in enumerate(value)
+ )
+ return value
class MCPService:
+ """Workspace-scoped MCP configuration and runtime facade."""
+
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def get_runtime_info(self, server_name: str) -> dict | None:
- session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
- if session:
- return session.get_runtime_info_dict()
- return None
+ async def _execution_context(self, context: TenantContext) -> ExecutionContext:
+ workspace_uuid = require_workspace_uuid(context)
+ instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
+ generation = getattr(context, 'placement_generation', None)
+ if not instance_uuid or not isinstance(generation, int) or isinstance(generation, bool) or generation <= 0:
+ raise ValueError('MCP operations require an explicit fenced execution context')
+ binding = await self.ap.workspace_service.get_execution_binding(
+ workspace_uuid,
+ expected_generation=generation,
+ )
+ if binding.instance_uuid != instance_uuid:
+ raise ValueError('MCP execution context belongs to another LangBot instance')
+ return ExecutionContext(
+ instance_uuid=instance_uuid,
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ bot_uuid=getattr(context, 'bot_uuid', None),
+ pipeline_uuid=getattr(context, 'pipeline_uuid', None),
+ query_uuid=getattr(context, 'query_uuid', None),
+ )
- async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict]:
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
+ async def get_runtime_info(self, context: TenantContext, server_name: str) -> dict | None:
+ execution_context = await self._execution_context(context)
+ session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
+ return session.get_runtime_info_dict() if session else None
- servers = result.all()
+ async def get_mcp_servers(self, context: TenantContext, contain_runtime_info: bool = False) -> list[dict]:
+ execution_context = await self._execution_context(context)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(sqlalchemy.select(persistence_mcp.MCPServer), persistence_mcp.MCPServer, context)
+ )
serialized_servers = [
- self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) for server in servers
+ redact_mcp_secrets(self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server))
+ for server in result.all()
]
if contain_runtime_info:
for server in serialized_servers:
- runtime_info = await self.get_runtime_info(server['name'])
-
- server['runtime_info'] = runtime_info if runtime_info else None
-
+ session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server['name'])
+ server['runtime_info'] = session.get_runtime_info_dict() if session else None
return serialized_servers
- async def create_mcp_server(self, server_data: dict) -> str:
- # Check limitation (extensions = MCP servers + plugins)
+ async def create_mcp_server(self, context: TenantContext, server_data: dict) -> str:
+ execution_context = await self._execution_context(context)
+ workspace_uuid = execution_context.workspace_uuid
+
+ # This gate is independent of Box availability. Cloud v2 disables
+ # stdio MCP even though Box Runtime itself remains available.
+ require_stdio_mcp_enabled(self.ap, server_data)
+
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_extensions = limitation.get('max_extensions', -1)
if max_extensions >= 0:
- existing_mcp_servers = await self.get_mcp_servers()
- plugins = await self.ap.plugin_connector.list_plugins()
- total_extensions = len(existing_mcp_servers) + len(plugins)
- if total_extensions >= max_extensions:
+ mcp_count_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(sqlalchemy.func.count(persistence_mcp.MCPServer.uuid)).where(
+ persistence_mcp.MCPServer.workspace_uuid == workspace_uuid
+ )
+ )
+ plugin_count_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == workspace_uuid)
+ )
+ if (mcp_count_result.scalar() or 0) + (plugin_count_result.scalar() or 0) >= max_extensions:
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
- server_name = str(server_data.get('name') or '').strip()
+ payload = dict(server_data)
+ payload.pop('workspace_uuid', None)
+ server_name = str(payload.get('name') or '').strip()
if not server_name:
raise ValueError('MCP server name is required')
- server_data['name'] = server_name
+ payload['name'] = server_name
+ payload['workspace_uuid'] = workspace_uuid
+ payload['uuid'] = str(uuid.uuid4())
existing_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
+ sqlalchemy.select(persistence_mcp.MCPServer).where(
+ persistence_mcp.MCPServer.workspace_uuid == workspace_uuid,
+ persistence_mcp.MCPServer.name == server_name,
+ )
)
if existing_result.first() is not None:
raise ValueError(f'MCP server already exists: {server_name}')
- server_data['uuid'] = str(uuid.uuid4())
- await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
-
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_data['uuid'])
- )
- server_entity = result.first()
- if server_entity:
- server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
- if self.ap.tool_mgr.mcp_tool_loader:
- task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
+ await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(payload))
+ created = await self._get_mcp_server_by_uuid_raw(execution_context, payload['uuid'])
+ if created and self.ap.tool_mgr.mcp_tool_loader:
+ task = create_detached_task(
+ self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(execution_context, created),
+ after_commit_manager=self.ap.persistence_mgr,
+ workspace_uuid=execution_context.workspace_uuid,
+ )
+ tracker = getattr(
+ self.ap.tool_mgr.mcp_tool_loader,
+ 'track_hosted_task',
+ None,
+ )
+ if callable(tracker):
+ tracker(task, execution_context)
+ else:
self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
+ return payload['uuid']
- return server_data['uuid']
+ async def get_mcp_server_by_uuid(self, context: TenantContext, server_uuid: str) -> dict | None:
+ execution_context = await self._execution_context(context)
+ server_data = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
+ return redact_mcp_secrets(server_data) if server_data is not None else None
- async def get_mcp_server_by_name(self, server_name: str) -> dict | None:
+ async def _get_mcp_server_by_uuid_raw(
+ self,
+ execution_context: ExecutionContext,
+ server_uuid: str,
+ ) -> dict | None:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
+ scope_statement(
+ sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid),
+ persistence_mcp.MCPServer,
+ execution_context,
+ )
+ )
+ server = result.first()
+ return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server) if server else None
+
+ async def get_mcp_server_by_name(self, context: TenantContext, server_name: str) -> dict | None:
+ execution_context = await self._execution_context(context)
+ server_data = await self._get_mcp_server_by_name_raw(execution_context, server_name)
+ if server_data is None:
+ return None
+ session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
+ response_data = {
+ **server_data,
+ 'runtime_info': session.get_runtime_info_dict() if session else None,
+ }
+ return redact_mcp_secrets(response_data)
+
+ async def _get_mcp_server_by_name_raw(
+ self,
+ execution_context: ExecutionContext,
+ server_name: str,
+ ) -> dict | None:
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name),
+ persistence_mcp.MCPServer,
+ execution_context,
+ )
)
server = result.first()
if server is None:
return None
+ return self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
- runtime_info = await self.get_runtime_info(server.name)
- server_data = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
- server_data['runtime_info'] = runtime_info if runtime_info else None
- return server_data
+ async def update_mcp_server(self, context: TenantContext, server_uuid: str, server_data: dict) -> None:
+ execution_context = await self._execution_context(context)
+ old_server = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
+ if old_server is None:
+ raise WorkspaceNotFoundError('MCP server not found')
+
+ payload = dict(server_data)
+ payload.pop('uuid', None)
+ payload.pop('workspace_uuid', None)
+ payload = restore_mcp_secret_placeholders(payload, old_server)
+ if 'name' in payload:
+ payload['name'] = str(payload['name'] or '').strip()
+ if not payload['name']:
+ raise ValueError('MCP server name is required')
+ duplicate = await self._get_mcp_server_by_name_raw(execution_context, payload['name'])
+ if duplicate is not None and duplicate['uuid'] != server_uuid:
+ raise ValueError(f'MCP server already exists: {payload["name"]}')
+
+ effective_server = {**old_server, **payload}
+ # Existing disabled rows remain readable/deletable. Switching away
+ # from stdio or explicitly disabling one is also allowed, but an
+ # update may never leave a disabled stdio server enabled.
+ if bool(effective_server.get('enable', True)):
+ require_stdio_mcp_enabled(self.ap, effective_server)
- async def update_mcp_server(self, server_uuid: str, server_data: dict) -> None:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
+ scope_statement(
+ sqlalchemy.update(persistence_mcp.MCPServer)
+ .where(persistence_mcp.MCPServer.uuid == server_uuid)
+ .values(payload),
+ persistence_mcp.MCPServer,
+ execution_context,
+ )
)
- old_server = result.first()
- old_server_name = old_server.name if old_server else None
- old_enable = old_server.enable if old_server else False
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('MCP server not found')
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_mcp.MCPServer)
- .where(persistence_mcp.MCPServer.uuid == server_uuid)
- .values(server_data)
- )
+ loader = self.ap.tool_mgr.mcp_tool_loader
+ if loader is None:
+ return
+ old_name = old_server['name']
+ old_enable = bool(old_server['enable'])
+ updated = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
+ if updated is None:
+ raise WorkspaceNotFoundError('MCP server not found')
+ new_enable = bool(updated['enable'])
+ if old_enable and loader.has_session(execution_context, old_name):
+ await loader.remove_mcp_server(execution_context, old_name)
+ if new_enable:
+ task = create_detached_task(
+ loader.host_mcp_server(execution_context, updated),
+ after_commit_manager=self.ap.persistence_mgr,
+ workspace_uuid=execution_context.workspace_uuid,
+ )
+ tracker = getattr(loader, 'track_hosted_task', None)
+ if callable(tracker):
+ tracker(task, execution_context)
+ else:
+ loader._hosted_mcp_tasks.append(task)
- if self.ap.tool_mgr.mcp_tool_loader:
- new_enable = server_data.get('enable', False)
-
- need_remove = old_server_name and old_server_name in self.ap.tool_mgr.mcp_tool_loader.sessions
-
- if old_enable and not new_enable:
- if need_remove:
- await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
-
- elif not old_enable and new_enable:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
- )
- updated_server = result.first()
- if updated_server:
- server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
- task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
- self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
-
- elif old_enable and new_enable:
- if need_remove:
- await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(old_server_name)
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
- )
- updated_server = result.first()
- if updated_server:
- server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, updated_server)
- task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
- self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(task)
-
- async def delete_mcp_server(self, server_uuid: str) -> None:
+ async def delete_mcp_server(self, context: TenantContext, server_uuid: str) -> None:
+ execution_context = await self._execution_context(context)
+ server = await self._get_mcp_server_by_uuid_raw(execution_context, server_uuid)
+ if server is None:
+ raise WorkspaceNotFoundError('MCP server not found')
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
+ scope_statement(
+ sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid),
+ persistence_mcp.MCPServer,
+ execution_context,
+ )
)
- server = result.first()
- server_name = server.name if server else None
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('MCP server not found')
+ loader = self.ap.tool_mgr.mcp_tool_loader
+ if loader and loader.has_session(execution_context, server['name']):
+ await loader.remove_mcp_server(execution_context, server['name'])
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
- )
+ async def _require_server(self, context: TenantContext, server_name: str) -> tuple[ExecutionContext, dict]:
+ execution_context = await self._execution_context(context)
+ server = await self._get_mcp_server_by_name_raw(execution_context, server_name)
+ if server is None:
+ raise WorkspaceNotFoundError('MCP server not found')
+ return execution_context, server
- if server_name and self.ap.tool_mgr.mcp_tool_loader:
- if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
- await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
+ async def get_mcp_server_resources(self, context: TenantContext, server_name: str) -> list[dict]:
+ execution_context, _ = await self._require_server(context, server_name)
+ return await self.ap.tool_mgr.mcp_tool_loader.get_resources(execution_context, server_name)
- async def get_mcp_server_resources(self, server_name: str) -> list[dict]:
- """Get resources from a specific MCP server."""
- return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name)
-
- async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]:
- """Get resource templates from a specific MCP server."""
- return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name)
+ async def get_mcp_server_resource_templates(self, context: TenantContext, server_name: str) -> list[dict]:
+ execution_context, _ = await self._require_server(context, server_name)
+ return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(execution_context, server_name)
async def read_mcp_server_resource_envelope(
self,
+ context: TenantContext,
server_name: str,
uri: str,
*,
max_bytes: int | None = None,
include_blob: bool = False,
) -> dict:
- """Read a resource from a specific MCP server with metadata."""
+ execution_context, _ = await self._require_server(context, server_name)
kwargs = {'include_blob': include_blob, 'source': 'ui_preview'}
if max_bytes is not None:
kwargs['max_bytes'] = max_bytes
- return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs)
+ return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(
+ execution_context,
+ server_name,
+ uri,
+ **kwargs,
+ )
- async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]:
- """Read a resource from a specific MCP server."""
- return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri)
-
- async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
- """测试 MCP 服务器连接并返回任务 ID"""
+ async def read_mcp_server_resource(self, context: TenantContext, server_name: str, uri: str) -> list[dict]:
+ execution_context, _ = await self._require_server(context, server_name)
+ return await self.ap.tool_mgr.mcp_tool_loader.read_resource(execution_context, server_name, uri)
+ async def test_mcp_server(self, context: TenantContext, server_name: str, server_data: dict) -> int:
+ execution_context = await self._execution_context(context)
runtime_mcp_session: RuntimeMCPSession | None = None
-
+ test_session: RuntimeMCPSession | None = None
ctx = taskmgr.TaskContext.new()
if server_name != '_':
- runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
+ _, persisted_server = await self._require_server(execution_context, server_name)
+ require_stdio_mcp_enabled(self.ap, persisted_server)
+ runtime_mcp_session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
if runtime_mcp_session is None:
- raise ValueError(f'Server not found: {server_name}')
-
+ raise WorkspaceNotFoundError('MCP server not found')
persisted_session = runtime_mcp_session
async def _refresh_and_report() -> None:
- # Testing a persisted server should REUSE its live shared-session
- # process, not rebuild it. Try a lightweight refresh (a real
- # list_tools probe over the existing connection) first; only fall
- # back to a full start() when the session has no live connection
- # to probe (never connected, or the process is actually gone).
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
if needs_start:
await persisted_session.start()
@@ -200,30 +453,24 @@ class MCPService:
try:
await persisted_session.refresh()
except Exception:
- # The live connection was stale/dropped: reconnect once
- # (reusing the live managed process where possible) and
- # re-probe, instead of reporting a false failure.
await persisted_session.start()
- # Surface the discovered tools so the config page can render them
- # even for an already-hosted server.
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
coroutine = _refresh_and_report()
else:
- runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(server_config=server_data)
-
- # A transient test owns an isolated Box session. Always tear it down
- # after the test completes (success or failure) so it does not leak.
+ payload = dict(server_data)
+ payload.pop('workspace_uuid', None)
+ payload['workspace_uuid'] = execution_context.workspace_uuid
+ require_stdio_mcp_enabled(self.ap, payload)
+ runtime_mcp_session = await self.ap.tool_mgr.mcp_tool_loader.load_mcp_server(
+ execution_context,
+ payload,
+ )
test_session = runtime_mcp_session
async def _run_and_cleanup() -> None:
try:
await test_session.start()
- # Capture the runtime info (status + discovered tools) BEFORE
- # shutting the transient session down. The create/edit config
- # page has no persisted server to reload from, so without this
- # a successful test could only show "no tools found". The
- # frontend reads ctx.metadata.runtime_info to render the tools.
ctx.metadata['runtime_info'] = test_session.get_runtime_info_dict()
finally:
try:
@@ -236,27 +483,41 @@ class MCPService:
coroutine = _run_and_cleanup()
- wrapper = self.ap.task_mgr.create_user_task(
- coroutine,
- kind='mcp-operation',
- name=f'mcp-test-{server_name}',
- label=f'Testing MCP server {server_name}',
- context=ctx,
- )
+ try:
+ wrapper = self.ap.task_mgr.create_user_task(
+ coroutine,
+ kind='mcp-operation',
+ name=f'mcp-test-{execution_context.workspace_uuid}-{server_name}',
+ label=f'Testing MCP server {server_name}',
+ context=ctx,
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ )
+ except taskmgr.TaskCapacityError:
+ if test_session is not None:
+ try:
+ await test_session.shutdown()
+ except Exception as exc:
+ self.ap.logger.warning(
+ f'Failed to tear down rejected transient MCP test session '
+ f'{test_session.server_name}: {type(exc).__name__}: {exc}'
+ )
+ raise
return wrapper.id
- async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
- """Get recent log lines captured from the MCP server's stderr."""
- session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
+ async def get_mcp_server_logs(
+ self,
+ context: TenantContext,
+ server_name: str,
+ limit: int = 200,
+ level: str | None = None,
+ ) -> list[dict]:
+ execution_context, _ = await self._require_server(context, server_name)
+ session = self.ap.tool_mgr.mcp_tool_loader.get_session(execution_context, server_name)
if not session:
return []
-
- # Get logs from the session's buffer
logs = list(session._log_buffer)
-
- # Filter by level if specified
if level:
logs = [log for log in logs if log.get('level') == level]
-
- # Return the most recent 'limit' logs
return logs[-limit:]
diff --git a/src/langbot/pkg/api/http/service/model.py b/src/langbot/pkg/api/http/service/model.py
index 87298c084..88801c6b8 100644
--- a/src/langbot/pkg/api/http/service/model.py
+++ b/src/langbot/pkg/api/http/service/model.py
@@ -9,6 +9,9 @@ from ....core import app
from ....entity.persistence import model as persistence_model
from ....entity.persistence import pipeline as persistence_pipeline
from ....provider.modelmgr import requester as model_requester
+from ....workspace.errors import WorkspaceNotFoundError
+from .secrets import mask_secret_value, redact_secrets, restore_secret_placeholders
+from .tenant import TenantContext, require_workspace_uuid, scope_statement
def _parse_provider_api_keys(provider_dict: dict) -> dict:
@@ -34,7 +37,29 @@ def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
return {**model_data, 'uuid': model_uuid}
-async def _validate_provider_supports(ap: app.Application, provider_uuid: str, model_type: str) -> None:
+def _redact_model_secrets(model_data: dict) -> dict:
+ """Return a copy with model args and embedded provider credentials masked."""
+
+ redacted = model_data.copy()
+ if 'extra_args' in redacted:
+ redacted['extra_args'] = redact_secrets(redacted['extra_args'])
+ if isinstance(redacted.get('provider'), dict):
+ provider = redacted['provider'].copy()
+ # ModelProvider never contains another provider. Dropping this key also
+ # makes the serializer robust to a reused/self-referential test double.
+ provider.pop('provider', None)
+ if 'api_keys' in provider:
+ provider['api_keys'] = mask_secret_value(provider['api_keys'])
+ redacted['provider'] = provider
+ return redacted
+
+
+async def _validate_provider_supports(
+ ap: app.Application,
+ context: TenantContext,
+ provider_uuid: str,
+ model_type: str,
+) -> None:
"""Validate that the provider's requester declares support for ``model_type``.
``model_type`` is one of the manifest ``support_type`` values:
@@ -47,11 +72,12 @@ async def _validate_provider_supports(ap: app.Application, provider_uuid: str, m
if model_mgr is None:
return
- provider_dict = getattr(model_mgr, 'provider_dict', None)
- if not provider_dict:
+ get_provider = getattr(model_mgr, 'get_provider_by_uuid', None)
+ if not callable(get_provider):
return
- runtime_provider = provider_dict.get(provider_uuid)
- if runtime_provider is None:
+ try:
+ runtime_provider = await get_provider(context, provider_uuid)
+ except ValueError:
return
requester_name = getattr(getattr(runtime_provider, 'provider_entity', None), 'requester', None)
@@ -74,20 +100,48 @@ async def _validate_provider_supports(ap: app.Application, provider_uuid: str, m
raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models')
+async def _require_workspace_provider(
+ ap: app.Application,
+ context: TenantContext,
+ provider_uuid: str,
+) -> dict:
+ """Require the referenced provider to belong to the active Workspace."""
+
+ provider = await ap.provider_service.get_provider(context, provider_uuid)
+ if provider is None:
+ raise WorkspaceNotFoundError('Provider not found')
+ return provider
+
+
+async def _require_runtime_provider(
+ ap: app.Application,
+ context: TenantContext,
+ provider_uuid: str,
+) -> model_requester.RuntimeProvider:
+ try:
+ return await ap.model_mgr.get_provider_by_uuid(context, provider_uuid)
+ except ValueError as exc:
+ raise Exception('provider not found') from exc
+
+
class LLMModelsService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def get_llm_models(self, include_secret: bool = True) -> list[dict]:
+ async def get_llm_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""Get all LLM models with provider info"""
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(sqlalchemy.select(persistence_model.LLMModel), persistence_model.LLMModel, context)
+ )
models = result.all()
# Get all providers for lookup
providers_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.ModelProvider)
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
+ )
)
providers = {p.uuid: p for p in providers_result.all()}
@@ -98,29 +152,50 @@ class LLMModelsService:
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
provider_dict = _parse_provider_api_keys(provider_dict)
- if not include_secret:
- provider_dict['api_keys'] = ['***'] * len(provider_dict.get('api_keys', []))
model_dict['provider'] = provider_dict
+ if not include_secret:
+ model_dict = _redact_model_secrets(model_dict)
models_list.append(model_dict)
return models_list
- async def get_llm_models_by_provider(self, provider_uuid: str) -> list[dict]:
+ async def get_llm_models_by_provider(
+ self,
+ context: TenantContext,
+ provider_uuid: str,
+ *,
+ include_secret: bool = False,
+ ) -> list[dict]:
"""Get LLM models by provider UUID"""
+ await _require_workspace_provider(self.ap, context, provider_uuid)
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.LLMModel).where(
- persistence_model.LLMModel.provider_uuid == provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.LLMModel).where(
+ persistence_model.LLMModel.provider_uuid == provider_uuid
+ ),
+ persistence_model.LLMModel,
+ context,
)
)
models = result.all()
- return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
+ serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
+ return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
async def create_llm_model(
- self, model_data: dict, preserve_uuid: bool = False, auto_set_to_default_pipeline: bool = True
+ self,
+ context: TenantContext,
+ model_data: dict,
+ preserve_uuid: bool = False,
+ auto_set_to_default_pipeline: bool = True,
) -> str:
"""Create a new LLM model"""
+ workspace_uuid = require_workspace_uuid(context)
+ model_data = model_data.copy()
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
+ model_data['workspace_uuid'] = workspace_uuid
+ if 'extra_args' in model_data:
+ model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
# Handle provider creation if needed
if 'provider' in model_data:
@@ -130,31 +205,35 @@ class LLMModelsService:
else:
# Create new provider
provider_uuid = await self.ap.provider_service.find_or_create_provider(
+ context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
- await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'llm')
+ await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
+ await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'llm')
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_model.LLMModel).values(**model_data))
- runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
- if runtime_provider is None:
- raise Exception('provider not found')
-
+ runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
+ context,
persistence_model.LLMModel(**model_data),
runtime_provider,
)
- self.ap.model_mgr.llm_models.append(runtime_llm_model)
+ await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
if auto_set_to_default_pipeline:
# set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
- persistence_pipeline.LegacyPipeline.is_default == True
+ scope_statement(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.is_default == True
+ ),
+ persistence_pipeline.LegacyPipeline,
+ workspace_uuid,
)
)
pipeline = result.first()
@@ -167,14 +246,23 @@ class LLMModelsService:
'fallbacks': [],
}
pipeline_data = {'config': pipeline_config}
- await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
+ await self.ap.pipeline_service.update_pipeline(context, pipeline.uuid, pipeline_data)
return model_data['uuid']
- async def get_llm_model(self, model_uuid: str) -> dict | None:
+ async def get_llm_model(
+ self,
+ context: TenantContext,
+ model_uuid: str,
+ include_secret: bool = False,
+ ) -> dict | None:
"""Get a single LLM model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
+ scope_statement(
+ sqlalchemy.select(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
+ persistence_model.LLMModel,
+ context,
+ )
)
model = result.first()
if model is None:
@@ -184,21 +272,38 @@ class LLMModelsService:
# Get provider
provider_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.uuid == model.provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider).where(
+ persistence_model.ModelProvider.uuid == model.provider_uuid
+ ),
+ persistence_model.ModelProvider,
+ context,
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
- model_dict['provider'] = _parse_provider_api_keys(provider_dict)
+ provider_dict = _parse_provider_api_keys(provider_dict)
+ model_dict['provider'] = provider_dict
+
+ if not include_secret:
+ model_dict = _redact_model_secrets(model_dict)
return model_dict
- async def update_llm_model(self, model_uuid: str, model_data: dict) -> None:
+ async def update_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing LLM model"""
- if 'uuid' in model_data:
- del model_data['uuid']
+ existing_model = await self.get_llm_model(context, model_uuid, include_secret=True)
+ if existing_model is None:
+ raise WorkspaceNotFoundError('Model not found')
+ model_data = model_data.copy()
+ model_data.pop('uuid', None)
+ model_data.pop('workspace_uuid', None)
+ if 'extra_args' in model_data:
+ model_data['extra_args'] = restore_secret_placeholders(
+ model_data['extra_args'],
+ existing_model.get('extra_args', {}),
+ )
# Handle provider update if needed
if 'provider' in model_data:
@@ -207,50 +312,71 @@ class LLMModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
+ context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_model.LLMModel)
- .where(persistence_model.LLMModel.uuid == model_uuid)
- .values(**model_data)
+ provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
+ await _require_workspace_provider(self.ap, context, provider_uuid)
+ await _validate_provider_supports(self.ap, context, provider_uuid, 'llm')
+
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(persistence_model.LLMModel)
+ .where(persistence_model.LLMModel.uuid == model_uuid)
+ .values(**model_data),
+ persistence_model.LLMModel,
+ context,
+ )
)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Model not found')
- await self.ap.model_mgr.remove_llm_model(model_uuid)
-
- runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
- if runtime_provider is None:
- raise Exception('provider not found')
-
+ await self.ap.model_mgr.remove_llm_model(context, model_uuid)
+ runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
- persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
+ context,
+ persistence_model.LLMModel(
+ **_runtime_model_data(
+ model_uuid,
+ {
+ key: value
+ for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
+ if key not in {'provider', 'created_at', 'updated_at'}
+ },
+ )
+ ),
runtime_provider,
)
- self.ap.model_mgr.llm_models.append(runtime_llm_model)
+ await self.ap.model_mgr.cache_llm_model(context, runtime_llm_model)
- async def delete_llm_model(self, model_uuid: str) -> None:
+ async def delete_llm_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete an LLM model"""
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.delete(persistence_model.LLMModel).where(persistence_model.LLMModel.uuid == model_uuid),
+ persistence_model.LLMModel,
+ context,
+ )
)
- await self.ap.model_mgr.remove_llm_model(model_uuid)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Model not found')
+ await self.ap.model_mgr.remove_llm_model(context, model_uuid)
- async def test_llm_model(self, model_uuid: str, model_data: dict) -> None:
+ async def test_llm_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Test an LLM model"""
+ require_workspace_uuid(context)
runtime_llm_model: model_requester.RuntimeLLMModel | None = None
if model_uuid != '_':
- for model in self.ap.model_mgr.llm_models:
- if model.model_entity.uuid == model_uuid:
- runtime_llm_model = model
- break
- if runtime_llm_model is None:
- raise Exception('model not found')
+ if await self.get_llm_model(context, model_uuid) is None:
+ raise WorkspaceNotFoundError('Model not found')
+ runtime_llm_model = await self.ap.model_mgr.get_model_by_uuid(context, model_uuid)
else:
- runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(model_data)
+ runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(context, model_data)
extra_args = model_data.get('extra_args', {})
await runtime_llm_model.provider.invoke_llm(
@@ -259,6 +385,7 @@ class LLMModelsService:
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
funcs=[],
extra_args=extra_args,
+ execution_context=runtime_llm_model.execution_context,
)
@@ -268,13 +395,19 @@ class EmbeddingModelsService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def get_embedding_models(self) -> list[dict]:
+ async def get_embedding_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""Get all embedding models with provider info"""
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.select(persistence_model.EmbeddingModel), persistence_model.EmbeddingModel, context
+ )
+ )
models = result.all()
providers_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.ModelProvider)
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
+ )
)
providers = {p.uuid: p for p in providers_result.all()}
@@ -284,25 +417,46 @@ class EmbeddingModelsService:
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
- model_dict['provider'] = _parse_provider_api_keys(provider_dict)
+ provider_dict = _parse_provider_api_keys(provider_dict)
+ model_dict['provider'] = provider_dict
+ if not include_secret:
+ model_dict = _redact_model_secrets(model_dict)
models_list.append(model_dict)
return models_list
- async def get_embedding_models_by_provider(self, provider_uuid: str) -> list[dict]:
+ async def get_embedding_models_by_provider(
+ self,
+ context: TenantContext,
+ provider_uuid: str,
+ *,
+ include_secret: bool = False,
+ ) -> list[dict]:
"""Get embedding models by provider UUID"""
+ await _require_workspace_provider(self.ap, context, provider_uuid)
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.EmbeddingModel).where(
- persistence_model.EmbeddingModel.provider_uuid == provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.EmbeddingModel).where(
+ persistence_model.EmbeddingModel.provider_uuid == provider_uuid
+ ),
+ persistence_model.EmbeddingModel,
+ context,
)
)
models = result.all()
- return [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
+ serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, m) for m in models]
+ return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
- async def create_embedding_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
+ async def create_embedding_model(
+ self, context: TenantContext, model_data: dict, preserve_uuid: bool = False
+ ) -> str:
"""Create a new embedding model"""
+ model_data = model_data.copy()
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
+ model_data['workspace_uuid'] = require_workspace_uuid(context)
+ if 'extra_args' in model_data:
+ model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
if 'provider' in model_data:
provider_data = model_data.pop('provider')
@@ -310,35 +464,44 @@ class EmbeddingModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
+ context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
- await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'text-embedding')
+ await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
+ await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'text-embedding')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.EmbeddingModel).values(**model_data)
)
- runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
- if runtime_provider is None:
- raise Exception('provider not found')
-
+ runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
+ context,
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
- self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
+ await self.ap.model_mgr.cache_embedding_model(context, runtime_embedding_model)
return model_data['uuid']
- async def get_embedding_model(self, model_uuid: str) -> dict | None:
+ async def get_embedding_model(
+ self,
+ context: TenantContext,
+ model_uuid: str,
+ include_secret: bool = False,
+ ) -> dict | None:
"""Get a single embedding model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.EmbeddingModel).where(
- persistence_model.EmbeddingModel.uuid == model_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.EmbeddingModel).where(
+ persistence_model.EmbeddingModel.uuid == model_uuid
+ ),
+ persistence_model.EmbeddingModel,
+ context,
)
)
model = result.first()
@@ -348,21 +511,38 @@ class EmbeddingModelsService:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.EmbeddingModel, model)
provider_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.uuid == model.provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider).where(
+ persistence_model.ModelProvider.uuid == model.provider_uuid
+ ),
+ persistence_model.ModelProvider,
+ context,
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
- model_dict['provider'] = _parse_provider_api_keys(provider_dict)
+ provider_dict = _parse_provider_api_keys(provider_dict)
+ model_dict['provider'] = provider_dict
+
+ if not include_secret:
+ model_dict = _redact_model_secrets(model_dict)
return model_dict
- async def update_embedding_model(self, model_uuid: str, model_data: dict) -> None:
+ async def update_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing embedding model"""
- if 'uuid' in model_data:
- del model_data['uuid']
+ existing_model = await self.get_embedding_model(context, model_uuid, include_secret=True)
+ if existing_model is None:
+ raise WorkspaceNotFoundError('Model not found')
+ model_data = model_data.copy()
+ model_data.pop('uuid', None)
+ model_data.pop('workspace_uuid', None)
+ if 'extra_args' in model_data:
+ model_data['extra_args'] = restore_secret_placeholders(
+ model_data['extra_args'],
+ existing_model.get('extra_args', {}),
+ )
if 'provider' in model_data:
provider_data = model_data.pop('provider')
@@ -370,57 +550,82 @@ class EmbeddingModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
+ context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_model.EmbeddingModel)
- .where(persistence_model.EmbeddingModel.uuid == model_uuid)
- .values(**model_data)
- )
+ provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
+ await _require_workspace_provider(self.ap, context, provider_uuid)
+ await _validate_provider_supports(self.ap, context, provider_uuid, 'text-embedding')
- await self.ap.model_mgr.remove_embedding_model(model_uuid)
-
- runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
- if runtime_provider is None:
- raise Exception('provider not found')
-
- runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
- persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
- runtime_provider,
- )
- self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
-
- async def delete_embedding_model(self, model_uuid: str) -> None:
- """Delete an embedding model"""
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_model.EmbeddingModel).where(
- persistence_model.EmbeddingModel.uuid == model_uuid
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(persistence_model.EmbeddingModel)
+ .where(persistence_model.EmbeddingModel.uuid == model_uuid)
+ .values(**model_data),
+ persistence_model.EmbeddingModel,
+ context,
)
)
- await self.ap.model_mgr.remove_embedding_model(model_uuid)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Model not found')
- async def test_embedding_model(self, model_uuid: str, model_data: dict) -> None:
+ await self.ap.model_mgr.remove_embedding_model(context, model_uuid)
+ runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
+ runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
+ context,
+ persistence_model.EmbeddingModel(
+ **_runtime_model_data(
+ model_uuid,
+ {
+ key: value
+ for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
+ if key not in {'provider', 'created_at', 'updated_at'}
+ },
+ )
+ ),
+ runtime_provider,
+ )
+ await self.ap.model_mgr.cache_embedding_model(context, runtime_embedding_model)
+
+ async def delete_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
+ """Delete an embedding model"""
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.delete(persistence_model.EmbeddingModel).where(
+ persistence_model.EmbeddingModel.uuid == model_uuid
+ ),
+ persistence_model.EmbeddingModel,
+ context,
+ )
+ )
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Model not found')
+ await self.ap.model_mgr.remove_embedding_model(context, model_uuid)
+
+ async def test_embedding_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Test an embedding model"""
+ require_workspace_uuid(context)
runtime_embedding_model: model_requester.RuntimeEmbeddingModel | None = None
if model_uuid != '_':
- for model in self.ap.model_mgr.embedding_models:
- if model.model_entity.uuid == model_uuid:
- runtime_embedding_model = model
- break
- if runtime_embedding_model is None:
- raise Exception('model not found')
+ if await self.get_embedding_model(context, model_uuid) is None:
+ raise WorkspaceNotFoundError('Model not found')
+ runtime_embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(context, model_uuid)
else:
- runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data)
+ runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(
+ context,
+ model_data,
+ )
await runtime_embedding_model.provider.invoke_embedding(
model=runtime_embedding_model,
input_text=['Hello, world!'],
extra_args={},
+ execution_context=runtime_embedding_model.execution_context,
)
@@ -430,13 +635,17 @@ class RerankModelsService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def get_rerank_models(self) -> list[dict]:
+ async def get_rerank_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""Get all rerank models with provider info"""
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(sqlalchemy.select(persistence_model.RerankModel), persistence_model.RerankModel, context)
+ )
models = result.all()
providers_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.ModelProvider)
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider), persistence_model.ModelProvider, context
+ )
)
providers = {p.uuid: p for p in providers_result.all()}
@@ -446,25 +655,44 @@ class RerankModelsService:
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
- model_dict['provider'] = _parse_provider_api_keys(provider_dict)
+ provider_dict = _parse_provider_api_keys(provider_dict)
+ model_dict['provider'] = provider_dict
+ if not include_secret:
+ model_dict = _redact_model_secrets(model_dict)
models_list.append(model_dict)
return models_list
- async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
+ async def get_rerank_models_by_provider(
+ self,
+ context: TenantContext,
+ provider_uuid: str,
+ *,
+ include_secret: bool = False,
+ ) -> list[dict]:
"""Get rerank models by provider UUID"""
+ await _require_workspace_provider(self.ap, context, provider_uuid)
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.RerankModel).where(
- persistence_model.RerankModel.provider_uuid == provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.RerankModel).where(
+ persistence_model.RerankModel.provider_uuid == provider_uuid
+ ),
+ persistence_model.RerankModel,
+ context,
)
)
models = result.all()
- return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
+ serialized = [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
+ return serialized if include_secret else [_redact_model_secrets(model) for model in serialized]
- async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
+ async def create_rerank_model(self, context: TenantContext, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new rerank model"""
+ model_data = model_data.copy()
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
+ model_data['workspace_uuid'] = require_workspace_uuid(context)
+ if 'extra_args' in model_data:
+ model_data['extra_args'] = restore_secret_placeholders(model_data['extra_args'])
if 'provider' in model_data:
provider_data = model_data.pop('provider')
@@ -472,34 +700,45 @@ class RerankModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
+ context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
- await _validate_provider_supports(self.ap, model_data['provider_uuid'], 'rerank')
+ await _require_workspace_provider(self.ap, context, model_data['provider_uuid'])
+ await _validate_provider_supports(self.ap, context, model_data['provider_uuid'], 'rerank')
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
)
- runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
- if runtime_provider is None:
- raise Exception('provider not found')
-
+ runtime_provider = await _require_runtime_provider(self.ap, context, model_data['provider_uuid'])
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
+ context,
persistence_model.RerankModel(**model_data),
runtime_provider,
)
- self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
+ await self.ap.model_mgr.cache_rerank_model(context, runtime_rerank_model)
return model_data['uuid']
- async def get_rerank_model(self, model_uuid: str) -> dict | None:
+ async def get_rerank_model(
+ self,
+ context: TenantContext,
+ model_uuid: str,
+ include_secret: bool = False,
+ ) -> dict | None:
"""Get a single rerank model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
+ scope_statement(
+ sqlalchemy.select(persistence_model.RerankModel).where(
+ persistence_model.RerankModel.uuid == model_uuid
+ ),
+ persistence_model.RerankModel,
+ context,
+ )
)
model = result.first()
if model is None:
@@ -508,21 +747,38 @@ class RerankModelsService:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
provider_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.uuid == model.provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider).where(
+ persistence_model.ModelProvider.uuid == model.provider_uuid
+ ),
+ persistence_model.ModelProvider,
+ context,
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
- model_dict['provider'] = _parse_provider_api_keys(provider_dict)
+ provider_dict = _parse_provider_api_keys(provider_dict)
+ model_dict['provider'] = provider_dict
+
+ if not include_secret:
+ model_dict = _redact_model_secrets(model_dict)
return model_dict
- async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None:
+ async def update_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Update an existing rerank model"""
- if 'uuid' in model_data:
- del model_data['uuid']
+ existing_model = await self.get_rerank_model(context, model_uuid, include_secret=True)
+ if existing_model is None:
+ raise WorkspaceNotFoundError('Model not found')
+ model_data = model_data.copy()
+ model_data.pop('uuid', None)
+ model_data.pop('workspace_uuid', None)
+ if 'extra_args' in model_data:
+ model_data['extra_args'] = restore_secret_placeholders(
+ model_data['extra_args'],
+ existing_model.get('extra_args', {}),
+ )
if 'provider' in model_data:
provider_data = model_data.pop('provider')
@@ -530,50 +786,76 @@ class RerankModelsService:
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
+ context,
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_model.RerankModel)
- .where(persistence_model.RerankModel.uuid == model_uuid)
- .values(**model_data)
+ provider_uuid = model_data.get('provider_uuid', existing_model['provider_uuid'])
+ await _require_workspace_provider(self.ap, context, provider_uuid)
+ await _validate_provider_supports(self.ap, context, provider_uuid, 'rerank')
+
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(persistence_model.RerankModel)
+ .where(persistence_model.RerankModel.uuid == model_uuid)
+ .values(**model_data),
+ persistence_model.RerankModel,
+ context,
+ )
)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Model not found')
- await self.ap.model_mgr.remove_rerank_model(model_uuid)
-
- runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
- if runtime_provider is None:
- raise Exception('provider not found')
-
+ await self.ap.model_mgr.remove_rerank_model(context, model_uuid)
+ runtime_provider = await _require_runtime_provider(self.ap, context, provider_uuid)
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
- persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
+ context,
+ persistence_model.RerankModel(
+ **_runtime_model_data(
+ model_uuid,
+ {
+ key: value
+ for key, value in {**existing_model, **model_data, 'provider_uuid': provider_uuid}.items()
+ if key not in {'provider', 'created_at', 'updated_at'}
+ },
+ )
+ ),
runtime_provider,
)
- self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
+ await self.ap.model_mgr.cache_rerank_model(context, runtime_rerank_model)
- async def delete_rerank_model(self, model_uuid: str) -> None:
+ async def delete_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
"""Delete a rerank model"""
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.delete(persistence_model.RerankModel).where(
+ persistence_model.RerankModel.uuid == model_uuid
+ ),
+ persistence_model.RerankModel,
+ context,
+ )
)
- await self.ap.model_mgr.remove_rerank_model(model_uuid)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Model not found')
+ await self.ap.model_mgr.remove_rerank_model(context, model_uuid)
- async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
+ async def test_rerank_model(self, context: TenantContext, model_uuid: str, model_data: dict) -> None:
"""Test a rerank model"""
+ require_workspace_uuid(context)
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
if model_uuid != '_':
- for model in self.ap.model_mgr.rerank_models:
- if model.model_entity.uuid == model_uuid:
- runtime_rerank_model = model
- break
- if runtime_rerank_model is None:
- raise Exception('model not found')
+ if await self.get_rerank_model(context, model_uuid) is None:
+ raise WorkspaceNotFoundError('Model not found')
+ runtime_rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(context, model_uuid)
else:
- runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
+ runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(
+ context,
+ model_data,
+ )
await runtime_rerank_model.provider.invoke_rerank(
model=runtime_rerank_model,
@@ -582,4 +864,5 @@ class RerankModelsService:
'Artificial intelligence is a branch of computer science.',
'The weather is nice today.',
],
+ execution_context=runtime_rerank_model.execution_context,
)
diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py
index 46a352ad1..b0363cda4 100644
--- a/src/langbot/pkg/api/http/service/monitoring.py
+++ b/src/langbot/pkg/api/http/service/monitoring.py
@@ -2,11 +2,46 @@ from __future__ import annotations
import uuid
import datetime
+import functools
import json
import sqlalchemy
+from sqlalchemy.dialects import postgresql as postgresql_dialect
+from sqlalchemy.dialects import sqlite as sqlite_dialect
from ....core import app
from ....entity.persistence import monitoring as persistence_monitoring
+from ..authz import WorkspaceRequiredError
+from ..context import ExecutionContext
+from .tenant import TenantContext, require_workspace_uuid
+
+
+_DEFAULT_MONITORING_PAGE_ROWS = 1000
+_DEFAULT_MONITORING_EXPORT_ROWS = 10000
+_DEFAULT_MONITORING_DETAIL_ROWS = 2000
+_DEFAULT_MONITORING_TIMESERIES_BUCKETS = 1000
+_DEFAULT_MONITORING_MAX_OFFSET = 1000000
+_HARD_MAX_MONITORING_PAGE_ROWS = 5000
+_HARD_MAX_MONITORING_EXPORT_ROWS = 50000
+_HARD_MAX_MONITORING_DETAIL_ROWS = 10000
+_HARD_MAX_MONITORING_TIMESERIES_BUCKETS = 10000
+_HARD_MAX_MONITORING_OFFSET = 10000000
+_DEFAULT_CLEANUP_BATCHES_PER_TABLE = 4
+_HARD_MAX_CLEANUP_BATCHES_PER_TABLE = 100
+
+
+def _workspace_transaction(method):
+ """Run an explicit service entrypoint in one Workspace transaction."""
+
+ @functools.wraps(method)
+ async def wrapped(self, context, *args, **kwargs):
+ workspace_uuid = require_workspace_uuid(context)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(workspace_uuid):
+ return await method(self, context, *args, **kwargs)
+ return await method(self, context, *args, **kwargs)
+
+ return wrapped
class MonitoringService:
@@ -17,9 +52,109 @@ class MonitoringService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
+ def _configured_query_limit(self, name: str, default: int, hard_max: int) -> int:
+ config = (
+ getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('monitoring', {}).get('query_limits', {})
+ )
+ try:
+ value = int(config.get(name, default))
+ except (TypeError, ValueError):
+ value = default
+ return min(max(value, 1), hard_max)
+
+ def normalize_page_window(self, limit: int, offset: int = 0) -> tuple[int, int]:
+ """Clamp tenant-controlled pagination before constructing a DB query."""
+
+ page_cap = self._configured_query_limit(
+ 'page_rows',
+ _DEFAULT_MONITORING_PAGE_ROWS,
+ _HARD_MAX_MONITORING_PAGE_ROWS,
+ )
+ offset_cap = self._configured_query_limit(
+ 'max_offset',
+ _DEFAULT_MONITORING_MAX_OFFSET,
+ _HARD_MAX_MONITORING_OFFSET,
+ )
+ try:
+ normalized_limit = int(limit)
+ except (TypeError, ValueError):
+ normalized_limit = 100
+ try:
+ normalized_offset = int(offset)
+ except (TypeError, ValueError):
+ normalized_offset = 0
+ return (
+ min(max(normalized_limit, 1), page_cap),
+ min(max(normalized_offset, 0), offset_cap),
+ )
+
+ def normalize_export_limit(self, limit: int) -> int:
+ """Clamp exports that are currently materialized as an in-memory list."""
+
+ export_cap = self._configured_query_limit(
+ 'export_rows',
+ _DEFAULT_MONITORING_EXPORT_ROWS,
+ _HARD_MAX_MONITORING_EXPORT_ROWS,
+ )
+ try:
+ normalized = int(limit)
+ except (TypeError, ValueError):
+ normalized = _DEFAULT_MONITORING_EXPORT_ROWS
+ return min(max(normalized, 1), export_cap)
+
+ def _detail_limit(self) -> int:
+ return self._configured_query_limit(
+ 'detail_rows',
+ _DEFAULT_MONITORING_DETAIL_ROWS,
+ _HARD_MAX_MONITORING_DETAIL_ROWS,
+ )
+
+ def _timeseries_bucket_limit(self) -> int:
+ return self._configured_query_limit(
+ 'timeseries_buckets',
+ _DEFAULT_MONITORING_TIMESERIES_BUCKETS,
+ _HARD_MAX_MONITORING_TIMESERIES_BUCKETS,
+ )
+
+ @staticmethod
+ def _token_bucket_expression(
+ timestamp_column: sqlalchemy.Column,
+ *,
+ bucket: str,
+ dialect_name: str,
+ ):
+ """Build a server-side hour/day bucket for supported business databases."""
+
+ if bucket not in {'hour', 'day'}:
+ bucket = 'hour'
+ if dialect_name == 'postgresql':
+ return sqlalchemy.func.date_trunc(bucket, timestamp_column)
+ if dialect_name == 'sqlite':
+ bucket_format = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d'
+ return sqlalchemy.func.strftime(bucket_format, timestamp_column)
+ raise RuntimeError(f'Unsupported monitoring database dialect: {dialect_name}')
+
+ @staticmethod
+ def _require_write_context(context: ExecutionContext | None) -> str:
+ """Reject background/runtime writes that lost their execution fence."""
+
+ if not isinstance(context, ExecutionContext):
+ raise WorkspaceRequiredError('Monitoring writes require an ExecutionContext')
+ if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
+ raise WorkspaceRequiredError('Monitoring writes require an instance and Workspace')
+ if context.placement_generation <= 0:
+ raise WorkspaceRequiredError('Monitoring writes require a positive placement generation')
+ return context.workspace_uuid
+
# ========== Cleanup Methods ==========
- async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
+ async def cleanup_expired_records(
+ self,
+ context: ExecutionContext,
+ retention_days: int,
+ batch_size: int = 1000,
+ max_batches_per_table: int | None = None,
+ ) -> dict[str, int]:
"""Delete monitoring records older than the specified retention period.
Args:
@@ -29,10 +164,29 @@ class MonitoringService:
Returns:
A dict mapping table name to the number of deleted rows.
"""
+ workspace_uuid = self._require_write_context(context)
if retention_days < 1:
raise ValueError('retention_days must be >= 1')
if batch_size < 1:
raise ValueError('batch_size must be >= 1')
+ if max_batches_per_table is None:
+ cleanup_config = (
+ getattr(getattr(self.ap, 'instance_config', None), 'data', {})
+ .get('monitoring', {})
+ .get('auto_cleanup', {})
+ )
+ max_batches_per_table = cleanup_config.get(
+ 'max_batches_per_table_per_run',
+ _DEFAULT_CLEANUP_BATCHES_PER_TABLE,
+ )
+ try:
+ max_batches_per_table = int(max_batches_per_table)
+ except (TypeError, ValueError):
+ max_batches_per_table = _DEFAULT_CLEANUP_BATCHES_PER_TABLE
+ max_batches_per_table = min(
+ max(max_batches_per_table, 1),
+ _HARD_MAX_CLEANUP_BATCHES_PER_TABLE,
+ )
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
days=retention_days
@@ -83,16 +237,28 @@ class MonitoringService:
),
]
- deleted_counts: dict[str, int] = {}
+ async def delete_records() -> dict[str, int]:
+ deleted_counts: dict[str, int] = {}
+ for table_name, model_cls, ts_column, pk_column in tables_and_columns:
+ deleted_counts[table_name] = await self._delete_expired_in_batches(
+ context=context,
+ model_cls=model_cls,
+ ts_column=ts_column,
+ pk_column=pk_column,
+ cutoff=cutoff,
+ batch_size=batch_size,
+ max_batches=max_batches_per_table,
+ )
+ return deleted_counts
- for table_name, model_cls, ts_column, pk_column in tables_and_columns:
- deleted_counts[table_name] = await self._delete_expired_in_batches(
- model_cls=model_cls,
- ts_column=ts_column,
- pk_column=pk_column,
- cutoff=cutoff,
- batch_size=batch_size,
- )
+ tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
+ if callable(tenant_scope):
+ # Carry the Workspace across the complete cleanup without holding a
+ # connection. Each select+delete batch opens and commits its own UoW.
+ async with tenant_scope(workspace_uuid):
+ deleted_counts = await delete_records()
+ else:
+ deleted_counts = await delete_records()
if sum(deleted_counts.values()) > 0:
await self._release_sqlite_space()
@@ -101,29 +267,48 @@ class MonitoringService:
async def _delete_expired_in_batches(
self,
+ context: ExecutionContext,
model_cls: type,
ts_column: sqlalchemy.Column,
pk_column: sqlalchemy.Column,
cutoff: datetime.datetime,
batch_size: int,
+ max_batches: int,
) -> int:
+ workspace_uuid = self._require_write_context(context)
deleted_total = 0
- while True:
- select_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
- )
- pk_values = list(select_result.scalars().all())
- if not pk_values:
- break
+ for _batch_number in range(max_batches):
+
+ async def delete_batch() -> tuple[int, int]:
+ select_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(pk_column)
+ .where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
+ .limit(batch_size)
+ )
+ pk_values = list(select_result.scalars().all())
+ if not pk_values:
+ return 0, 0
+
+ delete_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.delete(model_cls).where(
+ model_cls.workspace_uuid == workspace_uuid,
+ pk_column.in_(pk_values),
+ )
+ )
+ return len(pk_values), int(delete_result.rowcount or 0)
+
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(workspace_uuid):
+ selected, deleted = await delete_batch()
+ else:
+ selected, deleted = await delete_batch()
- delete_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
- )
- deleted = delete_result.rowcount or 0
deleted_total += deleted
-
- if len(pk_values) < batch_size:
+ if selected == 0:
+ break
+ if selected < batch_size:
break
return deleted_total
@@ -158,28 +343,40 @@ class MonitoringService:
async def _get_message_for_tool_context(
self,
+ context: ExecutionContext,
message_id: str | None = None,
session_id: str | None = None,
):
+ workspace_uuid = self._require_write_context(context)
+ context_columns = (
+ persistence_monitoring.MonitoringMessage.id,
+ persistence_monitoring.MonitoringMessage.bot_id,
+ persistence_monitoring.MonitoringMessage.bot_name,
+ persistence_monitoring.MonitoringMessage.pipeline_id,
+ persistence_monitoring.MonitoringMessage.pipeline_name,
+ persistence_monitoring.MonitoringMessage.session_id,
+ )
if message_id:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
- persistence_monitoring.MonitoringMessage.id == message_id
+ sqlalchemy.select(*context_columns).where(
+ persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringMessage.id == message_id,
)
)
row = result.first()
if row:
- return row[0]
+ return row
if not session_id:
return None
user_query = (
- sqlalchemy.select(persistence_monitoring.MonitoringMessage)
+ sqlalchemy.select(*context_columns)
.where(
sqlalchemy.and_(
persistence_monitoring.MonitoringMessage.session_id == session_id,
persistence_monitoring.MonitoringMessage.role == 'user',
+ persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
)
)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
@@ -188,22 +385,27 @@ class MonitoringService:
result = await self.ap.persistence_mgr.execute_async(user_query)
row = result.first()
if row:
- return row[0]
+ return row
any_query = (
- sqlalchemy.select(persistence_monitoring.MonitoringMessage)
- .where(persistence_monitoring.MonitoringMessage.session_id == session_id)
+ sqlalchemy.select(*context_columns)
+ .where(
+ persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringMessage.session_id == session_id,
+ )
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
.limit(1)
)
result = await self.ap.persistence_mgr.execute_async(any_query)
row = result.first()
- return row[0] if row else None
+ return row
# ========== Recording Methods ==========
+ @_workspace_transaction
async def record_message(
self,
+ context: ExecutionContext,
bot_id: str,
bot_name: str,
pipeline_id: str,
@@ -220,9 +422,11 @@ class MonitoringService:
role: str = 'user',
) -> str:
"""Record a message"""
+ workspace_uuid = self._require_write_context(context)
message_id = str(uuid.uuid4())
message_data = {
'id': message_id,
+ 'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'bot_id': bot_id,
'bot_name': bot_name,
@@ -246,8 +450,10 @@ class MonitoringService:
return message_id
+ @_workspace_transaction
async def record_llm_call(
self,
+ context: ExecutionContext,
bot_id: str,
bot_name: str,
pipeline_id: str,
@@ -263,9 +469,11 @@ class MonitoringService:
message_id: str | None = None,
) -> str:
"""Record an LLM call"""
+ workspace_uuid = self._require_write_context(context)
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
+ 'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'model_name': model_name,
'input_tokens': input_tokens,
@@ -289,8 +497,10 @@ class MonitoringService:
return call_id
+ @_workspace_transaction
async def record_tool_call(
self,
+ context: ExecutionContext,
tool_name: str,
tool_source: str,
duration: int,
@@ -306,7 +516,12 @@ class MonitoringService:
error_message: str | None = None,
) -> str:
"""Record a tool call."""
- context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id)
+ workspace_uuid = self._require_write_context(context)
+ context_message = await self._get_message_for_tool_context(
+ context,
+ message_id=message_id,
+ session_id=session_id,
+ )
if context_message:
bot_id = bot_id or context_message.bot_id
bot_name = bot_name or context_message.bot_name
@@ -318,6 +533,7 @@ class MonitoringService:
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
+ 'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'tool_name': tool_name,
'tool_source': tool_source,
@@ -340,8 +556,10 @@ class MonitoringService:
return call_id
+ @_workspace_transaction
async def record_embedding_call(
self,
+ context: ExecutionContext,
model_name: str,
prompt_tokens: int,
total_tokens: int,
@@ -356,9 +574,11 @@ class MonitoringService:
call_type: str | None = None,
) -> str:
"""Record an embedding call"""
+ workspace_uuid = self._require_write_context(context)
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
+ 'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'model_name': model_name,
'prompt_tokens': prompt_tokens,
@@ -380,8 +600,10 @@ class MonitoringService:
return call_id
+ @_workspace_transaction
async def record_session_start(
self,
+ context: ExecutionContext,
session_id: str,
bot_id: str,
bot_name: str,
@@ -392,7 +614,9 @@ class MonitoringService:
user_name: str | None = None,
) -> None:
"""Record a new session"""
+ workspace_uuid = self._require_write_context(context)
session_data = {
+ 'workspace_uuid': workspace_uuid,
'session_id': session_id,
'bot_id': bot_id,
'bot_name': bot_name,
@@ -411,8 +635,10 @@ class MonitoringService:
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
)
+ @_workspace_transaction
async def update_session_activity(
self,
+ context: ExecutionContext,
session_id: str,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
@@ -424,6 +650,7 @@ class MonitoringService:
Returns:
True if session was found and updated, False if session doesn't exist.
"""
+ workspace_uuid = self._require_write_context(context)
update_values = {
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
@@ -437,14 +664,19 @@ class MonitoringService:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringSession)
- .where(persistence_monitoring.MonitoringSession.session_id == session_id)
+ .where(
+ persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringSession.session_id == session_id,
+ )
.values(update_values)
)
# Check if any rows were updated
return result.rowcount > 0
+ @_workspace_transaction
async def record_error(
self,
+ context: ExecutionContext,
bot_id: str,
bot_name: str,
pipeline_id: str,
@@ -456,9 +688,11 @@ class MonitoringService:
message_id: str | None = None,
) -> str:
"""Record an error"""
+ workspace_uuid = self._require_write_context(context)
error_id = str(uuid.uuid4())
error_data = {
'id': error_id,
+ 'workspace_uuid': workspace_uuid,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'error_type': error_type,
'error_message': error_message,
@@ -477,14 +711,17 @@ class MonitoringService:
return error_id
+ @_workspace_transaction
async def update_message_status(
self,
+ context: ExecutionContext,
message_id: str,
status: str,
level: str | None = None,
variables: str | None = None,
) -> None:
"""Update message status and optionally variables"""
+ workspace_uuid = self._require_write_context(context)
update_values = {'status': status}
if level is not None:
update_values['level'] = level
@@ -493,7 +730,10 @@ class MonitoringService:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
- .where(persistence_monitoring.MonitoringMessage.id == message_id)
+ .where(
+ persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringMessage.id == message_id,
+ )
.values(update_values)
)
@@ -501,17 +741,19 @@ class MonitoringService:
async def get_overview_metrics(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Get overview metrics"""
+ workspace_uuid = require_workspace_uuid(context)
# Build base query conditions
- message_conditions = []
- llm_conditions = []
- embedding_conditions = []
- session_conditions = []
+ message_conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
+ llm_conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
+ embedding_conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
+ session_conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
if bot_ids:
message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
@@ -594,6 +836,7 @@ class MonitoringService:
async def get_token_statistics(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -612,8 +855,11 @@ class MonitoringService:
token accounting.
"""
LLMCall = persistence_monitoring.MonitoringLLMCall
+ workspace_uuid = require_workspace_uuid(context)
+ if bucket not in {'hour', 'day'}:
+ bucket = 'hour'
- conditions = []
+ conditions = [LLMCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(LLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
@@ -685,21 +931,29 @@ class MonitoringService:
}
# ---- Per-model breakdown ----
- by_model_query = _apply(
- sqlalchemy.select(
- LLMCall.model_name,
- sqlalchemy.func.count(LLMCall.id),
- sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
- sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
- sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
- sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
- sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
- sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
- ).group_by(LLMCall.model_name)
+ model_total_tokens = sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0)
+ model_limit, _unused_offset = self.normalize_page_window(_HARD_MAX_MONITORING_PAGE_ROWS)
+ by_model_query = (
+ _apply(
+ sqlalchemy.select(
+ LLMCall.model_name,
+ sqlalchemy.func.count(LLMCall.id),
+ sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
+ sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
+ model_total_tokens,
+ sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.duration), 0),
+ sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.cost), 0.0),
+ sqlalchemy.func.sum(sqlalchemy.case((LLMCall.status == 'error', 1), else_=0)),
+ ).group_by(LLMCall.model_name)
+ )
+ .order_by(model_total_tokens.desc())
+ .limit(model_limit + 1)
)
by_model_result = await self.ap.persistence_mgr.execute_async(by_model_query)
+ by_model_rows = by_model_result.all()
+ by_model_truncated = len(by_model_rows) > model_limit
by_model = []
- for mrow in by_model_result.all():
+ for mrow in by_model_rows[:model_limit]:
(
model_name,
m_calls,
@@ -724,49 +978,65 @@ class MonitoringService:
'avg_duration_ms': int((m_duration or 0) / m_calls) if m_calls > 0 else 0,
}
)
- by_model.sort(key=lambda x: x['total_tokens'], reverse=True)
-
# ---- Time-bucketed series ----
- # Use a DB-agnostic bucketing approach: fetch (timestamp, tokens) rows and
- # aggregate in Python. The window is bounded by the time filter, so this is
- # cheap for typical dashboard ranges (hours/days).
- series_query = _apply(
- sqlalchemy.select(
- LLMCall.timestamp,
- LLMCall.input_tokens,
- LLMCall.output_tokens,
- LLMCall.total_tokens,
- ).order_by(LLMCall.timestamp.asc())
+ # Aggregate before materialization. Requests may omit their time window,
+ # so fetching every historical call and bucketing in Python is unsafe.
+ engine = self.ap.persistence_mgr.get_db_engine()
+ bucket_expression = self._token_bucket_expression(
+ LLMCall.timestamp,
+ bucket=bucket,
+ dialect_name=engine.dialect.name,
+ )
+ bucket_limit = self._timeseries_bucket_limit()
+ series_query = (
+ _apply(
+ sqlalchemy.select(
+ bucket_expression.label('bucket'),
+ sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.input_tokens), 0),
+ sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.output_tokens), 0),
+ sqlalchemy.func.coalesce(sqlalchemy.func.sum(LLMCall.total_tokens), 0),
+ sqlalchemy.func.count(LLMCall.id),
+ ).group_by(bucket_expression)
+ )
+ .order_by(bucket_expression.desc())
+ .limit(bucket_limit + 1)
)
series_result = await self.ap.persistence_mgr.execute_async(series_query)
bucket_fmt = '%Y-%m-%d %H:00' if bucket == 'hour' else '%Y-%m-%d'
- buckets: dict[str, dict] = {}
- for srow in series_result.all():
- ts, s_in, s_out, s_total = srow
- if ts is None:
+ series_rows = series_result.all()
+ timeseries_truncated = len(series_rows) > bucket_limit
+ timeseries = []
+ for bucket_value, s_in, s_out, s_total, calls in reversed(series_rows[:bucket_limit]):
+ if bucket_value is None:
continue
- key = ts.strftime(bucket_fmt)
- b = buckets.setdefault(
- key,
- {'bucket': key, 'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0, 'calls': 0},
+ bucket_key = (
+ bucket_value.strftime(bucket_fmt)
+ if isinstance(bucket_value, (datetime.datetime, datetime.date))
+ else str(bucket_value)
+ )
+ timeseries.append(
+ {
+ 'bucket': bucket_key,
+ 'input_tokens': int(s_in or 0),
+ 'output_tokens': int(s_out or 0),
+ 'total_tokens': int(s_total or 0),
+ 'calls': int(calls or 0),
+ }
)
- b['input_tokens'] += int(s_in or 0)
- b['output_tokens'] += int(s_out or 0)
- b['total_tokens'] += int(s_total or 0)
- b['calls'] += 1
-
- timeseries = [buckets[k] for k in sorted(buckets.keys())]
return {
'summary': summary,
'by_model': by_model,
+ 'by_model_truncated': by_model_truncated,
'timeseries': timeseries,
+ 'timeseries_truncated': timeseries_truncated,
'bucket': bucket,
}
async def get_messages(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
@@ -776,7 +1046,9 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get messages with filters"""
- conditions = []
+ limit, offset = self.normalize_page_window(limit, offset)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
@@ -820,6 +1092,7 @@ class MonitoringService:
async def get_llm_calls(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -828,7 +1101,9 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get LLM calls with filters"""
- conditions = []
+ limit, offset = self.normalize_page_window(limit, offset)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
@@ -871,6 +1146,7 @@ class MonitoringService:
async def get_tool_calls(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
@@ -880,7 +1156,9 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get tool calls with filters"""
- conditions = []
+ limit, offset = self.normalize_page_window(limit, offset)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
@@ -923,6 +1201,7 @@ class MonitoringService:
async def get_embedding_calls(
self,
+ context: TenantContext,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
knowledge_base_id: str | None = None,
@@ -930,7 +1209,9 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get embedding calls with filters"""
- conditions = []
+ limit, offset = self.normalize_page_window(limit, offset)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
if start_time:
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
@@ -971,6 +1252,7 @@ class MonitoringService:
async def get_sessions(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -980,7 +1262,9 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get sessions with filters"""
- conditions = []
+ limit, offset = self.normalize_page_window(limit, offset)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
@@ -1025,6 +1309,7 @@ class MonitoringService:
async def get_errors(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1033,7 +1318,9 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get errors with filters"""
- conditions = []
+ limit, offset = self.normalize_page_window(limit, offset)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
@@ -1076,12 +1363,16 @@ class MonitoringService:
async def get_session_analysis(
self,
+ context: TenantContext,
session_id: str,
) -> dict:
- """Get detailed analysis for a specific session"""
+ """Get bounded session details with full statistics computed in SQL."""
+ workspace_uuid = require_workspace_uuid(context)
+ detail_limit = self._detail_limit()
# Get session info
session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where(
- persistence_monitoring.MonitoringSession.session_id == session_id
+ persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringSession.session_id == session_id,
)
session_result = await self.ap.persistence_mgr.execute_async(session_query)
session_row = session_result.first()
@@ -1094,63 +1385,112 @@ class MonitoringService:
session = session_row[0] if isinstance(session_row, tuple) else session_row
- # Get messages for this session
- messages_query = (
- sqlalchemy.select(persistence_monitoring.MonitoringMessage)
- .where(persistence_monitoring.MonitoringMessage.session_id == session_id)
- .order_by(persistence_monitoring.MonitoringMessage.timestamp.asc())
+ message_stats_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id).label('total'),
+ sqlalchemy.func.sum(
+ sqlalchemy.case(
+ (persistence_monitoring.MonitoringMessage.status == 'success', 1),
+ else_=0,
+ )
+ ).label('success'),
+ sqlalchemy.func.sum(
+ sqlalchemy.case(
+ (persistence_monitoring.MonitoringMessage.status == 'error', 1),
+ else_=0,
+ )
+ ).label('error'),
+ sqlalchemy.func.sum(
+ sqlalchemy.case(
+ (persistence_monitoring.MonitoringMessage.status == 'pending', 1),
+ else_=0,
+ )
+ ).label('pending'),
+ sqlalchemy.func.min(persistence_monitoring.MonitoringMessage.timestamp).label('first_timestamp'),
+ sqlalchemy.func.max(persistence_monitoring.MonitoringMessage.timestamp).label('last_timestamp'),
+ ).where(
+ persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringMessage.session_id == session_id,
+ )
)
- messages_result = await self.ap.persistence_mgr.execute_async(messages_query)
- messages_rows = messages_result.all()
+ message_stats = message_stats_result.one()
- # Count messages by status
- success_messages = 0
- error_messages = 0
- pending_messages = 0
- for row in messages_rows:
- msg = row[0] if isinstance(row, tuple) else row
- if msg.status == 'success':
- success_messages += 1
- elif msg.status == 'error':
- error_messages += 1
- elif msg.status == 'pending':
- pending_messages += 1
-
- # Get LLM calls for this session
- llm_query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).where(
- persistence_monitoring.MonitoringLLMCall.session_id == session_id
+ llm_stats_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id).label('total_calls'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.input_tokens),
+ 0,
+ ).label('total_input_tokens'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.output_tokens),
+ 0,
+ ).label('total_output_tokens'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.total_tokens),
+ 0,
+ ).label('total_tokens'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.duration),
+ 0,
+ ).label('total_duration'),
+ sqlalchemy.func.sum(
+ sqlalchemy.case(
+ (persistence_monitoring.MonitoringLLMCall.status == 'success', 1),
+ else_=0,
+ )
+ ).label('success_calls'),
+ sqlalchemy.func.sum(
+ sqlalchemy.case(
+ (persistence_monitoring.MonitoringLLMCall.status != 'success', 1),
+ else_=0,
+ )
+ ).label('error_calls'),
+ ).where(
+ persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringLLMCall.session_id == session_id,
+ )
)
- llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
- llm_rows = llm_result.all()
+ llm_stats = llm_stats_result.one()
- # Calculate LLM statistics
- total_llm_calls = len(llm_rows)
- total_input_tokens = 0
- total_output_tokens = 0
- total_tokens = 0
- total_duration = 0
- success_llm_calls = 0
- error_llm_calls = 0
-
- for row in llm_rows:
- llm_call = row[0] if isinstance(row, tuple) else row
- total_input_tokens += llm_call.input_tokens
- total_output_tokens += llm_call.output_tokens
- total_tokens += llm_call.total_tokens
- total_duration += llm_call.duration
- if llm_call.status == 'success':
- success_llm_calls += 1
- else:
- error_llm_calls += 1
-
- # Get tool calls for this session
+ tool_stats_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id).label('total_calls'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringToolCall.duration),
+ 0,
+ ).label('total_duration'),
+ sqlalchemy.func.sum(
+ sqlalchemy.case(
+ (persistence_monitoring.MonitoringToolCall.status == 'success', 1),
+ else_=0,
+ )
+ ).label('success_calls'),
+ sqlalchemy.func.sum(
+ sqlalchemy.case(
+ (persistence_monitoring.MonitoringToolCall.status != 'success', 1),
+ else_=0,
+ )
+ ).label('error_calls'),
+ ).where(
+ persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringToolCall.session_id == session_id,
+ )
+ )
+ tool_stats = tool_stats_result.one()
tool_query = (
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
- .where(persistence_monitoring.MonitoringToolCall.session_id == session_id)
+ .where(
+ persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringToolCall.session_id == session_id,
+ )
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
+ .limit(detail_limit + 1)
)
tool_result = await self.ap.persistence_mgr.execute_async(tool_query)
tool_rows = tool_result.all()
+ tool_calls_truncated = len(tool_rows) > detail_limit
+ tool_rows = tool_rows[:detail_limit]
tool_calls = [
self.ap.persistence_mgr.serialize_model(
@@ -1159,26 +1499,19 @@ class MonitoringService:
for row in tool_rows
]
- total_tool_calls = len(tool_rows)
- success_tool_calls = 0
- error_tool_calls = 0
- total_tool_duration = 0
- for row in tool_rows:
- tool_call = row[0] if isinstance(row, tuple) else row
- total_tool_duration += tool_call.duration
- if tool_call.status == 'success':
- success_tool_calls += 1
- else:
- error_tool_calls += 1
-
- # Get errors for this session
error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError)
- .where(persistence_monitoring.MonitoringError.session_id == session_id)
+ .where(
+ persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringError.session_id == session_id,
+ )
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
+ .limit(detail_limit + 1)
)
error_result = await self.ap.persistence_mgr.execute_async(error_query)
error_rows = error_result.all()
+ errors_truncated = len(error_rows) > detail_limit
+ error_rows = error_rows[:detail_limit]
errors = [
self.ap.persistence_mgr.serialize_model(
@@ -1187,53 +1520,64 @@ class MonitoringService:
for row in error_rows
]
- # Calculate session duration
- if messages_rows:
- first_msg = messages_rows[0][0] if isinstance(messages_rows[0], tuple) else messages_rows[0]
- last_msg = messages_rows[-1][0] if isinstance(messages_rows[-1], tuple) else messages_rows[-1]
- session_duration_seconds = int((last_msg.timestamp - first_msg.timestamp).total_seconds())
+ if message_stats.first_timestamp is not None and message_stats.last_timestamp is not None:
+ session_duration_seconds = int(
+ (message_stats.last_timestamp - message_stats.first_timestamp).total_seconds()
+ )
else:
session_duration_seconds = 0
+ total_llm_calls = int(llm_stats.total_calls or 0)
+ total_tool_calls = int(tool_stats.total_calls or 0)
return {
'session_id': session_id,
'found': True,
'session': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, session),
'message_stats': {
- 'total': len(messages_rows),
- 'success': success_messages,
- 'error': error_messages,
- 'pending': pending_messages,
+ 'total': int(message_stats.total or 0),
+ 'success': int(message_stats.success or 0),
+ 'error': int(message_stats.error or 0),
+ 'pending': int(message_stats.pending or 0),
},
'llm_stats': {
'total_calls': total_llm_calls,
- 'success_calls': success_llm_calls,
- 'error_calls': error_llm_calls,
- 'total_input_tokens': total_input_tokens,
- 'total_output_tokens': total_output_tokens,
- 'total_tokens': total_tokens,
- 'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,
+ 'success_calls': int(llm_stats.success_calls or 0),
+ 'error_calls': int(llm_stats.error_calls or 0),
+ 'total_input_tokens': int(llm_stats.total_input_tokens or 0),
+ 'total_output_tokens': int(llm_stats.total_output_tokens or 0),
+ 'total_tokens': int(llm_stats.total_tokens or 0),
+ 'average_duration_ms': (int(llm_stats.total_duration / total_llm_calls) if total_llm_calls > 0 else 0),
},
'tool_calls': tool_calls,
'tool_stats': {
'total_calls': total_tool_calls,
- 'success_calls': success_tool_calls,
- 'error_calls': error_tool_calls,
- 'total_duration_ms': total_tool_duration,
- 'average_duration_ms': int(total_tool_duration / total_tool_calls) if total_tool_calls > 0 else 0,
+ 'success_calls': int(tool_stats.success_calls or 0),
+ 'error_calls': int(tool_stats.error_calls or 0),
+ 'total_duration_ms': int(tool_stats.total_duration or 0),
+ 'average_duration_ms': (
+ int(tool_stats.total_duration / total_tool_calls) if total_tool_calls > 0 else 0
+ ),
},
'errors': errors,
+ 'detail_truncated': {
+ 'tool_calls': tool_calls_truncated,
+ 'errors': errors_truncated,
+ },
'session_duration_seconds': session_duration_seconds,
}
async def get_message_details(
self,
+ context: TenantContext,
message_id: str,
) -> dict:
- """Get detailed information for a specific message including associated LLM calls and errors"""
+ """Get bounded message details with full statistics computed in SQL."""
+ workspace_uuid = require_workspace_uuid(context)
+ detail_limit = self._detail_limit()
# Get message info
message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
- persistence_monitoring.MonitoringMessage.id == message_id
+ persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringMessage.id == message_id,
)
message_result = await self.ap.persistence_mgr.execute_async(message_query)
message_row = message_result.first()
@@ -1246,14 +1590,44 @@ class MonitoringService:
message = message_row[0] if isinstance(message_row, tuple) else message_row
- # Get LLM calls for this message
+ llm_stats_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id).label('total_calls'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.input_tokens),
+ 0,
+ ).label('total_input_tokens'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.output_tokens),
+ 0,
+ ).label('total_output_tokens'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.total_tokens),
+ 0,
+ ).label('total_tokens'),
+ sqlalchemy.func.coalesce(
+ sqlalchemy.func.sum(persistence_monitoring.MonitoringLLMCall.duration),
+ 0,
+ ).label('total_duration'),
+ ).where(
+ persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringLLMCall.message_id == message_id,
+ )
+ )
+ llm_stats = llm_stats_result.one()
llm_query = (
sqlalchemy.select(persistence_monitoring.MonitoringLLMCall)
- .where(persistence_monitoring.MonitoringLLMCall.message_id == message_id)
+ .where(
+ persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringLLMCall.message_id == message_id,
+ )
.order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc())
+ .limit(detail_limit + 1)
)
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
llm_rows = llm_result.all()
+ llm_calls_truncated = len(llm_rows) > detail_limit
+ llm_rows = llm_rows[:detail_limit]
llm_calls = [
self.ap.persistence_mgr.serialize_model(
@@ -1262,20 +1636,19 @@ class MonitoringService:
for row in llm_rows
]
- # Calculate LLM statistics
- total_input_tokens = sum(call.input_tokens for call in llm_rows)
- total_output_tokens = sum(call.output_tokens for call in llm_rows)
- total_tokens = sum(call.total_tokens for call in llm_rows)
- total_duration = sum(call.duration for call in llm_rows)
-
- # Get errors for this message
error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError)
- .where(persistence_monitoring.MonitoringError.message_id == message_id)
+ .where(
+ persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid,
+ persistence_monitoring.MonitoringError.message_id == message_id,
+ )
.order_by(persistence_monitoring.MonitoringError.timestamp.asc())
+ .limit(detail_limit + 1)
)
error_result = await self.ap.persistence_mgr.execute_async(error_query)
error_rows = error_result.all()
+ errors_truncated = len(error_rows) > detail_limit
+ error_rows = error_rows[:detail_limit]
errors = [
self.ap.persistence_mgr.serialize_model(
@@ -1283,6 +1656,7 @@ class MonitoringService:
)
for row in error_rows
]
+ total_llm_calls = int(llm_stats.total_calls or 0)
return {
'message_id': message_id,
@@ -1290,14 +1664,18 @@ class MonitoringService:
'message': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message),
'llm_calls': llm_calls,
'llm_stats': {
- 'total_calls': len(llm_rows),
- 'total_input_tokens': total_input_tokens,
- 'total_output_tokens': total_output_tokens,
- 'total_tokens': total_tokens,
- 'total_duration_ms': total_duration,
- 'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0,
+ 'total_calls': total_llm_calls,
+ 'total_input_tokens': int(llm_stats.total_input_tokens or 0),
+ 'total_output_tokens': int(llm_stats.total_output_tokens or 0),
+ 'total_tokens': int(llm_stats.total_tokens or 0),
+ 'total_duration_ms': int(llm_stats.total_duration or 0),
+ 'average_duration_ms': (int(llm_stats.total_duration / total_llm_calls) if total_llm_calls > 0 else 0),
},
'errors': errors,
+ 'detail_truncated': {
+ 'llm_calls': llm_calls_truncated,
+ 'errors': errors_truncated,
+ },
}
# ========== Export Methods ==========
@@ -1379,6 +1757,7 @@ class MonitoringService:
async def export_messages(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1386,7 +1765,9 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export messages as list of dictionaries for CSV conversion"""
- conditions = []
+ limit = self.normalize_export_limit(limit)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringMessage.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
@@ -1432,6 +1813,7 @@ class MonitoringService:
async def export_llm_calls(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1439,7 +1821,9 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export LLM calls as list of dictionaries for CSV conversion"""
- conditions = []
+ limit = self.normalize_export_limit(limit)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringLLMCall.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
@@ -1485,13 +1869,16 @@ class MonitoringService:
async def export_embedding_calls(
self,
+ context: TenantContext,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
knowledge_base_id: str | None = None,
limit: int = 100000,
) -> list[dict]:
"""Export embedding calls as list of dictionaries for CSV conversion"""
- conditions = []
+ limit = self.normalize_export_limit(limit)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringEmbeddingCall.workspace_uuid == workspace_uuid]
if start_time:
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
@@ -1533,6 +1920,7 @@ class MonitoringService:
async def export_errors(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1540,7 +1928,9 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export errors as list of dictionaries for CSV conversion"""
- conditions = []
+ limit = self.normalize_export_limit(limit)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringError.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
@@ -1581,6 +1971,7 @@ class MonitoringService:
async def export_sessions(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1588,7 +1979,9 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export sessions as list of dictionaries for CSV conversion"""
- conditions = []
+ limit = self.normalize_export_limit(limit)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
@@ -1633,6 +2026,7 @@ class MonitoringService:
async def record_feedback(
self,
+ context: ExecutionContext,
feedback_id: str,
feedback_type: int,
feedback_content: str | None = None,
@@ -1646,7 +2040,7 @@ class MonitoringService:
stream_id: str | None = None,
user_id: str | None = None,
platform: str | None = None,
- ) -> str:
+ ) -> str | None:
"""Record user feedback (like/dislike) from AI Bot conversation.
Args:
@@ -1669,6 +2063,7 @@ class MonitoringService:
"""
import json
+ workspace_uuid = self._require_write_context(context)
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
@@ -1677,78 +2072,68 @@ class MonitoringService:
# Handle cancel feedback (type=3): delete existing record
if feedback_type == 3:
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
+ sqlalchemy.delete(MonitoringFeedback).where(
+ MonitoringFeedback.workspace_uuid == workspace_uuid,
+ MonitoringFeedback.feedback_id == feedback_id,
+ )
)
return None
- # Check if record with this feedback_id already exists
- existing_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
- )
- existing_row = existing_result.first()
-
- if existing_row:
- # UPDATE existing record
- existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(MonitoringFeedback)
- .where(MonitoringFeedback.feedback_id == feedback_id)
- .values(
- timestamp=now,
- feedback_type=feedback_type,
- feedback_content=feedback_content,
- inaccurate_reasons=reasons_json,
- bot_id=bot_id or existing.bot_id,
- bot_name=bot_name or existing.bot_name,
- pipeline_id=pipeline_id or existing.pipeline_id,
- pipeline_name=pipeline_name or existing.pipeline_name,
- session_id=session_id or existing.session_id,
- message_id=message_id or existing.message_id,
- stream_id=stream_id or existing.stream_id,
- user_id=user_id or existing.user_id,
- platform=platform or existing.platform,
- )
- )
- return existing.id
+ record_data = {
+ 'id': str(uuid.uuid4()),
+ 'workspace_uuid': workspace_uuid,
+ 'timestamp': now,
+ 'feedback_id': feedback_id,
+ 'feedback_type': feedback_type,
+ 'feedback_content': feedback_content,
+ 'inaccurate_reasons': reasons_json,
+ 'bot_id': bot_id,
+ 'bot_name': bot_name,
+ 'pipeline_id': pipeline_id,
+ 'pipeline_name': pipeline_name,
+ 'session_id': session_id,
+ 'message_id': message_id,
+ 'stream_id': stream_id,
+ 'user_id': user_id,
+ 'platform': platform,
+ }
+ dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
+ if dialect_name == 'postgresql':
+ statement = postgresql_dialect.insert(MonitoringFeedback).values(record_data)
+ elif dialect_name == 'sqlite':
+ statement = sqlite_dialect.insert(MonitoringFeedback).values(record_data)
else:
- # INSERT new record with IntegrityError defense
- record_id = str(uuid.uuid4())
- record_data = {
- 'id': record_id,
- 'timestamp': now,
- 'feedback_id': feedback_id,
- 'feedback_type': feedback_type,
- 'feedback_content': feedback_content,
- 'inaccurate_reasons': reasons_json,
- 'bot_id': bot_id,
- 'bot_name': bot_name,
- 'pipeline_id': pipeline_id,
- 'pipeline_name': pipeline_name,
- 'session_id': session_id,
- 'message_id': message_id,
- 'stream_id': stream_id,
- 'user_id': user_id,
- 'platform': platform,
- }
- try:
- await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringFeedback).values(record_data))
- return record_id
- except Exception:
- # UNIQUE constraint conflict (concurrent feedback for same feedback_id)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(MonitoringFeedback)
- .where(MonitoringFeedback.feedback_id == feedback_id)
- .values(
- timestamp=now,
- feedback_type=feedback_type,
- feedback_content=feedback_content,
- inaccurate_reasons=reasons_json,
- )
- )
- return feedback_id
+ raise RuntimeError(f'Monitoring feedback upsert does not support {dialect_name!r}')
+
+ excluded = statement.excluded
+
+ def preserve_existing(column):
+ return sqlalchemy.func.coalesce(sqlalchemy.func.nullif(getattr(excluded, column.key), ''), column)
+
+ statement = statement.on_conflict_do_update(
+ index_elements=[MonitoringFeedback.workspace_uuid, MonitoringFeedback.feedback_id],
+ set_={
+ 'timestamp': excluded.timestamp,
+ 'feedback_type': excluded.feedback_type,
+ 'feedback_content': excluded.feedback_content,
+ 'inaccurate_reasons': excluded.inaccurate_reasons,
+ 'bot_id': preserve_existing(MonitoringFeedback.bot_id),
+ 'bot_name': preserve_existing(MonitoringFeedback.bot_name),
+ 'pipeline_id': preserve_existing(MonitoringFeedback.pipeline_id),
+ 'pipeline_name': preserve_existing(MonitoringFeedback.pipeline_name),
+ 'session_id': preserve_existing(MonitoringFeedback.session_id),
+ 'message_id': preserve_existing(MonitoringFeedback.message_id),
+ 'stream_id': preserve_existing(MonitoringFeedback.stream_id),
+ 'user_id': preserve_existing(MonitoringFeedback.user_id),
+ 'platform': preserve_existing(MonitoringFeedback.platform),
+ },
+ ).returning(MonitoringFeedback.id)
+ result = await self.ap.persistence_mgr.execute_async(statement)
+ return str(result.scalar_one())
async def get_feedback_stats(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1759,7 +2144,8 @@ class MonitoringService:
Returns:
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
"""
- conditions = []
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
@@ -1837,6 +2223,7 @@ class MonitoringService:
async def get_feedback_list(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
feedback_type: int | None = None,
@@ -1846,7 +2233,9 @@ class MonitoringService:
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get feedback list with filters."""
- conditions = []
+ limit, offset = self.normalize_page_window(limit, offset)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
@@ -1889,6 +2278,7 @@ class MonitoringService:
async def export_feedback(
self,
+ context: TenantContext,
bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
@@ -1896,7 +2286,9 @@ class MonitoringService:
limit: int = 100000,
) -> list[dict]:
"""Export feedback as list of dictionaries for CSV conversion."""
- conditions = []
+ limit = self.normalize_export_limit(limit)
+ workspace_uuid = require_workspace_uuid(context)
+ conditions = [persistence_monitoring.MonitoringFeedback.workspace_uuid == workspace_uuid]
if bot_ids:
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py
index 2a6451c8b..d02fda6aa 100644
--- a/src/langbot/pkg/api/http/service/pipeline.py
+++ b/src/langbot/pkg/api/http/service/pipeline.py
@@ -6,6 +6,9 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import pipeline as persistence_pipeline
+from ....workspace.errors import WorkspaceNotFoundError
+from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
+from .tenant import TenantContext, require_workspace_uuid, scope_statement
default_stage_order = [
@@ -30,7 +33,8 @@ class PipelineService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def get_pipeline_metadata(self) -> list[dict]:
+ async def get_pipeline_metadata(self, context: TenantContext) -> list[dict]:
+ require_workspace_uuid(context)
return [
self.ap.pipeline_config_meta_trigger,
self.ap.pipeline_config_meta_safety,
@@ -38,8 +42,19 @@ class PipelineService:
self.ap.pipeline_config_meta_output,
]
- async def get_pipelines(self, sort_by: str = 'created_at', sort_order: str = 'DESC') -> list[dict]:
- query = sqlalchemy.select(persistence_pipeline.LegacyPipeline)
+ async def get_pipelines(
+ self,
+ context: TenantContext,
+ sort_by: str = 'created_at',
+ sort_order: str = 'DESC',
+ *,
+ include_secret: bool = False,
+ ) -> list[dict]:
+ query = scope_statement(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline),
+ persistence_pipeline.LegacyPipeline,
+ context,
+ )
if sort_by == 'created_at':
if sort_order == 'DESC':
@@ -54,15 +69,26 @@ class PipelineService:
result = await self.ap.persistence_mgr.execute_async(query)
pipelines = result.all()
- return [
+ serialized = [
self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
for pipeline in pipelines
]
+ return serialized if include_secret else [redact_secrets(pipeline) for pipeline in serialized]
- async def get_pipeline(self, pipeline_uuid: str) -> dict | None:
+ async def get_pipeline(
+ self,
+ context: TenantContext,
+ pipeline_uuid: str,
+ *,
+ include_secret: bool = False,
+ ) -> dict | None:
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
- persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
+ ),
+ persistence_pipeline.LegacyPipeline,
+ context,
)
)
@@ -71,20 +97,24 @@ class PipelineService:
if pipeline is None:
return None
- return self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
+ serialized = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
+ return serialized if include_secret else redact_secrets(serialized)
- async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
+ async def create_pipeline(self, context: TenantContext, pipeline_data: dict, default: bool = False) -> str:
from ....utils import paths as path_utils
+ workspace_uuid = require_workspace_uuid(context)
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_pipelines = limitation.get('max_pipelines', -1)
if max_pipelines >= 0:
- existing_pipelines = await self.get_pipelines()
+ existing_pipelines = await self.get_pipelines(context)
if len(existing_pipelines) >= max_pipelines:
raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')
+ pipeline_data = pipeline_data.copy()
pipeline_data['uuid'] = str(uuid.uuid4())
+ pipeline_data['workspace_uuid'] = workspace_uuid
pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version()
pipeline_data['stages'] = default_stage_order.copy()
pipeline_data['is_default'] = default
@@ -108,79 +138,122 @@ class PipelineService:
sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**pipeline_data)
)
- pipeline = await self.get_pipeline(pipeline_data['uuid'])
+ pipeline = await self.get_pipeline(context, pipeline_data['uuid'], include_secret=True)
- await self.ap.pipeline_mgr.load_pipeline(pipeline)
+ await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
return pipeline_data['uuid']
- async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
+ async def update_pipeline(self, context: TenantContext, pipeline_uuid: str, pipeline_data: dict) -> None:
+ workspace_uuid = require_workspace_uuid(context)
pipeline_data = pipeline_data.copy()
- for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
+ for protected_field in ('uuid', 'workspace_uuid', 'for_version', 'stages', 'is_default'):
pipeline_data.pop(protected_field, None)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_pipeline.LegacyPipeline)
- .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
- .values(**pipeline_data)
- )
+ if 'config' in pipeline_data:
+ current_config = None
+ if contains_secret_placeholder(pipeline_data['config']):
+ current_pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
+ if current_pipeline is None:
+ raise WorkspaceNotFoundError('Pipeline not found')
+ current_config = current_pipeline.get('config', {})
+ pipeline_data['config'] = restore_secret_placeholders(
+ pipeline_data['config'],
+ current_config if current_config is not None else {},
+ )
- pipeline = await self.get_pipeline(pipeline_uuid)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(persistence_pipeline.LegacyPipeline)
+ .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
+ .values(**pipeline_data),
+ persistence_pipeline.LegacyPipeline,
+ workspace_uuid,
+ )
+ )
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Pipeline not found')
+
+ pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
+ if pipeline is None:
+ raise WorkspaceNotFoundError('Pipeline not found')
if 'name' in pipeline_data:
from ....entity.persistence import bot as persistence_bot
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid)
+ scope_statement(
+ sqlalchemy.select(persistence_bot.Bot).where(
+ persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid
+ ),
+ persistence_bot.Bot,
+ workspace_uuid,
+ )
)
bots = result.all()
for bot in bots:
bot_data = {'use_pipeline_name': pipeline_data['name']}
- await self.ap.bot_service.update_bot(bot.uuid, bot_data)
+ await self.ap.bot_service.update_bot(context, bot.uuid, bot_data)
- await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
- await self.ap.pipeline_mgr.load_pipeline(pipeline)
+ await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
+ await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
# update all conversation that use this pipeline
for session in self.ap.sess_mgr.session_list:
- if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid:
+ if (
+ session.using_conversation is not None
+ and session.using_conversation.pipeline_uuid == pipeline_uuid
+ and getattr(session, 'workspace_uuid', workspace_uuid) == workspace_uuid
+ ):
session.using_conversation = None
- async def delete_pipeline(self, pipeline_uuid: str) -> None:
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(
- persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
+ async def delete_pipeline(self, context: TenantContext, pipeline_uuid: str) -> None:
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
+ ),
+ persistence_pipeline.LegacyPipeline,
+ context,
)
)
- await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Pipeline not found')
+ await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
- async def copy_pipeline(self, pipeline_uuid: str) -> str:
+ async def copy_pipeline(self, context: TenantContext, pipeline_uuid: str) -> str:
"""Copy a pipeline with all its configurations"""
+ workspace_uuid = require_workspace_uuid(context)
# Check limitation
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
max_pipelines = limitation.get('max_pipelines', -1)
if max_pipelines >= 0:
- existing_pipelines = await self.get_pipelines()
+ existing_pipelines = await self.get_pipelines(context)
if len(existing_pipelines) >= max_pipelines:
raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')
# Get the original pipeline
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
- persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
+ ),
+ persistence_pipeline.LegacyPipeline,
+ workspace_uuid,
)
)
original_pipeline = result.first()
if original_pipeline is None:
- raise ValueError(f'Pipeline {pipeline_uuid} not found')
+ raise WorkspaceNotFoundError(f'Pipeline {pipeline_uuid} not found')
# Create new pipeline data
new_uuid = str(uuid.uuid4())
new_pipeline_data = {
'uuid': new_uuid,
+ 'workspace_uuid': workspace_uuid,
'name': f'{original_pipeline.name} (Copy)',
'description': original_pipeline.description,
'for_version': self.ap.ver_mgr.get_current_version(),
@@ -207,13 +280,14 @@ class PipelineService:
)
# Load the new pipeline
- pipeline = await self.get_pipeline(new_uuid)
- await self.ap.pipeline_mgr.load_pipeline(pipeline)
+ pipeline = await self.get_pipeline(context, new_uuid, include_secret=True)
+ await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
return new_uuid
async def update_pipeline_extensions(
self,
+ context: TenantContext,
pipeline_uuid: str,
bound_plugins: list[dict],
bound_mcp_servers: list[str] = None,
@@ -225,16 +299,21 @@ class PipelineService:
mcp_resource_agent_read_enabled: bool | None = None,
) -> None:
"""Update the bound plugins and MCP servers for a pipeline"""
+ workspace_uuid = require_workspace_uuid(context)
# Get current pipeline
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
- persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
+ persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
+ ),
+ persistence_pipeline.LegacyPipeline,
+ workspace_uuid,
)
)
pipeline = result.first()
if pipeline is None:
- raise ValueError(f'Pipeline {pipeline_uuid} not found')
+ raise WorkspaceNotFoundError(f'Pipeline {pipeline_uuid} not found')
# Update extensions_preferences
extensions_preferences = pipeline.extensions_preferences or {}
@@ -252,12 +331,16 @@ class PipelineService:
extensions_preferences['mcp_resources'] = bound_mcp_resources
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_pipeline.LegacyPipeline)
- .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
- .values(extensions_preferences=extensions_preferences)
+ scope_statement(
+ sqlalchemy.update(persistence_pipeline.LegacyPipeline)
+ .where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
+ .values(extensions_preferences=extensions_preferences),
+ persistence_pipeline.LegacyPipeline,
+ workspace_uuid,
+ )
)
# Reload pipeline to apply changes
- await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
- pipeline = await self.get_pipeline(pipeline_uuid)
- await self.ap.pipeline_mgr.load_pipeline(pipeline)
+ await self.ap.pipeline_mgr.remove_pipeline(context, pipeline_uuid)
+ pipeline = await self.get_pipeline(context, pipeline_uuid, include_secret=True)
+ await self.ap.pipeline_mgr.load_pipeline(context, pipeline)
diff --git a/src/langbot/pkg/api/http/service/provider.py b/src/langbot/pkg/api/http/service/provider.py
index 39df7f4a9..74647d7ab 100644
--- a/src/langbot/pkg/api/http/service/provider.py
+++ b/src/langbot/pkg/api/http/service/provider.py
@@ -7,6 +7,9 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import model as persistence_model
+from ....workspace.errors import WorkspaceNotFoundError
+from .secrets import contains_secret_placeholder, redact_secrets, restore_secret_placeholders
+from .tenant import TenantContext, require_workspace_uuid, scope_statement
class ModelProviderService:
@@ -35,9 +38,15 @@ class ModelProviderService:
return normalized_keys
- async def get_providers(self) -> list[dict]:
+ async def get_providers(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
"""Get all providers"""
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider),
+ persistence_model.ModelProvider,
+ context,
+ )
+ )
providers = result.all()
providers_list = []
for p in providers:
@@ -50,14 +59,25 @@ class ModelProviderService:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
+ if not include_secret:
+ provider_dict = redact_secrets(provider_dict)
providers_list.append(provider_dict)
return providers_list
- async def get_provider(self, provider_uuid: str) -> dict | None:
+ async def get_provider(
+ self,
+ context: TenantContext,
+ provider_uuid: str,
+ include_secret: bool = False,
+ ) -> dict | None:
"""Get a single provider by UUID"""
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.uuid == provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider).where(
+ persistence_model.ModelProvider.uuid == provider_uuid
+ ),
+ persistence_model.ModelProvider,
+ context,
)
)
provider = result.first()
@@ -72,103 +92,171 @@ class ModelProviderService:
provider_dict['api_keys'] = json.loads(provider_dict['api_keys'])
except Exception:
provider_dict['api_keys'] = []
+ if not include_secret:
+ provider_dict = redact_secrets(provider_dict)
return provider_dict
- async def create_provider(self, provider_data: dict) -> str:
+ async def create_provider(self, context: TenantContext, provider_data: dict) -> str:
"""Create a new provider"""
+ provider_data = provider_data.copy()
provider_data['uuid'] = str(uuid.uuid4())
- provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
+ provider_data['workspace_uuid'] = require_workspace_uuid(context)
+ provider_data['api_keys'] = self._normalize_api_keys(
+ restore_secret_placeholders(provider_data.get('api_keys'), sensitive=True)
+ )
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
# load to runtime
- runtime_provider = await self.ap.model_mgr.load_provider(provider_data)
- self.ap.model_mgr.provider_dict[runtime_provider.provider_entity.uuid] = runtime_provider
+ runtime_provider = await self.ap.model_mgr.load_provider(context, provider_data)
+ await self.ap.model_mgr.cache_provider(context, runtime_provider)
return provider_data['uuid']
- async def update_provider(self, provider_uuid: str, provider_data: dict) -> None:
+ async def update_provider(self, context: TenantContext, provider_uuid: str, provider_data: dict) -> None:
"""Update an existing provider"""
- if 'uuid' in provider_data:
- del provider_data['uuid']
+ provider_data = provider_data.copy()
+ provider_data.pop('uuid', None)
+ provider_data.pop('workspace_uuid', None)
if 'api_keys' in provider_data:
- provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_model.ModelProvider)
- .where(persistence_model.ModelProvider.uuid == provider_uuid)
- .values(**provider_data)
+ submitted_keys = provider_data.get('api_keys')
+ if contains_secret_placeholder(submitted_keys, sensitive=True):
+ current_provider = await self.get_provider(context, provider_uuid, include_secret=True)
+ if current_provider is None:
+ raise WorkspaceNotFoundError('Provider not found')
+ submitted_keys = restore_secret_placeholders(
+ submitted_keys,
+ current_provider.get('api_keys', []),
+ sensitive=True,
+ )
+ provider_data['api_keys'] = self._normalize_api_keys(submitted_keys)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(persistence_model.ModelProvider)
+ .where(persistence_model.ModelProvider.uuid == provider_uuid)
+ .values(**provider_data),
+ persistence_model.ModelProvider,
+ context,
+ )
)
- await self.ap.model_mgr.reload_provider(provider_uuid)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Provider not found')
+ await self.ap.model_mgr.reload_provider(context, provider_uuid)
- async def delete_provider(self, provider_uuid: str) -> None:
+ async def delete_provider(self, context: TenantContext, provider_uuid: str) -> None:
"""Delete a provider (only if no models reference it)"""
+ workspace_uuid = require_workspace_uuid(context)
# Check if any models use this provider
llm_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.LLMModel).where(
- persistence_model.LLMModel.provider_uuid == provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.LLMModel).where(
+ persistence_model.LLMModel.provider_uuid == provider_uuid
+ ),
+ persistence_model.LLMModel,
+ workspace_uuid,
)
)
if llm_result.first() is not None:
raise ValueError('Cannot delete provider: LLM models still reference it')
embedding_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.EmbeddingModel).where(
- persistence_model.EmbeddingModel.provider_uuid == provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.EmbeddingModel).where(
+ persistence_model.EmbeddingModel.provider_uuid == provider_uuid
+ ),
+ persistence_model.EmbeddingModel,
+ workspace_uuid,
)
)
if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it')
rerank_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.RerankModel).where(
- persistence_model.RerankModel.provider_uuid == provider_uuid
+ scope_statement(
+ sqlalchemy.select(persistence_model.RerankModel).where(
+ persistence_model.RerankModel.provider_uuid == provider_uuid
+ ),
+ persistence_model.RerankModel,
+ workspace_uuid,
)
)
if rerank_result.first() is not None:
raise ValueError('Cannot delete provider: Rerank models still reference it')
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.uuid == provider_uuid
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.delete(persistence_model.ModelProvider).where(
+ persistence_model.ModelProvider.uuid == provider_uuid
+ ),
+ persistence_model.ModelProvider,
+ workspace_uuid,
)
)
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Provider not found')
- await self.ap.model_mgr.remove_provider(provider_uuid)
+ await self.ap.model_mgr.remove_provider(context, provider_uuid)
- async def get_provider_model_counts(self, provider_uuid: str) -> dict:
+ async def get_provider_model_counts(self, context: TenantContext, provider_uuid: str) -> dict:
"""Get count of models using this provider"""
+ workspace_uuid = require_workspace_uuid(context)
+ if await self.get_provider(context, provider_uuid) is None:
+ raise WorkspaceNotFoundError('Provider not found')
llm_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(sqlalchemy.func.count())
- .select_from(persistence_model.LLMModel)
- .where(persistence_model.LLMModel.provider_uuid == provider_uuid)
+ scope_statement(
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(persistence_model.LLMModel)
+ .where(persistence_model.LLMModel.provider_uuid == provider_uuid),
+ persistence_model.LLMModel,
+ workspace_uuid,
+ )
)
llm_count = llm_result.scalar() or 0
embedding_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(sqlalchemy.func.count())
- .select_from(persistence_model.EmbeddingModel)
- .where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid)
+ scope_statement(
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(persistence_model.EmbeddingModel)
+ .where(persistence_model.EmbeddingModel.provider_uuid == provider_uuid),
+ persistence_model.EmbeddingModel,
+ workspace_uuid,
+ )
)
embedding_count = embedding_result.scalar() or 0
rerank_result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(sqlalchemy.func.count())
- .select_from(persistence_model.RerankModel)
- .where(persistence_model.RerankModel.provider_uuid == provider_uuid)
+ scope_statement(
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(persistence_model.RerankModel)
+ .where(persistence_model.RerankModel.provider_uuid == provider_uuid),
+ persistence_model.RerankModel,
+ workspace_uuid,
+ )
)
rerank_count = rerank_result.scalar() or 0
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
- async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
+ async def find_or_create_provider(
+ self,
+ context: TenantContext,
+ requester: str,
+ base_url: str,
+ api_keys: list,
+ ) -> str:
"""Find existing provider or create new one"""
- api_keys = self._normalize_api_keys(api_keys)
+ workspace_uuid = require_workspace_uuid(context)
+ api_keys = self._normalize_api_keys(restore_secret_placeholders(api_keys, sensitive=True))
# Try to find existing provider with same config
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.requester == requester,
- persistence_model.ModelProvider.base_url == base_url,
+ scope_statement(
+ sqlalchemy.select(persistence_model.ModelProvider).where(
+ persistence_model.ModelProvider.requester == requester,
+ persistence_model.ModelProvider.base_url == base_url,
+ ),
+ persistence_model.ModelProvider,
+ workspace_uuid,
)
)
for provider in result.all():
@@ -187,29 +275,38 @@ class ModelProviderService:
pass
return await self.create_provider(
+ context,
{
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys,
- }
+ },
)
- async def update_space_model_provider_api_keys(self, api_key: str) -> None:
+ async def update_space_model_provider_api_keys(self, context: TenantContext, api_key: str) -> None:
"""Update Space model provider API keys"""
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_model.ModelProvider)
- .where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
- .values(api_keys=self._normalize_api_keys(api_key))
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(persistence_model.ModelProvider)
+ .where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
+ .values(api_keys=self._normalize_api_keys(api_key)),
+ persistence_model.ModelProvider,
+ context,
+ )
)
- await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')
+ if getattr(result, 'rowcount', None) == 0:
+ raise WorkspaceNotFoundError('Provider not found')
+ await self.ap.model_mgr.reload_provider(context, '00000000-0000-0000-0000-000000000000')
- async def scan_provider_models(self, provider_uuid: str, model_type: str | None = None) -> dict:
- provider = await self.get_provider(provider_uuid)
+ async def scan_provider_models(
+ self, context: TenantContext, provider_uuid: str, model_type: str | None = None
+ ) -> dict:
+ provider = await self.get_provider(context, provider_uuid, include_secret=True)
if provider is None:
- raise ValueError('provider not found')
+ raise WorkspaceNotFoundError('Provider not found')
- runtime_provider = await self.ap.model_mgr.load_provider(provider)
+ runtime_provider = await self.ap.model_mgr.load_provider(context, provider)
try:
scan_result = await runtime_provider.requester.scan_models(
@@ -230,11 +327,15 @@ class ModelProviderService:
scanned_models = scan_result
debug_info = None
- llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
- embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
+ llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(context, provider_uuid)
+ embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
+ context, provider_uuid
+ )
rerank_service = getattr(self.ap, 'rerank_models_service', None)
rerank_models = (
- await rerank_service.get_rerank_models_by_provider(provider_uuid) if rerank_service is not None else []
+ await rerank_service.get_rerank_models_by_provider(context, provider_uuid)
+ if rerank_service is not None
+ else []
)
existing_llm_names = {model['name'] for model in llm_models}
existing_embedding_names = {model['name'] for model in embedding_models}
diff --git a/src/langbot/pkg/api/http/service/secrets.py b/src/langbot/pkg/api/http/service/secrets.py
new file mode 100644
index 000000000..bd0b82ac7
--- /dev/null
+++ b/src/langbot/pkg/api/http/service/secrets.py
@@ -0,0 +1,336 @@
+from __future__ import annotations
+
+import copy
+import re
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+
+
+SECRET_MASK = '***'
+_MISSING_SECRET = object()
+
+_SENSITIVE_NAMES = frozenset(
+ {
+ 'api_key',
+ 'api_keys',
+ 'apikey',
+ 'apikeys',
+ 'auth',
+ 'authorization',
+ 'cookie',
+ 'credentials',
+ 'database_url',
+ 'dsn',
+ 'header_value',
+ 'key',
+ 'proxy_authorization',
+ 'set_cookie',
+ 'webhook_url',
+ }
+)
+_SENSITIVE_TOKENS = frozenset(
+ {
+ 'apikey',
+ 'credential',
+ 'credentials',
+ 'passwd',
+ 'password',
+ 'secret',
+ 'token',
+ }
+)
+_KEY_QUALIFIERS = frozenset(
+ {
+ 'access',
+ 'api',
+ 'auth',
+ 'bearer',
+ 'client',
+ 'debug',
+ 'encryption',
+ 'private',
+ 'signing',
+ }
+)
+_SENSITIVE_URL_QUERY_NAMES = frozenset(
+ {
+ 'code',
+ 'credential',
+ 'credentials',
+ 'password',
+ 'passwd',
+ 'sig',
+ 'signature',
+ }
+)
+
+
+def _normalize_key(key: object) -> str:
+ value = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', str(key or ''))
+ return re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
+
+
+def is_sensitive_key(key: object) -> bool:
+ """Return whether a configuration key conventionally carries a secret."""
+
+ normalized = _normalize_key(key)
+ if normalized in _SENSITIVE_NAMES:
+ return True
+ tokens = frozenset(token for token in normalized.split('_') if token)
+ if tokens & _SENSITIVE_TOKENS:
+ return True
+ return bool(tokens & {'key', 'keys'}) and bool(tokens & _KEY_QUALIFIERS)
+
+
+def is_url_key(key: object) -> bool:
+ """Return whether a configuration field conventionally carries a URL."""
+
+ normalized = _normalize_key(key)
+ return normalized == 'url' or normalized.endswith('_url')
+
+
+def _is_sensitive_url_query_key(key: object) -> bool:
+ normalized = _normalize_key(key)
+ return (
+ is_sensitive_key(key) or normalized in _SENSITIVE_URL_QUERY_NAMES or normalized.endswith(('_sig', '_signature'))
+ )
+
+
+def _redact_url_string(value: str) -> str:
+ if not value:
+ return value
+ try:
+ parsed = urlsplit(value)
+ netloc = parsed.netloc
+ if '@' in netloc:
+ _, host = netloc.rsplit('@', 1)
+ netloc = f'{SECRET_MASK}@{host}'
+ query = urlencode(
+ [
+ (key, SECRET_MASK if _is_sensitive_url_query_key(key) and item else item)
+ for key, item in parse_qsl(parsed.query, keep_blank_values=True)
+ ],
+ doseq=True,
+ safe='*',
+ )
+ return urlunsplit((parsed.scheme, netloc, parsed.path, query, parsed.fragment))
+ except (TypeError, ValueError):
+ # A malformed URL cannot be safely decomposed, so fail closed.
+ return SECRET_MASK
+
+
+def redact_url_secrets(value):
+ """Redact URL userinfo and credential-like query values."""
+
+ if isinstance(value, str):
+ return _redact_url_string(value)
+ if isinstance(value, list):
+ return [redact_url_secrets(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_url_secrets(item) for item in value)
+ return copy.deepcopy(value)
+
+
+def _contains_url_secret_placeholder(value) -> bool:
+ if isinstance(value, str):
+ if value == SECRET_MASK:
+ return True
+ try:
+ parsed = urlsplit(value)
+ if '@' in parsed.netloc and SECRET_MASK in parsed.netloc.rsplit('@', 1)[0]:
+ return True
+ return any(
+ item == SECRET_MASK and _is_sensitive_url_query_key(key)
+ for key, item in parse_qsl(parsed.query, keep_blank_values=True)
+ )
+ except (TypeError, ValueError):
+ return False
+ if isinstance(value, (list, tuple)):
+ return any(_contains_url_secret_placeholder(item) for item in value)
+ return False
+
+
+def _restore_url_string(value: str, current_value) -> str:
+ if value == SECRET_MASK:
+ if current_value is _MISSING_SECRET:
+ raise ValueError('Masked URL secret has no existing value')
+ return copy.deepcopy(current_value)
+
+ try:
+ submitted = urlsplit(value)
+ except (TypeError, ValueError):
+ return value
+
+ current = None
+ if isinstance(current_value, str):
+ try:
+ current = urlsplit(current_value)
+ except (TypeError, ValueError):
+ current = None
+
+ netloc = submitted.netloc
+ if '@' in netloc:
+ submitted_userinfo, host = netloc.rsplit('@', 1)
+ if SECRET_MASK in submitted_userinfo:
+ if current is None or '@' not in current.netloc:
+ raise ValueError('Masked URL userinfo has no existing value')
+ current_userinfo, _ = current.netloc.rsplit('@', 1)
+ netloc = f'{current_userinfo}@{host}'
+
+ current_query: dict[str, list[str]] = {}
+ if current is not None:
+ for key, item in parse_qsl(current.query, keep_blank_values=True):
+ current_query.setdefault(_normalize_key(key), []).append(item)
+ consumed: dict[str, int] = {}
+ restored_query: list[tuple[str, str]] = []
+ for key, item in parse_qsl(submitted.query, keep_blank_values=True):
+ normalized = _normalize_key(key)
+ if item == SECRET_MASK and _is_sensitive_url_query_key(key):
+ index = consumed.get(normalized, 0)
+ candidates = current_query.get(normalized, [])
+ if index >= len(candidates):
+ raise ValueError('Masked URL query secret has no existing value')
+ item = candidates[index]
+ consumed[normalized] = index + 1
+ restored_query.append((key, item))
+
+ return urlunsplit(
+ (
+ submitted.scheme,
+ netloc,
+ submitted.path,
+ urlencode(restored_query, doseq=True, safe='*'),
+ submitted.fragment,
+ )
+ )
+
+
+def restore_url_secret_placeholders(value, current_value=_MISSING_SECRET):
+ """Restore URL placeholders from the corresponding persisted URL."""
+
+ if isinstance(value, str):
+ return _restore_url_string(value, current_value)
+ if isinstance(value, list):
+ current_items = current_value if isinstance(current_value, (list, tuple)) else ()
+ return [
+ restore_url_secret_placeholders(
+ item,
+ current_items[index] if index < len(current_items) else _MISSING_SECRET,
+ )
+ for index, item in enumerate(value)
+ ]
+ if isinstance(value, tuple):
+ current_items = current_value if isinstance(current_value, (list, tuple)) else ()
+ return tuple(
+ restore_url_secret_placeholders(
+ item,
+ current_items[index] if index < len(current_items) else _MISSING_SECRET,
+ )
+ for index, item in enumerate(value)
+ )
+ return copy.deepcopy(value)
+
+
+def mask_secret_value(value):
+ """Return a shape-preserving copy whose non-empty leaves are masked."""
+
+ if isinstance(value, dict):
+ return {key: mask_secret_value(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [mask_secret_value(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(mask_secret_value(item) for item in value)
+ if value is None or value == '':
+ return value
+ return SECRET_MASK
+
+
+def redact_secrets(value):
+ """Return a recursively redacted copy without mutating the source value."""
+
+ if isinstance(value, dict):
+ return {
+ key: (
+ mask_secret_value(item)
+ if is_sensitive_key(key)
+ else redact_url_secrets(item)
+ if is_url_key(key)
+ else redact_secrets(item)
+ )
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [redact_secrets(item) for item in value]
+ if isinstance(value, tuple):
+ return tuple(redact_secrets(item) for item in value)
+ return copy.deepcopy(value)
+
+
+def restore_secret_placeholders(value, current_value=_MISSING_SECRET, *, sensitive: bool = False):
+ """Restore masked leaves from existing data before a management write.
+
+ ``***`` is a reserved placeholder only inside a sensitive field. A masked
+ leaf without an existing counterpart is rejected so it can never become a
+ persisted credential. Empty values and explicit replacements pass through.
+ """
+
+ if sensitive and value == SECRET_MASK:
+ if current_value is _MISSING_SECRET:
+ raise ValueError('Masked secret has no existing value')
+ return copy.deepcopy(current_value)
+ if isinstance(value, dict):
+ current_mapping = current_value if isinstance(current_value, dict) else {}
+ return {
+ key: (
+ restore_url_secret_placeholders(
+ item,
+ current_mapping.get(key, _MISSING_SECRET),
+ )
+ if not sensitive and not is_sensitive_key(key) and is_url_key(key)
+ else restore_secret_placeholders(
+ item,
+ current_mapping.get(key, _MISSING_SECRET),
+ sensitive=sensitive or is_sensitive_key(key),
+ )
+ )
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ current_items = current_value if isinstance(current_value, (list, tuple)) else ()
+ return [
+ restore_secret_placeholders(
+ item,
+ current_items[index] if index < len(current_items) else _MISSING_SECRET,
+ sensitive=sensitive,
+ )
+ for index, item in enumerate(value)
+ ]
+ if isinstance(value, tuple):
+ current_items = current_value if isinstance(current_value, (list, tuple)) else ()
+ return tuple(
+ restore_secret_placeholders(
+ item,
+ current_items[index] if index < len(current_items) else _MISSING_SECRET,
+ sensitive=sensitive,
+ )
+ for index, item in enumerate(value)
+ )
+ return copy.deepcopy(value)
+
+
+def contains_secret_placeholder(value, *, sensitive: bool = False) -> bool:
+ """Return whether ``value`` contains a meaningful masked secret leaf."""
+
+ if sensitive and value == SECRET_MASK:
+ return True
+ if isinstance(value, dict):
+ return any(
+ (
+ _contains_url_secret_placeholder(item)
+ if not sensitive and not is_sensitive_key(key) and is_url_key(key)
+ else contains_secret_placeholder(item, sensitive=sensitive or is_sensitive_key(key))
+ )
+ for key, item in value.items()
+ )
+ if isinstance(value, (list, tuple)):
+ return any(contains_secret_placeholder(item, sensitive=sensitive) for item in value)
+ return False
diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py
index 94b926975..9073ccd4b 100644
--- a/src/langbot/pkg/api/http/service/skill.py
+++ b/src/langbot/pkg/api/http/service/skill.py
@@ -1,9 +1,11 @@
from __future__ import annotations
+import asyncio
import io
import inspect
import os
import posixpath
+import stat
import zipfile
from typing import Optional
from urllib.parse import quote, unquote, urlparse
@@ -12,6 +14,9 @@ import httpx
from ....core import app
from ....skill.utils import parse_frontmatter
+from ....utils import httpclient
+from ..context import ExecutionContext
+from .tenant import TenantContext, require_workspace_uuid
_PUBLIC_SKILL_FIELDS = (
@@ -32,6 +37,12 @@ _GITHUB_ASSET_HOSTS = {
'raw.githubusercontent.com',
'codeload.github.com',
}
+_MAX_GITHUB_ARCHIVE_BYTES = 10 * 1024 * 1024
+_MAX_GITHUB_ARCHIVE_ENTRIES = 4096
+_MAX_SKILL_ARCHIVE_FILES = 1024
+_MAX_SKILL_FILE_BYTES = 10 * 1024 * 1024
+_MAX_SKILL_UNCOMPRESSED_BYTES = 50 * 1024 * 1024
+_MAX_SKILL_COMPRESSION_RATIO = 200
class SkillService:
@@ -75,75 +86,112 @@ class SkillService:
"""Backwards-compatible alias preserved for clarity at call sites."""
self._require_box(action)
+ async def _execution_context(self, context: TenantContext) -> ExecutionContext:
+ workspace_uuid = require_workspace_uuid(context)
+ instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
+ generation = getattr(context, 'placement_generation', None)
+ if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
+ raise ValueError('Skill operations require an explicit fenced execution context')
+ binding = await self.ap.workspace_service.get_execution_binding(
+ workspace_uuid,
+ expected_generation=generation,
+ )
+ if binding.instance_uuid != instance_uuid:
+ raise ValueError('Skill execution context belongs to another LangBot instance')
+ return ExecutionContext(
+ instance_uuid=instance_uuid,
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ bot_uuid=getattr(context, 'bot_uuid', None),
+ pipeline_uuid=getattr(context, 'pipeline_uuid', None),
+ query_uuid=getattr(context, 'query_uuid', None),
+ )
+
@staticmethod
def _serialize_skill(skill: dict) -> dict:
return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill}
- async def list_skills(self) -> list[dict]:
+ async def list_skills(self, context: TenantContext) -> list[dict]:
+ execution_context = await self._execution_context(context)
# When Box is unavailable, surface an empty list rather than raising —
# the skills page should render cleanly, and the UI separately renders
# a "Box disabled / unavailable" banner via useBoxStatus.
box_service = self._box_service()
if box_service is None:
return []
- return [self._serialize_skill(skill) for skill in await box_service.list_skills()]
+ return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
- async def get_skill(self, skill_name: str) -> Optional[dict]:
+ async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
+ execution_context = await self._execution_context(context)
box_service = self._box_service()
if box_service is None:
return None
- skill = await box_service.get_skill(skill_name)
+ skill = await box_service.get_skill(execution_context, skill_name)
return self._serialize_skill(skill) if skill else None
- async def get_skill_by_name(self, name: str) -> Optional[dict]:
- return await self.get_skill(name)
+ async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
+ return await self.get_skill(context, name)
- async def create_skill(self, data: dict) -> dict:
+ async def create_skill(self, context: TenantContext, data: dict) -> dict:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Creating a skill')
- created = await box_service.create_skill(data)
- await self._reload_skills()
+ created = await box_service.create_skill(execution_context, data)
+ await self._reload_skills(execution_context)
return self._serialize_skill(created)
- async def update_skill(self, skill_name: str, data: dict) -> dict:
+ async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Editing a skill')
- updated = await box_service.update_skill(skill_name, data)
- await self._reload_skills()
+ updated = await box_service.update_skill(execution_context, skill_name, data)
+ await self._reload_skills(execution_context)
return self._serialize_skill(updated)
- async def delete_skill(self, skill_name: str) -> bool:
+ async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Deleting a skill')
- await box_service.delete_skill(skill_name)
- await self._reload_skills()
+ await box_service.delete_skill(execution_context, skill_name)
+ await self._reload_skills(execution_context)
return True
async def list_skill_files(
self,
+ context: TenantContext,
skill_name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Browsing skill files')
- return await box_service.list_skill_files(skill_name, path, include_hidden, max_entries)
+ return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries)
- async def read_skill_file(self, skill_name: str, path: str) -> dict:
+ async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Reading a skill file')
- return await box_service.read_skill_file(skill_name, path)
+ return await box_service.read_skill_file(execution_context, skill_name, path)
- async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict:
+ async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Editing skill files')
- result = await box_service.write_skill_file(skill_name, path, content)
- await self._reload_skills()
+ result = await box_service.write_skill_file(execution_context, skill_name, path, content)
+ await self._reload_skills(execution_context)
return result
- async def install_from_github(self, data: dict) -> list[dict]:
+ async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Installing a skill from GitHub')
owner = str(data['owner']).strip()
repo = str(data['repo']).strip()
release_tag = str(data.get('release_tag', '')).strip()
raw_asset_url = str(data['asset_url']).strip()
if self._is_github_skill_md_url(raw_asset_url):
- return await self._install_github_skill_md(raw_asset_url, owner=owner, repo=repo, data=data)
+ return await self._install_github_skill_md(
+ execution_context,
+ raw_asset_url,
+ owner=owner,
+ repo=repo,
+ data=data,
+ )
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
source_subdir = str(data.get('source_subdir', '') or '').strip()
@@ -151,29 +199,37 @@ class SkillService:
zip_bytes = await self._download_github_asset(asset_url)
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
installed = await box_service.install_skill_zip(
+ execution_context,
zip_bytes,
filename,
source_paths=data.get('source_paths') or [],
source_path=str(data.get('source_path', '') or ''),
source_subdir=source_subdir,
)
- await self._reload_skills()
+ await self._reload_skills(execution_context)
return [self._serialize_skill(skill) for skill in installed]
- async def preview_install_from_github(self, data: dict) -> list[dict]:
+ async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Previewing a skill from GitHub')
owner = str(data['owner']).strip()
repo = str(data['repo']).strip()
release_tag = str(data.get('release_tag', '')).strip()
raw_asset_url = str(data['asset_url']).strip()
if self._is_github_skill_md_url(raw_asset_url):
- return await self._preview_github_skill_md(raw_asset_url, owner=owner, repo=repo)
+ return await self._preview_github_skill_md(
+ execution_context,
+ raw_asset_url,
+ owner=owner,
+ repo=repo,
+ )
asset_url = self._validate_github_asset_url(raw_asset_url, owner=owner, repo=repo, release_tag=release_tag)
source_subdir = str(data.get('source_subdir', '') or '').strip()
zip_bytes = await self._download_github_asset(asset_url)
return await box_service.preview_skill_zip(
+ execution_context,
zip_bytes,
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
source_subdir=source_subdir,
@@ -181,27 +237,45 @@ class SkillService:
async def install_from_zip_upload(
self,
+ context: TenantContext,
*,
file_bytes: bytes,
filename: str,
source_paths: list[str] | None = None,
source_path: str = '',
) -> list[dict]:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Installing a skill from upload')
installed = await box_service.install_skill_zip(
+ execution_context,
file_bytes,
filename,
source_paths=source_paths or [],
source_path=source_path,
)
- await self._reload_skills()
+ await self._reload_skills(execution_context)
return [self._serialize_skill(skill) for skill in installed]
- async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]:
+ async def preview_install_from_zip_upload(
+ self,
+ context: TenantContext,
+ *,
+ file_bytes: bytes,
+ filename: str,
+ ) -> list[dict]:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Previewing a skill upload')
- return await box_service.preview_skill_zip(file_bytes, filename)
+ return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
- async def _install_github_skill_md(self, asset_url: str, *, owner: str, repo: str, data: dict) -> list[dict]:
+ async def _install_github_skill_md(
+ self,
+ context: TenantContext,
+ asset_url: str,
+ *,
+ owner: str,
+ repo: str,
+ data: dict,
+ ) -> list[dict]:
box_service = self._require_box('Installing a skill from GitHub')
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
asset_url,
@@ -210,46 +284,73 @@ class SkillService:
)
installed = await box_service.install_skill_zip(
+ context,
zip_bytes,
filename,
source_paths=data.get('source_paths') or [],
source_path=str(data.get('source_path', '') or ''),
target_suffix='',
)
- await self._reload_skills()
+ await self._reload_skills(context)
return [self._serialize_skill(skill) for skill in installed]
- async def _preview_github_skill_md(self, asset_url: str, *, owner: str, repo: str) -> list[dict]:
+ async def _preview_github_skill_md(
+ self,
+ context: TenantContext,
+ asset_url: str,
+ *,
+ owner: str,
+ repo: str,
+ ) -> list[dict]:
box_service = self._require_box('Previewing a skill from GitHub')
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
asset_url,
owner=owner,
repo=repo,
)
- return await box_service.preview_skill_zip(zip_bytes, f'{package_name}.zip', target_suffix='')
+ return await box_service.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
- async def reload_skills(self) -> list[dict]:
- await self._reload_skills()
- return await self.list_skills()
+ async def reload_skills(self, context: TenantContext) -> list[dict]:
+ execution_context = await self._execution_context(context)
+ await self._reload_skills(execution_context)
+ return await self.list_skills(execution_context)
- async def scan_directory_async(self, path: str) -> dict:
+ async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
+ execution_context = await self._execution_context(context)
box_service = self._require_box('Scanning a skill directory')
- return await box_service.scan_skill_directory(path)
+ return await box_service.scan_skill_directory(execution_context, path)
- async def _reload_skills(self) -> None:
+ async def _reload_skills(self, context: TenantContext) -> None:
skill_mgr = getattr(self.ap, 'skill_mgr', None)
reload_skills = getattr(skill_mgr, 'reload_skills', None)
if not callable(reload_skills):
return
- result = reload_skills()
+ result = reload_skills(context)
if inspect.isawaitable(result):
await result
async def _download_github_asset(self, asset_url: str) -> bytes:
- async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client:
- resp = await client.get(asset_url)
- resp.raise_for_status()
- return resp.content
+ async with httpx.AsyncClient(
+ follow_redirects=True,
+ timeout=120,
+ event_hooks=httpclient.httpx_response_limit_hooks(_MAX_GITHUB_ARCHIVE_BYTES),
+ ) as client:
+ async with client.stream('GET', asset_url) as resp:
+ resp.raise_for_status()
+ content_length = resp.headers.get('content-length')
+ if content_length is not None:
+ try:
+ if int(content_length) > _MAX_GITHUB_ARCHIVE_BYTES:
+ raise ValueError('GitHub skill archive exceeds the compressed size limit')
+ except ValueError as exc:
+ if 'exceeds' in str(exc):
+ raise
+ content = bytearray()
+ async for chunk in resp.aiter_bytes():
+ content.extend(chunk)
+ if len(content) > _MAX_GITHUB_ARCHIVE_BYTES:
+ raise ValueError('GitHub skill archive exceeds the compressed size limit')
+ return bytes(content)
async def _download_github_skill_directory_as_zip(
self, asset_url: str, *, owner: str, repo: str
@@ -257,14 +358,25 @@ class SkillService:
info = self._parse_github_skill_md_url(asset_url, owner=owner, repo=repo)
archive_url = f'https://codeload.github.com/{owner}/{repo}/zip/{quote(info["ref"], safe="/")}'
archive_bytes = await self._download_github_asset(archive_url)
+ return await asyncio.to_thread(self._build_github_skill_directory_zip, archive_bytes, info)
+ def _build_github_skill_directory_zip(
+ self,
+ archive_bytes: bytes,
+ info: dict[str, str],
+ ) -> tuple[bytes, str, str]:
+ """Validate and repack a GitHub skill archive outside the event loop."""
try:
source_archive = zipfile.ZipFile(io.BytesIO(archive_bytes), 'r')
except zipfile.BadZipFile as exc:
raise ValueError('GitHub repository archive must be a valid .zip archive') from exc
with source_archive as source_zip:
+ if len(source_zip.infolist()) > _MAX_GITHUB_ARCHIVE_ENTRIES:
+ raise ValueError('GitHub repository archive contains too many entries')
skill_entry = self._find_github_skill_archive_entry(source_zip, info['file_path'])
+ if skill_entry.file_size > _MAX_SKILL_FILE_BYTES:
+ raise ValueError('GitHub SKILL.md exceeds the file size limit')
try:
skill_md_content = source_zip.read(skill_entry).decode('utf-8')
except UnicodeDecodeError as exc:
@@ -302,6 +414,7 @@ class SkillService:
normalized_source_dir = posixpath.normpath(source_skill_dir)
source_prefix = f'{normalized_source_dir}/'
copied_files = 0
+ copied_bytes = 0
for member in source_zip.infolist():
normalized_member = posixpath.normpath(member.filename)
@@ -324,10 +437,33 @@ class SkillService:
if member.is_dir():
target_zip.writestr(target_info, b'')
continue
-
- target_zip.writestr(target_info, source_zip.read(member))
+ if member.flag_bits & 0x1:
+ raise ValueError('Encrypted GitHub skill archive entries are not supported')
+ unix_mode = member.external_attr >> 16
+ if stat.S_IFMT(unix_mode) == stat.S_IFLNK:
+ raise ValueError(f'GitHub archive contains a symbolic link: {member.filename}')
+ if member.file_size > _MAX_SKILL_FILE_BYTES:
+ raise ValueError(f'GitHub skill file exceeds the size limit: {member.filename}')
+ if member.file_size and member.file_size > max(member.compress_size, 1) * _MAX_SKILL_COMPRESSION_RATIO:
+ raise ValueError(f'GitHub skill file exceeds the compression-ratio limit: {member.filename}')
copied_files += 1
+ copied_bytes += member.file_size
+ if copied_files > _MAX_SKILL_ARCHIVE_FILES:
+ raise ValueError('GitHub skill directory contains too many files')
+ if copied_bytes > _MAX_SKILL_UNCOMPRESSED_BYTES:
+ raise ValueError('GitHub skill directory exceeds the uncompressed size limit')
+ # Copy in bounded chunks instead of materialising a potentially
+ # large member in Core memory. The Box Runtime independently
+ # revalidates the resulting archive before installation.
+ with source_zip.open(member, 'r') as source_file, target_zip.open(target_info, 'w') as target_file:
+ remaining = member.file_size
+ while remaining:
+ chunk = source_file.read(min(64 * 1024, remaining))
+ if not chunk:
+ raise ValueError(f'GitHub skill file is truncated: {member.filename}')
+ target_file.write(chunk)
+ remaining -= len(chunk)
if copied_files == 0:
raise ValueError('GitHub skill directory is empty')
diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py
index 6de259321..855b009c1 100644
--- a/src/langbot/pkg/api/http/service/space.py
+++ b/src/langbot/pkg/api/http/service/space.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from collections import OrderedDict
+
from langbot.pkg.utils import httpclient
import typing
import datetime
@@ -11,6 +13,10 @@ from ....entity.persistence import user
from ....entity.dto.space_model import SpaceModel
+_CREDITS_CACHE_TTL_SECONDS = 60
+_CREDITS_CACHE_MAX_ENTRIES = 4096
+
+
class SpaceService:
"""Service for interacting with LangBot Space API"""
@@ -19,7 +25,24 @@ class SpaceService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- self._credits_cache = {}
+ self._credits_cache = OrderedDict()
+
+ def _ordered_credits_cache(
+ self,
+ ) -> OrderedDict[str, tuple[int, float]]:
+ if not isinstance(self._credits_cache, OrderedDict):
+ # Preserve compatibility with tests and callers that seed the cache.
+ self._credits_cache = OrderedDict(self._credits_cache)
+ return self._credits_cache
+
+ def _prune_credits_cache(self, now: float) -> None:
+ cache = self._ordered_credits_cache()
+ while cache:
+ email = next(iter(cache))
+ _, cached_at = cache[email]
+ if now - cached_at < _CREDITS_CACHE_TTL_SECONDS:
+ break
+ cache.pop(email, None)
def _get_space_config(self) -> typing.Dict[str, str]:
"""Get Space configuration from config file"""
@@ -85,12 +108,14 @@ class SpaceService:
def get_oauth_authorize_url(self, redirect_uri: str, state: str = '') -> str:
"""Get the Space OAuth authorization URL for redirect"""
+ from urllib.parse import urlencode
+
space_config = self._get_space_config()
authorize_url = space_config['oauth_authorize_url']
- params = f'redirect_uri={redirect_uri}'
+ params = {'redirect_uri': redirect_uri}
if state:
- params += f'&state={state}'
- return f'{authorize_url}?{params}'
+ params['state'] = state
+ return f'{authorize_url}?{urlencode(params)}'
async def exchange_oauth_code(self, code: str) -> typing.Dict:
"""Exchange OAuth authorization code for tokens"""
@@ -105,8 +130,9 @@ class SpaceService:
json={'code': code, 'instance_id': constants.instance_id},
) as response:
if response.status != 200:
- raise ValueError(f'Failed to exchange OAuth code: {await response.text()}')
- data = await response.json()
+ error = await httpclient.read_text_limited(response)
+ raise ValueError(f'Failed to exchange OAuth code: {error}')
+ data = await httpclient.read_json_limited(response)
if data.get('code') != 0:
raise ValueError(f'Failed to exchange OAuth code: {data.get("msg")}')
return data.get('data', {})
@@ -121,8 +147,9 @@ class SpaceService:
f'{space_url}/api/v1/accounts/token/refresh', json={'refresh_token': refresh_token}
) as response:
if response.status != 200:
- raise ValueError(f'Failed to refresh token: {await response.text()}')
- data = await response.json()
+ error = await httpclient.read_text_limited(response)
+ raise ValueError(f'Failed to refresh token: {error}')
+ data = await httpclient.read_json_limited(response)
if data.get('code') != 0:
raise ValueError(f'Failed to refresh token: {data.get("msg")}')
return data.get('data', {})
@@ -137,8 +164,9 @@ class SpaceService:
f'{space_url}/api/v1/accounts/me', headers={'Authorization': f'Bearer {access_token}'}
) as response:
if response.status != 200:
- raise ValueError(f'Failed to get user info: {await response.text()}')
- data = await response.json()
+ error = await httpclient.read_text_limited(response)
+ raise ValueError(f'Failed to get user info: {error}')
+ data = await httpclient.read_json_limited(response)
if data.get('code') != 0:
raise ValueError(f'Failed to get user info: {data.get("msg")}')
return data.get('data', {})
@@ -154,11 +182,13 @@ class SpaceService:
async def get_credits(self, user_email: str, force_refresh: bool = False) -> int | None:
"""Get Space credits for user with caching (60s TTL)"""
- cache_ttl = 60
+ now = time.time()
+ cached_fallback = self._credits_cache.get(user_email)
+ self._prune_credits_cache(now)
if not force_refresh and user_email in self._credits_cache:
credits, ts = self._credits_cache[user_email]
- if time.time() - ts < cache_ttl:
+ if now - ts < _CREDITS_CACHE_TTL_SECONDS:
return credits
try:
@@ -167,10 +197,14 @@ class SpaceService:
return None
credits = info.get('credits')
if credits is not None:
- self._credits_cache[user_email] = (credits, time.time())
+ cache = self._ordered_credits_cache()
+ cache.pop(user_email, None)
+ if len(cache) >= _CREDITS_CACHE_MAX_ENTRIES:
+ cache.popitem(last=False)
+ cache[user_email] = (credits, time.time())
return credits
except Exception:
- return self._credits_cache.get(user_email, (None, 0))[0]
+ return cached_fallback[0] if cached_fallback is not None else None
async def get_models(self) -> typing.List[SpaceModel]:
"""Get models from Space"""
@@ -181,8 +215,9 @@ class SpaceService:
session = httpclient.get_session()
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response:
if response.status != 200:
- raise ValueError(f'Failed to get models: {await response.text()}')
- data = await response.json()
+ error = await httpclient.read_text_limited(response)
+ raise ValueError(f'Failed to get models: {error}')
+ data = await httpclient.read_json_limited(response)
if data.get('code') != 0:
raise ValueError(f'Failed to get models: {data.get("msg")}')
models_data = data.get('data', {}).get('models', [])
diff --git a/src/langbot/pkg/api/http/service/tenant.py b/src/langbot/pkg/api/http/service/tenant.py
new file mode 100644
index 000000000..7b0fc3805
--- /dev/null
+++ b/src/langbot/pkg/api/http/service/tenant.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+import typing
+
+from ..authz import WorkspaceRequiredError
+from ..context import ExecutionContext, RequestContext, WorkspaceContext
+
+TenantContext: typing.TypeAlias = RequestContext | ExecutionContext | WorkspaceContext | str
+
+
+def require_workspace_uuid(context: TenantContext | None) -> str:
+ """Resolve an explicit Workspace UUID without allowing a global fallback."""
+
+ if isinstance(context, str):
+ workspace_uuid = context
+ elif isinstance(context, RequestContext):
+ workspace_uuid = context.workspace_uuid
+ elif isinstance(context, ExecutionContext):
+ workspace_uuid = context.workspace_uuid
+ elif isinstance(context, WorkspaceContext):
+ workspace_uuid = context.workspace_uuid
+ else:
+ raise WorkspaceRequiredError('Workspace context is required')
+
+ normalized = workspace_uuid.strip()
+ if not normalized:
+ raise WorkspaceRequiredError('Workspace context is required')
+ return normalized
+
+
+def scope_statement(statement: typing.Any, model: typing.Any, context: TenantContext) -> typing.Any:
+ """Add the mandatory Workspace predicate to a SQLAlchemy statement."""
+
+ return statement.where(model.workspace_uuid == require_workspace_uuid(context))
diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py
index a9185f9bc..93a3441ad 100644
--- a/src/langbot/pkg/api/http/service/user.py
+++ b/src/langbot/pkg/api/http/service/user.py
@@ -6,71 +6,391 @@ import jwt
import datetime
import typing
import asyncio
+import dataclasses
+import heapq
+import hashlib
+import secrets
+import time
+import uuid
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
-from ....core import app
from ....entity.persistence import user
+from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
from ....utils import constants
from ....entity.errors import account as account_errors
+from ....workspace.collaboration import normalize_email
+from ....utils import bounded_executor
+
+if typing.TYPE_CHECKING:
+ from ....core.app import Application
+
+
+_SPACE_OAUTH_STATE_MAX_ENTRIES = 4096
+_SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR = 64
+_SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER = 4
+
+
+class AccountExistsLoginRequiredError(ValueError):
+ code = 'account_exists_login_required'
+
+
+class PublicRegistrationClosedError(ValueError):
+ code = 'registration_closed'
+
+
+class ControlPlaneDirectoryRequiredError(PublicRegistrationClosedError):
+ code = 'control_plane_required'
+
+
+class AccountDisabledError(ValueError):
+ code = 'account_disabled'
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class SpaceOAuthStateConsumption:
+ purpose: typing.Literal['login', 'bind']
+ account: user.User | None
+ launch_workspace_uuid: str | None = None
class UserService:
- ap: app.Application
+ ap: Application
_create_user_lock: asyncio.Lock
- def __init__(self, ap: app.Application) -> None:
+ def __init__(self, ap: Application) -> None:
self.ap = ap
self._create_user_lock = asyncio.Lock()
- self._password_hash_lock = asyncio.Semaphore(1)
+ self._password_hash_lock = asyncio.Lock()
+ self._space_oauth_state_lock = asyncio.Lock()
+ self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {}
+ self._space_oauth_state_expiry_heap: list[tuple[float, str]] = []
+
+ @staticmethod
+ def _space_oauth_state_digest(state: str) -> str:
+ return hashlib.sha256(state.encode('utf-8')).hexdigest()
+
+ def _prune_space_oauth_states(self, now: float) -> None:
+ while self._space_oauth_state_expiry_heap:
+ expires_at, digest = self._space_oauth_state_expiry_heap[0]
+ entry = self._space_oauth_states.get(digest)
+ if entry is None or entry[2] != expires_at:
+ heapq.heappop(self._space_oauth_state_expiry_heap)
+ continue
+ if expires_at > now:
+ break
+ heapq.heappop(self._space_oauth_state_expiry_heap)
+ self._space_oauth_states.pop(digest, None)
+
+ max_heap_entries = max(
+ _SPACE_OAUTH_STATE_HEAP_COMPACT_FLOOR,
+ len(self._space_oauth_states) * _SPACE_OAUTH_STATE_HEAP_MAX_MULTIPLIER,
+ )
+ if len(self._space_oauth_state_expiry_heap) > max_heap_entries:
+ self._space_oauth_state_expiry_heap[:] = [
+ (entry[2], digest) for digest, entry in self._space_oauth_states.items()
+ ]
+ heapq.heapify(self._space_oauth_state_expiry_heap)
+
+ def _evict_earliest_space_oauth_state(self) -> None:
+ while self._space_oauth_state_expiry_heap:
+ expires_at, digest = heapq.heappop(self._space_oauth_state_expiry_heap)
+ entry = self._space_oauth_states.get(digest)
+ if entry is not None and entry[2] == expires_at:
+ self._space_oauth_states.pop(digest, None)
+ return
+
+ async def issue_space_oauth_state(
+ self,
+ purpose: typing.Literal['login', 'bind'],
+ *,
+ account_uuid: str | None = None,
+ launch_workspace_uuid: str | None = None,
+ ttl_seconds: int = 600,
+ ) -> str:
+ """Issue an opaque, single-use OAuth state without exposing a JWT."""
+ if purpose == 'bind' and not account_uuid:
+ raise ValueError('An Account is required for Space binding')
+ if purpose == 'login' and account_uuid is not None:
+ raise ValueError('Login state cannot be bound to an Account')
+ if purpose != 'login' and launch_workspace_uuid is not None:
+ raise ValueError('Launch Workspace state is only valid for Space login')
+ if ttl_seconds <= 0:
+ raise ValueError('OAuth state lifetime must be positive')
+
+ raw_state = secrets.token_urlsafe(32)
+ digest = self._space_oauth_state_digest(raw_state)
+ expires_at = time.monotonic() + min(ttl_seconds, 600)
+ async with self._space_oauth_state_lock:
+ now = time.monotonic()
+ self._prune_space_oauth_states(now)
+ if len(self._space_oauth_states) >= _SPACE_OAUTH_STATE_MAX_ENTRIES:
+ self._evict_earliest_space_oauth_state()
+ self._space_oauth_states[digest] = (purpose, account_uuid, expires_at, launch_workspace_uuid)
+ heapq.heappush(
+ self._space_oauth_state_expiry_heap,
+ (expires_at, digest),
+ )
+ return raw_state
+
+ async def consume_space_oauth_state_details(
+ self,
+ raw_state: str,
+ purpose: typing.Literal['login', 'bind'],
+ ) -> SpaceOAuthStateConsumption:
+ """Atomically consume OAuth state and return any bound launch intent."""
+ if not isinstance(raw_state, str) or not raw_state:
+ raise ValueError('Invalid or expired OAuth state')
+ digest = self._space_oauth_state_digest(raw_state)
+ async with self._space_oauth_state_lock:
+ entry = self._space_oauth_states.pop(digest, None)
+ if entry is None or entry[0] != purpose or entry[2] <= time.monotonic():
+ raise ValueError('Invalid or expired OAuth state')
+ if purpose == 'login':
+ return SpaceOAuthStateConsumption(
+ purpose='login',
+ account=None,
+ launch_workspace_uuid=entry[3],
+ )
+
+ account_uuid = entry[1]
+ account = await self.get_user_by_uuid(account_uuid or '')
+ if account is None:
+ raise ValueError('Invalid or expired OAuth state')
+ self._require_active_account(account)
+ return SpaceOAuthStateConsumption(purpose='bind', account=account)
+
+ async def consume_space_oauth_state(
+ self,
+ raw_state: str,
+ purpose: typing.Literal['login', 'bind'],
+ ) -> user.User | None:
+ """Atomically consume OAuth state and resolve its active bind Account."""
+ consumed = await self.consume_space_oauth_state_details(raw_state, purpose)
+ return consumed.account
async def _hash_password(self, password: str) -> str:
+ if self._password_hash_lock.locked():
+ raise bounded_executor.BlockingWorkCapacityError(
+ 'Password hashing capacity reached',
+ scope='system:authentication',
+ )
async with self._password_hash_lock:
- return await asyncio.to_thread(argon2.PasswordHasher().hash, password)
+ with bounded_executor.blocking_work_scope('system:authentication'):
+ return await asyncio.to_thread(argon2.PasswordHasher().hash, password)
+
+ def _require_local_directory(self) -> None:
+ if self._uses_control_plane_directory():
+ raise ControlPlaneDirectoryRequiredError(
+ 'Cloud Accounts and directory changes are managed by the SaaS control plane'
+ )
+
+ def _uses_control_plane_directory(self) -> bool:
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ return bool(workspace_service is not None and workspace_service.policy.multi_workspace_enabled)
async def _verify_password(self, hashed_password: str, password: str) -> None:
+ if self._password_hash_lock.locked():
+ raise bounded_executor.BlockingWorkCapacityError(
+ 'Password hashing capacity reached',
+ scope='system:authentication',
+ )
async with self._password_hash_lock:
- await asyncio.to_thread(argon2.PasswordHasher().verify, hashed_password, password)
+ with bounded_executor.blocking_work_scope('system:authentication'):
+ await asyncio.to_thread(argon2.PasswordHasher().verify, hashed_password, password)
+
+ async def _update_space_provider_for_account(self, account: typing.Any, api_key: str) -> None:
+ """Refresh the OSS Workspace Space provider without guessing a SaaS Workspace.
+
+ Space OAuth credentials belong to an Account, while model-provider secrets
+ belong to a Workspace. Community edition has one unambiguous Workspace, so
+ the historical automatic refresh remains available only to the Workspace owner.
+ In multi-Workspace SaaS mode the OAuth callback has
+ no trusted Workspace selector; the closed control plane or an explicit
+ Workspace settings action must perform that linkage instead.
+ """
+
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
+ account_uuid = getattr(account, 'uuid', None)
+ if workspace_service is None or collaboration_service is None or not isinstance(account_uuid, str):
+ # Never turn a missing tenant kernel into a global secret mutation.
+ return
+ if workspace_service.policy.multi_workspace_enabled:
+ return
+
+ accesses = await collaboration_service.list_account_workspaces(account_uuid)
+ if len(accesses) != 1:
+ return
+ access = accesses[0]
+ if access.membership.role != MembershipRole.OWNER.value:
+ return
+ await self.ap.provider_service.update_space_model_provider_api_keys(
+ access.workspace.uuid,
+ api_key,
+ )
async def is_initialized(self) -> bool:
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
+ account = await self._identity_scalar(
+ sqlalchemy.select(user.User).limit(1),
+ f'instance:{self._jwt_identity()[1]}',
+ )
+ return account is not None
- result_list = result.all()
- return result_list is not None and len(result_list) > 0
+ async def get_login_capabilities(self) -> dict[str, bool]:
+ """Derive enabled public login methods in an explicit discovery scope."""
+ password_count = sqlalchemy.func.count().filter(user.User.password.is_not(None), user.User.password != '')
+ space_count = sqlalchemy.func.count().filter(user.User.space_account_uuid.is_not(None))
+ statement = sqlalchemy.select(password_count, space_count).where(
+ user.User.status == user.AccountStatus.ACTIVE.value
+ )
+ digest = hashlib.sha256(f'login-capabilities:{self._jwt_identity()[1]}'.encode('utf-8')).hexdigest()
+ current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
+ identity_uow = getattr(self.ap.persistence_mgr, 'identity_discovery_uow', None)
+ if current_session() is None and callable(identity_uow):
+ async with identity_uow(digest) as discovery:
+ result = await discovery.session.execute(statement)
+ else:
+ result = await self.ap.persistence_mgr.execute_async(statement)
+ password_accounts, space_accounts = result.one()
+ return {
+ 'password_login_enabled': bool(password_accounts),
+ 'space_login_enabled': bool(space_accounts),
+ }
+
+ async def get_workspace_owner(self, workspace_uuid: str) -> user.User | None:
+ """Resolve the active owner Account for a Workspace."""
+ statement = (
+ sqlalchemy.select(user.User)
+ .join(WorkspaceMembership, WorkspaceMembership.account_uuid == user.User.uuid)
+ .where(
+ WorkspaceMembership.workspace_uuid == workspace_uuid,
+ WorkspaceMembership.role == MembershipRole.OWNER.value,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ user.User.status == user.AccountStatus.ACTIVE.value,
+ )
+ )
+ current_session = self.ap.persistence_mgr.current_session()
+ if current_session is not None:
+ return await current_session.scalar(statement)
+ return await self._identity_scalar(statement, f'workspace-owner:{workspace_uuid}')
+
+ def _session_factory(self) -> async_sessionmaker[AsyncSession]:
+ return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
+
+ def _jwt_identity(self) -> tuple[str, str]:
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ instance_uuid = str(getattr(workspace_service, 'instance_uuid', '') or constants.instance_id).strip()
+ # UserService is constructed only after config/bootstrap in production.
+ # The fallback keeps lightweight isolated unit tests deterministic.
+ if not instance_uuid:
+ instance_uuid = 'uninitialized-test-instance'
+ return 'langbot-core', f'langbot-instance:{instance_uuid}'
+
+ def _legacy_local_tokens_allowed(self) -> bool:
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ policy = getattr(workspace_service, 'policy', None)
+ return getattr(policy, 'multi_workspace_enabled', False) is not True
async def create_user(self, user_email: str, password: str) -> None:
+ """Create the first local Account and Workspace owner atomically."""
+
+ await self.create_initial_account(user_email, password)
+
+ async def create_initial_account(self, user_email: str, password: str) -> user.User:
+ self._require_local_directory()
+ normalized_email = normalize_email(user_email)
hashed_password = await self._hash_password(password)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(user.User).values(user=user_email, password=hashed_password, account_type='local')
+ async with self._create_user_lock:
+ async with self._session_factory()() as session:
+ async with session.begin():
+ existing_count = int(
+ (await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(user.User))) or 0
+ )
+ if existing_count:
+ raise PublicRegistrationClosedError('System already initialized')
+ account = self._new_account(normalized_email, hashed_password)
+ session.add(account)
+ await session.flush()
+ await self.ap.workspace_service.bootstrap_local_account(account.uuid, session=session)
+ return account
+
+ async def register_invited_account(
+ self,
+ invitation_token: str,
+ user_email: str,
+ password: str,
+ ) -> tuple[user.User, typing.Any]:
+ """Create an invited Account and accept its Membership in one transaction."""
+
+ normalized_email = normalize_email(user_email)
+ if self._uses_control_plane_directory():
+ raise ControlPlaneDirectoryRequiredError(
+ 'Cloud invitation registration must use a Space account to preserve control-plane identity'
+ )
+ invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
+ if invitation.normalized_email != normalized_email:
+ from ....workspace.collaboration import InvitationEmailMismatchError
+
+ raise InvitationEmailMismatchError('Invitation email does not match the Account')
+ hashed_password = await self._hash_password(password)
+
+ async with self._create_user_lock:
+ async with self._session_factory()() as session:
+ async with session.begin():
+ existing = await session.scalar(
+ sqlalchemy.select(user.User).where(user.User.normalized_email == normalized_email)
+ )
+ if existing is not None:
+ raise AccountExistsLoginRequiredError('An Account already exists for this email')
+ account = self._new_account(normalized_email, hashed_password)
+ session.add(account)
+ await session.flush()
+ membership = await self.ap.workspace_collaboration_service.accept_invitation(
+ invitation_token,
+ account.uuid,
+ session=session,
+ )
+ return account, membership
+
+ def _new_account(self, normalized_email: str, hashed_password: str) -> user.User:
+ return user.User(
+ uuid=str(uuid.uuid4()),
+ user=normalized_email,
+ normalized_email=normalized_email,
+ password=hashed_password,
+ account_type='local',
+ status=user.AccountStatus.ACTIVE.value,
+ source=user.AccountSource.LOCAL.value,
+ projection_revision=0,
)
async def get_user_by_email(self, user_email: str) -> user.User | None:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(user.User).where(user.User.user == user_email)
+ normalized_email = user_email.strip().casefold()
+ return await self._identity_scalar(
+ sqlalchemy.select(user.User).where(user.User.normalized_email == normalized_email),
+ f'email:{normalized_email}',
)
- result_list = result.all()
- return result_list[0] if result_list is not None and len(result_list) > 0 else None
+ async def get_user_by_uuid(self, account_uuid: str) -> user.User | None:
+ return await self._identity_scalar(
+ sqlalchemy.select(user.User).where(user.User.uuid == account_uuid),
+ f'uuid:{account_uuid}',
+ )
async def get_user_by_space_account_uuid(self, space_account_uuid: str) -> user.User | None:
"""Get user by Space account UUID"""
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid)
+ return await self._identity_scalar(
+ sqlalchemy.select(user.User).where(user.User.space_account_uuid == space_account_uuid),
+ f'space:{space_account_uuid}',
)
- result_list = result.all()
- return result_list[0] if result_list is not None and len(result_list) > 0 else None
-
async def authenticate(self, user_email: str, password: str) -> str | None:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(user.User).where(user.User.user == user_email)
- )
-
- result_list = result.all()
-
- if result_list is None or len(result_list) == 0:
+ user_obj = await self.get_user_by_email(user_email)
+ if user_obj is None:
raise ValueError('用户不存在')
-
- user_obj = result_list[0]
+ self._require_active_account(user_obj)
# Check if this user has a local password set
if not user_obj.password:
@@ -78,30 +398,121 @@ class UserService:
await self._verify_password(user_obj.password, password)
- return await self.generate_jwt_token(user_email)
+ return await self.generate_jwt_token(user_obj)
- async def generate_jwt_token(self, user_email: str) -> str:
+ async def generate_jwt_token(self, account: user.User | str) -> str:
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
+ account_obj: user.User | None = account if not isinstance(account, str) and hasattr(account, 'user') else None
+ user_email = account_obj.user if account_obj is not None else account
+ if account_obj is None and hasattr(self.ap, 'persistence_mgr'):
+ try:
+ account_obj = await self.get_user_by_email(user_email)
+ except (AttributeError, TypeError):
+ # Lightweight unit-test and bootstrap callers may not have persistence wired.
+ account_obj = None
+
payload = {
'user': user_email,
- 'iss': 'LangBot-' + constants.edition,
+ 'iss': self._jwt_identity()[0],
+ 'aud': self._jwt_identity()[1],
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=jwt_expire),
}
+ if account_obj is not None:
+ self._require_active_account(account_obj)
+ payload.update(
+ {
+ 'sub': account_obj.uuid,
+ 'account_revision': account_obj.projection_revision,
+ }
+ )
return jwt.encode(payload, jwt_secret, algorithm='HS256')
async def verify_jwt_token(self, token: str) -> str:
- jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
+ account = await self.get_authenticated_account(token, allow_unresolved_legacy=True)
+ if isinstance(account, str):
+ return account
+ return account.user
- return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']
+ async def get_authenticated_account(
+ self,
+ token: str,
+ *,
+ allow_unresolved_legacy: bool = False,
+ ) -> user.User | str:
+ """Resolve a JWT to an active Account, accepting bounded legacy email tokens."""
+
+ jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
+ issuer, audience = self._jwt_identity()
+ try:
+ payload = jwt.decode(
+ token,
+ jwt_secret,
+ algorithms=['HS256'],
+ issuer=issuer,
+ audience=audience,
+ options={'require': ['exp', 'iss', 'aud']},
+ )
+ except jwt.MissingRequiredClaimError:
+ # Preserve one bounded OSS upgrade path for previously issued
+ # community tokens. SaaS/Cloud policy never accepts these tokens,
+ # and a token carrying a new-style or foreign audience cannot fall
+ # back into the legacy decoder.
+ unverified = jwt.decode(token, options={'verify_signature': False})
+ if (
+ not self._legacy_local_tokens_allowed()
+ or 'aud' in unverified
+ or unverified.get('iss') != 'LangBot-community'
+ ):
+ raise
+ payload = jwt.decode(
+ token,
+ jwt_secret,
+ algorithms=['HS256'],
+ options={'require': ['exp'], 'verify_aud': False, 'verify_iss': False},
+ )
+ account_obj: user.User | None = None
+ account_uuid = payload.get('sub')
+ if isinstance(account_uuid, str) and account_uuid:
+ try:
+ account_obj = await self.get_user_by_uuid(account_uuid)
+ except AttributeError:
+ account_obj = None
+ if account_obj is None:
+ legacy_email = payload.get('user')
+ if not isinstance(legacy_email, str) or not legacy_email:
+ raise ValueError('JWT Account identity is missing')
+ try:
+ account_obj = await self.get_user_by_email(legacy_email)
+ except AttributeError:
+ account_obj = None
+ if account_obj is None and allow_unresolved_legacy:
+ return legacy_email
+ if account_obj is None:
+ raise ValueError('Account not found')
+ self._require_active_account(account_obj)
+ token_revision = payload.get('account_revision')
+ if token_revision is not None and int(token_revision) != account_obj.projection_revision:
+ raise ValueError('Account token revision is stale')
+ return account_obj
+
+ @staticmethod
+ def _require_active_account(account: user.User) -> None:
+ status = getattr(account, 'status', user.AccountStatus.ACTIVE.value)
+ if isinstance(status, str) and status != user.AccountStatus.ACTIVE.value:
+ raise AccountDisabledError('Account is disabled')
async def reset_password(self, user_email: str, new_password: str) -> None:
hashed_password = await self._hash_password(new_password)
+ normalized_email = normalize_email(user_email)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
+ await self._identity_execute(
+ sqlalchemy.update(user.User)
+ .where(user.User.normalized_email == normalized_email)
+ .values(password=hashed_password),
+ f'email:{normalized_email}',
)
async def change_password(self, user_email: str, current_password: str, new_password: str) -> None:
@@ -115,9 +526,13 @@ class UserService:
await self._verify_password(user_obj.password, current_password)
hashed_password = await self._hash_password(new_password)
+ normalized_email = normalize_email(user_email)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
+ await self._identity_execute(
+ sqlalchemy.update(user.User)
+ .where(user.User.normalized_email == normalized_email)
+ .values(password=hashed_password),
+ f'email:{normalized_email}',
)
# Space user management
@@ -132,6 +547,16 @@ class UserService:
expires_in: int = 0,
) -> user.User:
"""Create or update a Space user account (only if system not initialized or user exists)"""
+ if self._uses_control_plane_directory():
+ return await self._update_projected_space_user(
+ space_account_uuid=space_account_uuid,
+ email=email,
+ access_token=access_token,
+ refresh_token=refresh_token,
+ api_key=api_key,
+ expires_in=expires_in,
+ )
+ self._require_local_directory()
expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
async with self._create_user_lock:
@@ -140,7 +565,7 @@ class UserService:
if existing_user:
# Update existing user's tokens
- await self.ap.persistence_mgr.execute_async(
+ await self._identity_execute(
sqlalchemy.update(user.User)
.where(user.User.space_account_uuid == space_account_uuid)
.values(
@@ -148,19 +573,56 @@ class UserService:
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
- )
+ ),
+ f'space:{space_account_uuid}',
)
- await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
+ await self._update_space_provider_for_account(existing_user, api_key)
return await self.get_user_by_space_account_uuid(space_account_uuid)
# Check if user with same email exists
existing_email_user = await self.get_user_by_email(email)
if existing_email_user:
- # Update existing user to link with Space account
+ # Email is display/contact identity, not an OAuth subject. An
+ # unknown Space subject must never take over an existing local
+ # Account merely by presenting the same email. The Account
+ # owner must first authenticate locally and use the explicit,
+ # account-bound bind flow.
+ raise account_errors.SpaceAccountBindingRequiredError()
+
+ # Check if system is already initialized
+ is_initialized = await self.is_initialized()
+ if is_initialized:
+ raise account_errors.SpaceAccountNotRegisteredError()
+
+ # Create new Space user (first time initialization)
+ if hasattr(self.ap.persistence_mgr, 'get_db_engine') and hasattr(self.ap, 'workspace_service'):
+ async with self._session_factory()() as session:
+ async with session.begin():
+ account = user.User(
+ uuid=str(uuid.uuid4()),
+ user=normalize_email(email),
+ normalized_email=normalize_email(email),
+ password='',
+ account_type='space',
+ status=user.AccountStatus.ACTIVE.value,
+ source=user.AccountSource.LOCAL.value,
+ projection_revision=0,
+ space_account_uuid=space_account_uuid,
+ space_access_token=access_token,
+ space_refresh_token=refresh_token,
+ space_api_key=api_key,
+ space_access_token_expires_at=expires_at,
+ )
+ session.add(account)
+ await session.flush()
+ await self.ap.workspace_service.bootstrap_local_account(account.uuid, session=session)
+ else:
+ # Compatibility path for lightweight service tests without a real engine.
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(user.User)
- .where(user.User.user == email)
- .values(
+ sqlalchemy.insert(user.User).values(
+ user=normalize_email(email),
+ normalized_email=normalize_email(email),
+ password='',
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
@@ -169,30 +631,56 @@ class UserService:
space_access_token_expires_at=expires_at,
)
)
- await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
- return await self.get_user_by_email(email)
+ created_user = await self.get_user_by_space_account_uuid(space_account_uuid)
+ if created_user is not None:
+ await self._update_space_provider_for_account(created_user, api_key)
+ return created_user
- # Check if system is already initialized
- is_initialized = await self.is_initialized()
- if is_initialized:
- raise account_errors.AccountEmailMismatchError()
+ async def _update_projected_space_user(
+ self,
+ *,
+ space_account_uuid: str,
+ email: str,
+ access_token: str,
+ refresh_token: str,
+ api_key: str,
+ expires_in: int,
+ ) -> user.User:
+ """Attach OAuth credentials to an already projected Cloud Account."""
- # Create new Space user (first time initialization)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(user.User).values(
- user=email,
- password='', # Space users don't have local password
- account_type='space',
- space_account_uuid=space_account_uuid,
+ normalized_email = normalize_email(email)
+ expires_at = datetime.datetime.now() + datetime.timedelta(seconds=expires_in) if expires_in > 0 else None
+ async with self._create_user_lock:
+ projected = await self.get_user_by_space_account_uuid(space_account_uuid)
+ if (
+ projected is None
+ or projected.uuid != space_account_uuid
+ or projected.normalized_email != normalized_email
+ or projected.source != user.AccountSource.CLOUD_PROJECTION.value
+ or projected.account_type != 'space'
+ ):
+ raise ControlPlaneDirectoryRequiredError('Space Account is not present in the verified Cloud directory')
+ self._require_active_account(projected)
+ await self._identity_execute(
+ sqlalchemy.update(user.User)
+ .where(
+ user.User.uuid == projected.uuid,
+ user.User.space_account_uuid == space_account_uuid,
+ user.User.source == user.AccountSource.CLOUD_PROJECTION.value,
+ )
+ .values(
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
- )
+ ),
+ f'space:{space_account_uuid}',
)
- await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
-
- return await self.get_user_by_space_account_uuid(space_account_uuid)
+ refreshed = await self.get_user_by_space_account_uuid(space_account_uuid)
+ if refreshed is None:
+ raise ControlPlaneDirectoryRequiredError('Space Account disappeared from the verified Cloud directory')
+ self._require_active_account(refreshed)
+ return refreshed
async def authenticate_space_user(
self, access_token: str, refresh_token: str, expires_in: int = 0
@@ -221,15 +709,44 @@ class UserService:
)
# Generate JWT token
- jwt_token = await self.generate_jwt_token(email)
+ jwt_token = await self.generate_jwt_token(user_obj)
return jwt_token, user_obj
async def get_first_user(self) -> user.User | None:
"""Get the first user (for single-user mode)"""
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
- result_list = result.all()
- return result_list[0] if result_list else None
+ return await self._identity_scalar(
+ sqlalchemy.select(user.User).limit(1),
+ f'instance:{self._jwt_identity()[1]}',
+ )
+
+ async def _identity_scalar(
+ self,
+ statement: typing.Any,
+ identity: str,
+ ) -> user.User | None:
+ """Execute one exact Account lookup in an explicit discovery transaction."""
+
+ digest = hashlib.sha256(identity.encode('utf-8')).hexdigest()
+ current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
+ identity_uow = getattr(self.ap.persistence_mgr, 'identity_discovery_uow', None)
+ if current_session() is None and callable(identity_uow):
+ async with identity_uow(digest) as discovery:
+ return await discovery.session.scalar(statement)
+ result = await self.ap.persistence_mgr.execute_async(statement)
+ rows = result.all()
+ return rows[0] if rows else None
+
+ async def _identity_execute(self, statement: typing.Any, identity: str) -> typing.Any:
+ """Execute one exact Account mutation in an explicit transaction."""
+
+ digest = hashlib.sha256(identity.encode('utf-8')).hexdigest()
+ current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
+ identity_uow = getattr(self.ap.persistence_mgr, 'identity_discovery_uow', None)
+ if current_session() is None and callable(identity_uow):
+ async with identity_uow(digest) as discovery:
+ return await discovery.session.execute(statement)
+ return await self.ap.persistence_mgr.execute_async(statement)
async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:
"""Set or change password for a user"""
@@ -246,12 +763,19 @@ class UserService:
await self._verify_password(user_obj.password, current_password)
hashed_password = await self._hash_password(new_password)
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
+ normalized_email = normalize_email(user_email)
+ await self._identity_execute(
+ sqlalchemy.update(user.User)
+ .where(user.User.normalized_email == normalized_email)
+ .values(password=hashed_password),
+ f'email:{normalized_email}',
)
async def bind_space_account(self, user_email: str, code: str) -> user.User:
"""Bind Space account to existing local account"""
+ local_account = await self.get_user_by_email(user_email)
+ if local_account is None:
+ raise ValueError('User not found')
# Exchange code for tokens
token_data = await self.ap.space_service.exchange_oauth_code(code)
access_token = token_data.get('access_token')
@@ -273,28 +797,33 @@ class UserService:
if not space_account_uuid or not space_email:
raise ValueError('Invalid Space user info')
+ if normalize_email(space_email) != normalize_email(user_email):
+ raise account_errors.AccountEmailMismatchError()
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
- if existing_space_user and existing_space_user.user != user_email:
+ if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
raise ValueError('This Space account is already bound to another user')
# Update local account to Space account
- await self.ap.persistence_mgr.execute_async(
+ normalized_email = normalize_email(user_email)
+ await self._identity_execute(
sqlalchemy.update(user.User)
- .where(user.User.user == user_email)
+ .where(user.User.normalized_email == normalized_email)
.values(
- user=space_email, # Update email to Space email
+ user=normalize_email(space_email), # Update email to Space email
+ normalized_email=normalize_email(space_email),
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
space_access_token_expires_at=expires_at,
- )
+ ),
+ f'email:{normalized_email}',
)
# Update Space model provider API keys
- await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
+ await self._update_space_provider_for_account(local_account, api_key)
return await self.get_user_by_email(space_email)
diff --git a/src/langbot/pkg/api/http/service/webhook.py b/src/langbot/pkg/api/http/service/webhook.py
index b3a671189..1925b51cd 100644
--- a/src/langbot/pkg/api/http/service/webhook.py
+++ b/src/langbot/pkg/api/http/service/webhook.py
@@ -4,6 +4,12 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import webhook
+from .secrets import SECRET_MASK, mask_secret_value, restore_secret_placeholders
+from .tenant import TenantContext, require_workspace_uuid, scope_statement
+
+
+_DEFAULT_MAX_WEBHOOKS_PER_WORKSPACE = 16
+_HARD_MAX_WEBHOOKS_PER_WORKSPACE = 64
class WebhookService:
@@ -12,31 +18,99 @@ class WebhookService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
- async def get_webhooks(self) -> list[dict]:
+ def max_per_workspace(self) -> int:
+ """Return the configured webhook cap within the process hard limit."""
+
+ config = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
+ try:
+ value = int(
+ config.get('webhooks', {}).get(
+ 'max_per_workspace',
+ _DEFAULT_MAX_WEBHOOKS_PER_WORKSPACE,
+ )
+ )
+ except (AttributeError, TypeError, ValueError):
+ value = _DEFAULT_MAX_WEBHOOKS_PER_WORKSPACE
+ return min(max(value, 1), _HARD_MAX_WEBHOOKS_PER_WORKSPACE)
+
+ def _serialize_webhook(self, entity, *, include_secret: bool) -> dict:
+ serialized = self.ap.persistence_mgr.serialize_model(webhook.Webhook, entity)
+ if not include_secret:
+ serialized = serialized.copy()
+ serialized['url'] = mask_secret_value(serialized.get('url'))
+ return serialized
+
+ async def get_webhooks(self, context: TenantContext, *, include_secret: bool = False) -> list[dict]:
"""Get all webhooks"""
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(webhook.Webhook))
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.select(webhook.Webhook).order_by(webhook.Webhook.id).limit(_HARD_MAX_WEBHOOKS_PER_WORKSPACE),
+ webhook.Webhook,
+ context,
+ )
+ )
webhooks = result.all()
- return [self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh) for wh in webhooks]
+ return [self._serialize_webhook(wh, include_secret=include_secret) for wh in webhooks]
- async def create_webhook(self, name: str, url: str, description: str = '', enabled: bool = True) -> dict:
+ async def create_webhook(
+ self,
+ context: TenantContext,
+ name: str,
+ url: str,
+ description: str = '',
+ enabled: bool = True,
+ ) -> dict:
"""Create a new webhook"""
- webhook_data = {'name': name, 'url': url, 'description': description, 'enabled': enabled}
+ workspace_uuid = require_workspace_uuid(context)
+ max_webhooks = self.max_per_workspace()
+ count_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(webhook.Webhook)
+ .where(webhook.Webhook.workspace_uuid == workspace_uuid)
+ )
+ if (count_result.scalar() or 0) >= max_webhooks:
+ raise ValueError(f'Maximum number of webhooks ({max_webhooks}) reached')
- await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(webhook.Webhook).values(**webhook_data))
+ url = restore_secret_placeholders(url, sensitive=True)
+ webhook_data = {
+ 'workspace_uuid': workspace_uuid,
+ 'name': name,
+ 'url': url,
+ 'description': description,
+ 'enabled': enabled,
+ }
+
+ insert_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(webhook.Webhook).values(**webhook_data)
+ )
# Retrieve the created webhook
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.url == url).order_by(webhook.Webhook.id.desc())
+ scope_statement(
+ sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == insert_result.inserted_primary_key[0]),
+ webhook.Webhook,
+ workspace_uuid,
+ )
)
created_webhook = result.first()
return self.ap.persistence_mgr.serialize_model(webhook.Webhook, created_webhook)
- async def get_webhook(self, webhook_id: int) -> dict | None:
+ async def get_webhook(
+ self,
+ context: TenantContext,
+ webhook_id: int,
+ *,
+ include_secret: bool = False,
+ ) -> dict | None:
"""Get a specific webhook by ID"""
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id)
+ scope_statement(
+ sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.id == webhook_id),
+ webhook.Webhook,
+ context,
+ )
)
wh = result.first()
@@ -44,16 +118,27 @@ class WebhookService:
if wh is None:
return None
- return self.ap.persistence_mgr.serialize_model(webhook.Webhook, wh)
+ return self._serialize_webhook(wh, include_secret=include_secret)
async def update_webhook(
- self, webhook_id: int, name: str = None, url: str = None, description: str = None, enabled: bool = None
- ) -> None:
+ self,
+ context: TenantContext,
+ webhook_id: int,
+ name: str | None = None,
+ url: str | None = None,
+ description: str | None = None,
+ enabled: bool | None = None,
+ ) -> bool:
"""Update a webhook's metadata"""
update_data = {}
if name is not None:
update_data['name'] = name
if url is not None:
+ if url == SECRET_MASK:
+ current = await self.get_webhook(context, webhook_id, include_secret=True)
+ if current is None:
+ return False
+ url = restore_secret_placeholders(url, current.get('url'), sensitive=True)
update_data['url'] = url
if description is not None:
update_data['description'] = description
@@ -61,20 +146,37 @@ class WebhookService:
update_data['enabled'] = enabled
if update_data:
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.update(webhook.Webhook).where(webhook.Webhook.id == webhook_id).values(**update_data),
+ webhook.Webhook,
+ context,
+ )
)
+ return (result.rowcount or 0) > 0
+ return await self.get_webhook(context, webhook_id) is not None
- async def delete_webhook(self, webhook_id: int) -> None:
+ async def delete_webhook(self, context: TenantContext, webhook_id: int) -> bool:
"""Delete a webhook"""
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id)
+ result = await self.ap.persistence_mgr.execute_async(
+ scope_statement(
+ sqlalchemy.delete(webhook.Webhook).where(webhook.Webhook.id == webhook_id),
+ webhook.Webhook,
+ context,
+ )
)
+ return (result.rowcount or 0) > 0
- async def get_enabled_webhooks(self) -> list[dict]:
+ async def get_enabled_webhooks(self, context: TenantContext) -> list[dict]:
"""Get all enabled webhooks"""
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True)
+ scope_statement(
+ sqlalchemy.select(webhook.Webhook).where(webhook.Webhook.enabled == True),
+ webhook.Webhook,
+ context,
+ )
+ .order_by(webhook.Webhook.id)
+ .limit(self.max_per_workspace())
)
webhooks = result.all()
diff --git a/src/langbot/pkg/api/mcp/context.py b/src/langbot/pkg/api/mcp/context.py
new file mode 100644
index 000000000..56fbe0ea1
--- /dev/null
+++ b/src/langbot/pkg/api/mcp/context.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+import contextvars
+
+from ..http.context import RequestContext
+
+
+_request_context: contextvars.ContextVar[RequestContext | None] = contextvars.ContextVar(
+ 'langbot_mcp_request_context',
+ default=None,
+)
+
+
+def bind_request_context(context: RequestContext) -> contextvars.Token[RequestContext | None]:
+ """Bind the authenticated MCP request while its ASGI request is executing."""
+
+ return _request_context.set(context)
+
+
+def reset_request_context(token: contextvars.Token[RequestContext | None]) -> None:
+ _request_context.reset(token)
+
+
+def get_request_context() -> RequestContext:
+ """Return the current trusted MCP context or fail closed."""
+
+ context = _request_context.get()
+ if context is None:
+ raise RuntimeError('MCP Workspace context is unavailable')
+ return context
diff --git a/src/langbot/pkg/api/mcp/mount.py b/src/langbot/pkg/api/mcp/mount.py
index d113b2501..0856c1dab 100644
--- a/src/langbot/pkg/api/mcp/mount.py
+++ b/src/langbot/pkg/api/mcp/mount.py
@@ -19,7 +19,10 @@ from __future__ import annotations
import contextlib
import typing
+import uuid
+from ..http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
+from .context import bind_request_context, reset_request_context
from .server import LangBotMCPServer
if typing.TYPE_CHECKING:
@@ -28,6 +31,9 @@ if typing.TYPE_CHECKING:
# JSON-RPC-ish 401 body returned before the MCP app is reached.
_UNAUTHORIZED_BODY = b'{"error":"unauthorized","message":"A valid LangBot API key is required for MCP access."}'
+_ENTITLEMENT_UNAVAILABLE_BODY = (
+ b'{"error":"entitlement_unavailable","message":"Workspace entitlement is unavailable for MCP access."}'
+)
def _extract_api_key(headers: list[tuple[bytes, bytes]]) -> str:
@@ -76,7 +82,7 @@ class MCPMount:
def wrap(self, quart_asgi: typing.Callable) -> typing.Callable:
"""Return a dispatcher ASGI app fronting ``quart_asgi``."""
mcp_asgi = self._mcp_asgi
- verify_api_key = self.ap.apikey_service.verify_api_key
+ authenticate_api_key = self.ap.apikey_service.authenticate_api_key
is_mcp_path = self._is_mcp_path
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
@@ -88,12 +94,12 @@ class MCPMount:
# Authenticate MCP HTTP requests with a LangBot API key.
api_key = _extract_api_key(scope.get('headers', []))
- authorized = False
+ identity = None
if api_key:
with contextlib.suppress(Exception):
- authorized = await verify_api_key(api_key)
+ identity = await authenticate_api_key(api_key)
- if not authorized:
+ if identity is None:
await send(
{
'type': 'http.response.start',
@@ -107,6 +113,56 @@ class MCPMount:
await send({'type': 'http.response.body', 'body': _UNAUTHORIZED_BODY})
return
- await mcp_asgi(scope, receive, send)
+ deployment_admission = getattr(self.ap, 'deployment_admission', None)
+ try:
+ if deployment_admission is not None:
+ deployment_admission.require_active()
+ entitlement_revision = 0
+ deployment = getattr(self.ap, 'deployment', None)
+ if deployment is not None and getattr(deployment, 'multi_workspace_enabled', False):
+ resolver = getattr(self.ap, 'entitlement_resolver', None)
+ if resolver is None or identity.instance_uuid != resolver.instance_uuid:
+ raise RuntimeError('Workspace entitlement resolver is unavailable')
+ entitlement = await resolver.resolve(identity.workspace_uuid)
+ entitlement_revision = entitlement.entitlement_revision
+ except Exception:
+ await send(
+ {
+ 'type': 'http.response.start',
+ 'status': 403,
+ 'headers': [(b'content-type', b'application/json')],
+ }
+ )
+ await send({'type': 'http.response.body', 'body': _ENTITLEMENT_UNAVAILABLE_BODY})
+ return
+
+ request_context = RequestContext(
+ instance_uuid=identity.instance_uuid,
+ placement_generation=identity.placement_generation,
+ request_id=str(uuid.uuid4()),
+ auth_type='api-key',
+ principal=PrincipalContext(
+ principal_type=PrincipalType.API_KEY,
+ api_key_uuid=identity.api_key_uuid,
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid=identity.workspace_uuid,
+ membership_uuid=None,
+ role=None,
+ permissions=identity.permissions,
+ ),
+ entitlement_revision=entitlement_revision,
+ )
+ tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
+ if not callable(tenant_scope):
+ raise RuntimeError('MCP request persistence scope is unavailable')
+ async with tenant_scope(identity.workspace_uuid):
+ token = bind_request_context(request_context)
+ try:
+ await mcp_asgi(scope, receive, send)
+ if deployment_admission is not None:
+ deployment_admission.require_active()
+ finally:
+ reset_request_context(token)
return dispatcher
diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py
index 95630bbaf..4cf4e33ad 100644
--- a/src/langbot/pkg/api/mcp/server.py
+++ b/src/langbot/pkg/api/mcp/server.py
@@ -22,6 +22,9 @@ import typing
from mcp.server.fastmcp import FastMCP
+from ..http.authz import Permission, require_permission
+from .context import get_request_context
+
if typing.TYPE_CHECKING:
from ...core import app as app_module
@@ -46,6 +49,12 @@ def _dump(value: typing.Any) -> str:
return json.dumps(value, ensure_ascii=False, default=str)
+def _authorized(permission: Permission):
+ context = get_request_context()
+ require_permission(context, permission)
+ return context
+
+
class LangBotMCPServer:
"""Builds and owns the FastMCP instance for LangBot."""
@@ -72,6 +81,7 @@ class LangBotMCPServer:
# ----- System (read-only) -------------------------------------- #
@mcp.tool(description='Get basic LangBot system/runtime information (version, edition).')
async def get_system_info() -> str:
+ _authorized(Permission.WORKSPACE_VIEW)
version = None
try:
version = ap.ver_mgr.get_current_version()
@@ -87,11 +97,13 @@ class LangBotMCPServer:
# ----- Bots ---------------------------------------------------- #
@mcp.tool(description='List all messaging-platform bots. Secrets are redacted.')
async def list_bots() -> str:
- return _dump(await ap.bot_service.get_bots(include_secret=False))
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.bot_service.get_bots(context, include_secret=False))
@mcp.tool(description='Get a single bot by its UUID. Secrets are redacted.')
async def get_bot(bot_uuid: str) -> str:
- return _dump(await ap.bot_service.get_bot(bot_uuid, include_secret=False))
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.bot_service.get_bot(context, bot_uuid, include_secret=False))
@mcp.tool(
description=(
@@ -101,26 +113,31 @@ class LangBotMCPServer:
)
)
async def create_bot(bot_data: dict) -> str:
- return _dump({'uuid': await ap.bot_service.create_bot(bot_data)})
+ context = _authorized(Permission.RESOURCE_MANAGE)
+ return _dump({'uuid': await ap.bot_service.create_bot(context, bot_data)})
@mcp.tool(description='Update a bot by UUID. `bot_data` matches the PUT bot body.')
async def update_bot(bot_uuid: str, bot_data: dict) -> str:
- await ap.bot_service.update_bot(bot_uuid, bot_data)
+ context = _authorized(Permission.RESOURCE_MANAGE)
+ await ap.bot_service.update_bot(context, bot_uuid, bot_data)
return _dump({'ok': True})
@mcp.tool(description='Delete a bot by UUID.')
async def delete_bot(bot_uuid: str) -> str:
- await ap.bot_service.delete_bot(bot_uuid)
+ context = _authorized(Permission.RESOURCE_MANAGE)
+ await ap.bot_service.delete_bot(context, bot_uuid)
return _dump({'ok': True})
# ----- Pipelines ----------------------------------------------- #
@mcp.tool(description='List all pipelines.')
async def list_pipelines() -> str:
- return _dump(await ap.pipeline_service.get_pipelines())
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.pipeline_service.get_pipelines(context))
@mcp.tool(description='Get a single pipeline by UUID.')
async def get_pipeline(pipeline_uuid: str) -> str:
- return _dump(await ap.pipeline_service.get_pipeline(pipeline_uuid))
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.pipeline_service.get_pipeline(context, pipeline_uuid))
@mcp.tool(
description=(
@@ -129,49 +146,59 @@ class LangBotMCPServer:
)
)
async def create_pipeline(pipeline_data: dict) -> str:
- return _dump({'uuid': await ap.pipeline_service.create_pipeline(pipeline_data)})
+ context = _authorized(Permission.RESOURCE_MANAGE)
+ return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
- await ap.pipeline_service.update_pipeline(pipeline_uuid, pipeline_data)
+ context = _authorized(Permission.RESOURCE_MANAGE)
+ await ap.pipeline_service.update_pipeline(context, pipeline_uuid, pipeline_data)
return _dump({'ok': True})
@mcp.tool(description='Delete a pipeline by UUID.')
async def delete_pipeline(pipeline_uuid: str) -> str:
- await ap.pipeline_service.delete_pipeline(pipeline_uuid)
+ context = _authorized(Permission.RESOURCE_MANAGE)
+ await ap.pipeline_service.delete_pipeline(context, pipeline_uuid)
return _dump({'ok': True})
# ----- Models -------------------------------------------------- #
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
async def list_llm_models() -> str:
- return _dump(await ap.llm_model_service.get_llm_models(include_secret=False))
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.llm_model_service.get_llm_models(context, include_secret=False))
@mcp.tool(description='Get a single LLM model by UUID.')
async def get_llm_model(model_uuid: str) -> str:
- return _dump(await ap.llm_model_service.get_llm_model(model_uuid))
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.llm_model_service.get_llm_model(context, model_uuid, include_secret=False))
@mcp.tool(description='List all configured embedding models.')
async def list_embedding_models() -> str:
- return _dump(await ap.embedding_models_service.get_embedding_models())
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.embedding_models_service.get_embedding_models(context, include_secret=False))
@mcp.tool(description='List all model providers (OpenAI-compatible, Anthropic, etc.).')
async def list_model_providers() -> str:
- return _dump(await ap.provider_service.get_providers())
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.provider_service.get_providers(context, include_secret=False))
# ----- Knowledge bases ----------------------------------------- #
@mcp.tool(description='List all knowledge bases (RAG).')
async def list_knowledge_bases() -> str:
- return _dump(await ap.knowledge_service.get_knowledge_bases())
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.knowledge_service.get_knowledge_bases(context))
@mcp.tool(description='Get a single knowledge base by UUID.')
async def get_knowledge_base(kb_uuid: str) -> str:
- return _dump(await ap.knowledge_service.get_knowledge_base(kb_uuid))
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.knowledge_service.get_knowledge_base(context, kb_uuid))
@mcp.tool(
description=('Retrieve (semantic search) from a knowledge base. Returns the matched chunks for `query`.')
)
async def retrieve_knowledge_base(kb_uuid: str, query: str) -> str:
- return _dump(await ap.knowledge_service.retrieve_knowledge_base(kb_uuid, query))
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.knowledge_service.retrieve_knowledge_base(context, kb_uuid, query))
# ----- MCP servers (LangBot as MCP client) --------------------- #
@mcp.tool(
@@ -180,16 +207,19 @@ class LangBotMCPServer:
)
)
async def list_mcp_servers() -> str:
- return _dump(await ap.mcp_service.get_mcp_servers())
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.mcp_service.get_mcp_servers(context))
# ----- Skills -------------------------------------------------- #
@mcp.tool(description='List installed skills.')
async def list_skills() -> str:
- return _dump(await ap.skill_service.list_skills())
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.skill_service.list_skills(context))
@mcp.tool(description='Get a single skill by name.')
async def get_skill(skill_name: str) -> str:
- return _dump(await ap.skill_service.get_skill(skill_name))
+ context = _authorized(Permission.RESOURCE_VIEW)
+ return _dump(await ap.skill_service.get_skill(context, skill_name))
# ------------------------------------------------------------------ #
# ASGI app
diff --git a/src/langbot/pkg/box/admission.py b/src/langbot/pkg/box/admission.py
new file mode 100644
index 000000000..8eab5b16c
--- /dev/null
+++ b/src/langbot/pkg/box/admission.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+import asyncio
+import datetime as dt
+import time
+import weakref
+from collections.abc import Callable
+from typing import TYPE_CHECKING
+
+from langbot_plugin.box.errors import BoxAdmissionError, BoxRuntimeUnavailableError
+from langbot_plugin.box.models import (
+ SandboxAdmissionGrant,
+ SandboxAdmissionPolicy,
+ SandboxAdmissionRevocation,
+)
+
+from ..api.http.context import ExecutionContext
+from ..cloud.entitlements import EntitlementSnapshot, EntitlementUnavailableError
+
+if TYPE_CHECKING:
+ from langbot_plugin.box.client import BoxRuntimeClient
+
+ from ..core.app import Application
+
+
+_UTC = dt.timezone.utc
+_MANAGED_SANDBOX_FEATURE = 'managed_sandbox'
+_MANAGED_SANDBOX_SESSION_LIMIT = 'managed_sandbox_sessions'
+_MAX_GRANT_TTL_SEC = 300
+
+
+class SandboxAdmissionController:
+ """Project Cloud entitlements into short-lived Box Runtime grants.
+
+ Product and plan names intentionally never cross this boundary. The
+ closed Control Plane supplies a versioned generic entitlement, while Core
+ installs only the numeric authority understood by the shared Box Runtime.
+
+ No state is allocated for a Workspace until it attempts to use the
+ managed sandbox. Per-Workspace locks serialize renewal/revocation so a
+ concurrent first use cannot install conflicting grants.
+ """
+
+ def __init__(
+ self,
+ ap: Application,
+ client: BoxRuntimeClient,
+ *,
+ policy: SandboxAdmissionPolicy,
+ wall_time: Callable[[], float] = time.time,
+ ) -> None:
+ self.ap = ap
+ self.client = client
+ self.policy = policy
+ self._wall_time = wall_time
+ self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
+ self._highest_revisions: dict[str, int] = {}
+
+ def _workspace_lock(self, workspace_uuid: str) -> asyncio.Lock:
+ lock = self._locks.get(workspace_uuid)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._locks[workspace_uuid] = lock
+ return lock
+
+ @staticmethod
+ def _context_revision(context: ExecutionContext) -> int:
+ revision = getattr(context, 'entitlement_revision', 0)
+ if isinstance(revision, bool) or not isinstance(revision, int):
+ return 0
+ return max(revision, 0)
+
+ def _revocation_revision(self, context: ExecutionContext, candidate_revision: int = 0) -> int:
+ return max(
+ 1,
+ self._highest_revisions.get(context.workspace_uuid, 0),
+ self._context_revision(context),
+ candidate_revision,
+ )
+
+ async def _revoke_locked(
+ self,
+ context: ExecutionContext,
+ *,
+ candidate_revision: int = 0,
+ ) -> None:
+ revision = self._revocation_revision(context, candidate_revision)
+ revocation = SandboxAdmissionRevocation(
+ instance_uuid=context.instance_uuid,
+ workspace_uuid=context.workspace_uuid,
+ entitlement_revision=revision,
+ )
+ try:
+ result = await self.client.revoke_sandbox_admission_grant(revocation)
+ if (
+ not isinstance(result, dict)
+ or result.get('revoked') is not True
+ or result.get('workspace_uuid') != context.workspace_uuid
+ or result.get('entitlement_revision') != revision
+ ):
+ raise BoxRuntimeUnavailableError('Box Runtime returned an invalid sandbox revocation receipt')
+ except Exception as exc:
+ # The caller still fails closed even if the control connection is
+ # unavailable. A previously installed grant expires independently
+ # in at most five minutes inside the Runtime.
+ self.ap.logger.warning(
+ 'Failed to install Box sandbox admission revocation: '
+ f'workspace_uuid={context.workspace_uuid} revision={revision} error={exc}'
+ )
+ self._highest_revisions[context.workspace_uuid] = revision
+
+ @staticmethod
+ def _require_managed_sandbox(snapshot: EntitlementSnapshot) -> None:
+ snapshot.require_feature(_MANAGED_SANDBOX_FEATURE)
+ sessions = snapshot.limit(_MANAGED_SANDBOX_SESSION_LIMIT)
+ if sessions != 1:
+ raise EntitlementUnavailableError('Workspace entitlement must grant exactly one managed sandbox session')
+
+ def _grant_expiry(self, snapshot: EntitlementSnapshot) -> dt.datetime:
+ now_epoch = int(self._wall_time())
+ ttl_sec = min(self.policy.max_grant_ttl_sec, _MAX_GRANT_TTL_SEC)
+ expires_epoch = min(snapshot.expires_at, now_epoch + ttl_sec)
+ if expires_epoch <= now_epoch:
+ raise EntitlementUnavailableError('Workspace entitlement expired before sandbox admission')
+ return dt.datetime.fromtimestamp(expires_epoch, tz=_UTC)
+
+ async def require(self, context: ExecutionContext) -> SandboxAdmissionGrant:
+ """Validate entitlement freshness and install/renew one Runtime grant."""
+
+ resolver = getattr(self.ap, 'entitlement_resolver', None)
+ if resolver is None:
+ raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
+ if context.instance_uuid != resolver.instance_uuid:
+ raise EntitlementUnavailableError('Workspace entitlement targets another LangBot instance')
+
+ lock = self._workspace_lock(context.workspace_uuid)
+ async with lock:
+ try:
+ snapshot = await resolver.resolve(
+ context.workspace_uuid,
+ minimum_revision=self._context_revision(context),
+ now=int(self._wall_time()),
+ )
+ except EntitlementUnavailableError as exc:
+ # Only a verified, scoped snapshot can authoritatively revoke
+ # a revision. Provider timeouts, malformed responses, and
+ # rollback/equivocation errors fail this request closed but do
+ # not tombstone a still-valid revision forever.
+ authoritative_revision = exc.entitlement_revision
+ if authoritative_revision is not None:
+ await self._revoke_locked(
+ context,
+ candidate_revision=authoritative_revision,
+ )
+ raise
+
+ try:
+ self._require_managed_sandbox(snapshot)
+ except EntitlementUnavailableError:
+ await self._revoke_locked(
+ context,
+ candidate_revision=snapshot.entitlement_revision,
+ )
+ raise
+
+ grant = SandboxAdmissionGrant(
+ instance_uuid=context.instance_uuid,
+ workspace_uuid=context.workspace_uuid,
+ execution_generation=context.placement_generation,
+ entitlement_revision=snapshot.entitlement_revision,
+ expires_at=self._grant_expiry(snapshot),
+ max_sessions=1,
+ max_managed_processes=0,
+ )
+ result = await self.client.upsert_sandbox_admission_grant(grant)
+ if (
+ not isinstance(result, dict)
+ or result.get('installed') is not True
+ or result.get('workspace_uuid') != context.workspace_uuid
+ or result.get('execution_generation') != context.placement_generation
+ or result.get('entitlement_revision') != snapshot.entitlement_revision
+ or result.get('max_sessions') != 1
+ or result.get('max_managed_processes') != 0
+ ):
+ raise BoxRuntimeUnavailableError('Box Runtime returned an invalid sandbox admission receipt')
+ self._highest_revisions[context.workspace_uuid] = max(
+ self._highest_revisions.get(context.workspace_uuid, 0),
+ snapshot.entitlement_revision,
+ )
+ return grant
+
+ async def revoke(self, context: ExecutionContext, *, entitlement_revision: int = 0) -> None:
+ """Explicitly revoke a Workspace grant using a monotonic tombstone."""
+
+ async with self._workspace_lock(context.workspace_uuid):
+ await self._revoke_locked(context, candidate_revision=entitlement_revision)
+
+
+def require_cloud_admission_policy(raw_policy: object) -> SandboxAdmissionPolicy:
+ """Parse the Cloud Box policy without permitting an OSS downgrade."""
+
+ try:
+ policy = SandboxAdmissionPolicy.model_validate(raw_policy)
+ except Exception as exc:
+ raise BoxAdmissionError('Cloud Box sandbox admission policy is invalid') from exc
+ if not policy.required:
+ raise BoxAdmissionError('Cloud Box sandbox admission must be required')
+ if policy.logical_session_id != 'global':
+ raise BoxAdmissionError('Cloud Box sandbox session ID must be global')
+ if policy.required_backend != 'nsjail':
+ raise BoxAdmissionError('Cloud Box sandbox backend must be nsjail')
+ if policy.max_sessions != 1 or policy.max_managed_processes != 0:
+ raise BoxAdmissionError('Cloud Box sandbox policy must allow one session and zero managed processes')
+ if policy.max_grant_ttl_sec > _MAX_GRANT_TTL_SEC:
+ raise BoxAdmissionError('Cloud Box sandbox admission grant TTL must not exceed 300 seconds')
+ if policy.workspace_quota_mb <= 0:
+ raise BoxAdmissionError('Cloud Box sandbox workspace quota must be a positive integer')
+ return policy
diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py
index f24287477..2ef990d0c 100644
--- a/src/langbot/pkg/box/connector.py
+++ b/src/langbot/pkg/box/connector.py
@@ -4,6 +4,7 @@ import asyncio
import contextlib
import json
import os
+import secrets
import sys
import typing
from typing import TYPE_CHECKING
@@ -16,6 +17,17 @@ from langbot_plugin.runtime.io.connection import Connection
from langbot_plugin.box.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxRuntimeUnavailableError
from langbot_plugin.box.actions import LangBotToBoxAction
+from langbot_plugin.box.security import (
+ BOX_CONTROL_TOKEN_ENV,
+ BOX_CONTROL_TOKEN_HEADER,
+ BOX_INSTANCE_HEADER,
+ BOX_PLACEMENT_GENERATION_HEADER,
+ BOX_TRUSTED_INSTANCE_ENV,
+ BOX_WORKSPACE_HEADER,
+ normalize_instance_uuid,
+ validate_control_token,
+)
+from langbot_plugin.entities.io.context import ActionContext
from ..utils import platform
from ..utils.managed_runtime import ManagedRuntimeConnector
@@ -123,6 +135,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
self._relay_host = parsed.hostname or '127.0.0.1'
self._relay_port = parsed.port or _DEFAULT_PORT
self._filtered_box_config = _filter_config_for_runtime(_get_box_config(ap))
+ self._trusted_instance_uuid = normalize_instance_uuid(self.ap.workspace_service.instance_uuid)
+ self._control_token = str(os.environ.get(BOX_CONTROL_TOKEN_ENV) or '').strip()
def uses_websocket(self) -> bool:
"""Whether the connector should use WebSocket to reach the Box runtime.
@@ -223,8 +237,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
from langbot_plugin.runtime.io.controllers.stdio.client import StdioClientController
self.ap.logger.info('Use stdio to connect to box runtime')
+ self._ensure_control_token(allow_generate=True)
python_path = sys.executable
env = os.environ.copy()
+ env[BOX_CONTROL_TOKEN_ENV] = self._control_token
+ env[BOX_TRUSTED_INSTANCE_ENV] = self._trusted_instance_uuid
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
@@ -259,7 +276,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
"""Launch box server as detached subprocess, then connect via WS (Windows)."""
self.ap.logger.info('(windows) Use cmd to launch box runtime and communicate via ws')
+ self._ensure_control_token(allow_generate=True)
env = os.environ.copy()
+ env[BOX_CONTROL_TOKEN_ENV] = self._control_token
+ env[BOX_TRUSTED_INSTANCE_ENV] = self._trusted_instance_uuid
if self._filtered_box_config:
env['LANGBOT_BOX_CONFIG'] = json.dumps(self._filtered_box_config)
@@ -282,6 +302,7 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
async def _connect_remote_ws(self) -> None:
"""Connect to a remote (or Docker) box server via WebSocket."""
+ self._ensure_control_token(allow_generate=False)
ws_url = self._resolve_rpc_ws_url()
self.ap.logger.info(f'Use WebSocket to connect to box runtime ({ws_url})')
await self._connect_ws(ws_url, 'WebSocket')
@@ -325,7 +346,11 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
if self.runtime_disconnect_callback is not None:
await self.runtime_disconnect_callback(self)
- ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
+ ctrl = WebSocketClientController(
+ ws_url=ws_url,
+ make_connection_failed_callback=on_connect_failed,
+ additional_headers=self.get_control_headers(),
+ )
self._ctrl = ctrl
self._ctrl_task = asyncio.create_task(
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error, self._generation))
@@ -339,6 +364,41 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
if connect_error:
raise BoxRuntimeUnavailableError(f'box runtime connection failed: {connect_error[0]}')
+ def _ensure_control_token(self, *, allow_generate: bool) -> str:
+ if not self._control_token and allow_generate:
+ self._control_token = secrets.token_urlsafe(48)
+ try:
+ self._control_token = validate_control_token(self._control_token)
+ except ValueError as exc:
+ raise BoxRuntimeUnavailableError(
+ f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for an external Box runtime'
+ ) from exc
+ return self._control_token
+
+ def get_control_headers(self) -> dict[str, str]:
+ """Headers for the instance-authenticated RPC control handshake."""
+
+ self._ensure_control_token(allow_generate=False)
+ return {
+ BOX_CONTROL_TOKEN_HEADER: self._control_token,
+ BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
+ }
+
+ def get_relay_headers(
+ self,
+ action_context: ActionContext,
+ ) -> dict[str, str]:
+ """Return authenticated, placement-scoped relay handshake headers."""
+
+ context = ActionContext.model_validate(action_context).without_installation()
+ if context.instance_uuid != self._trusted_instance_uuid:
+ raise BoxRuntimeUnavailableError('Box relay context belongs to another LangBot instance')
+ return {
+ **self.get_control_headers(),
+ BOX_WORKSPACE_HEADER: context.workspace_uuid,
+ BOX_PLACEMENT_GENERATION_HEADER: str(context.placement_generation),
+ }
+
def _make_connection_callback(
self,
transport_name: str,
diff --git a/src/langbot/pkg/box/secure_fs.py b/src/langbot/pkg/box/secure_fs.py
new file mode 100644
index 000000000..4e6c7de3a
--- /dev/null
+++ b/src/langbot/pkg/box/secure_fs.py
@@ -0,0 +1,285 @@
+from __future__ import annotations
+
+import contextlib
+import errno
+import os
+import stat
+from collections.abc import Iterable
+
+
+class UnsafeWorkspacePathError(OSError):
+ """A tenant-controlled path could not be opened without following links."""
+
+
+_DIRECTORY_FLAGS = (
+ os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
+)
+_FILE_READ_FLAGS = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
+_FILE_WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
+_MAX_REMOVAL_ENTRIES = 4096
+_MAX_REMOVAL_DEPTH = 16
+
+
+def _component(value: str) -> str:
+ normalized = str(value or '').strip()
+ if (
+ not normalized
+ or normalized in {'.', '..'}
+ or '/' in normalized
+ or '\\' in normalized
+ or '\x00' in normalized
+ or len(os.fsencode(normalized)) > 240
+ ):
+ raise UnsafeWorkspacePathError('Unsafe Workspace path component')
+ return normalized
+
+
+def _unsafe(path: str, exc: BaseException | None = None) -> UnsafeWorkspacePathError:
+ error = UnsafeWorkspacePathError(f'Workspace path is not a link-free directory: {path}')
+ if exc is not None:
+ error.__cause__ = exc
+ return error
+
+
+@contextlib.contextmanager
+def _root_fd(root: str):
+ try:
+ fd = os.open(root, _DIRECTORY_FLAGS)
+ except OSError as exc:
+ raise _unsafe(root, exc)
+ try:
+ if not stat.S_ISDIR(os.fstat(fd).st_mode):
+ raise _unsafe(root)
+ yield fd
+ finally:
+ os.close(fd)
+
+
+def _open_dir_at(parent_fd: int, name: str, *, create: bool) -> int:
+ name = _component(name)
+ if create:
+ try:
+ os.mkdir(name, mode=0o700, dir_fd=parent_fd)
+ except FileExistsError:
+ pass
+ try:
+ fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd)
+ except OSError as exc:
+ raise _unsafe(name, exc)
+ if not stat.S_ISDIR(os.fstat(fd).st_mode):
+ os.close(fd)
+ raise _unsafe(name)
+ return fd
+
+
+def _remove_entry(
+ parent_fd: int,
+ name: str,
+ *,
+ budget: list[int] | None = None,
+ depth: int = 0,
+) -> None:
+ """Remove an entry recursively without following a symlink at any depth."""
+
+ name = _component(name)
+ budget = budget if budget is not None else [_MAX_REMOVAL_ENTRIES]
+ if depth > _MAX_REMOVAL_DEPTH or budget[0] <= 0:
+ raise UnsafeWorkspacePathError('Workspace cleanup exceeded its inode budget')
+ budget[0] -= 1
+ for _ in range(4):
+ try:
+ child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd)
+ except FileNotFoundError:
+ return
+ except OSError as exc:
+ if exc.errno not in {errno.ELOOP, errno.ENOTDIR, errno.EACCES}:
+ raise
+ try:
+ os.unlink(name, dir_fd=parent_fd)
+ return
+ except FileNotFoundError:
+ return
+ except IsADirectoryError:
+ continue
+ else:
+ try:
+ _clear_dir(child_fd, budget=budget, depth=depth + 1)
+ finally:
+ os.close(child_fd)
+ try:
+ os.rmdir(name, dir_fd=parent_fd)
+ return
+ except FileNotFoundError:
+ return
+ except NotADirectoryError:
+ continue
+ raise UnsafeWorkspacePathError('Workspace entry changed while it was being removed')
+
+
+def _clear_dir(directory_fd: int, *, budget: list[int], depth: int) -> None:
+ # ``scandir(fd)`` enumerates the already-open directory. Names are then
+ # resolved relative to the same fd, so a tenant cannot redirect the walk by
+ # swapping an ancestor symlink between validation and use.
+ # Do not materialize the whole directory: an attacker-controlled outbox
+ # may contain an inode bomb even when its byte size is tiny. Removal is
+ # deliberately budgeted and fails closed once the per-operation cap is
+ # reached; hard filesystem/inode quota remains a Cloud readiness gate.
+ with os.scandir(directory_fd) as iterator:
+ for entry in iterator:
+ _remove_entry(directory_fd, entry.name, budget=budget, depth=depth)
+
+
+@contextlib.contextmanager
+def _query_fd(root: str, subdir: str, query_key: str, *, create: bool, reset: bool = False):
+ subdir = _component(subdir)
+ query_key = _component(query_key)
+ with _root_fd(root) as root_fd:
+ subdir_fd = _open_dir_at(root_fd, subdir, create=create)
+ try:
+ if reset:
+ _remove_entry(subdir_fd, query_key)
+ query_fd = _open_dir_at(subdir_fd, query_key, create=create)
+ try:
+ yield query_fd
+ finally:
+ os.close(query_fd)
+ finally:
+ os.close(subdir_fd)
+
+
+def write_files(
+ root: str,
+ subdir: str,
+ query_key: str,
+ files: Iterable[tuple[str, bytes]],
+) -> None:
+ """Atomically recreate one query directory and write regular files only."""
+
+ with _query_fd(root, subdir, query_key, create=True, reset=True) as query_fd:
+ for raw_name, data in files:
+ name = _component(raw_name)
+ try:
+ file_fd = os.open(name, _FILE_WRITE_FLAGS, 0o600, dir_fd=query_fd)
+ except OSError as exc:
+ raise UnsafeWorkspacePathError(f'Could not create a link-free Workspace file: {name}') from exc
+ with os.fdopen(file_fd, 'wb') as file_obj:
+ file_obj.write(data)
+
+
+def _read_directory(
+ directory_fd: int,
+ *,
+ prefix: str,
+ max_file_bytes: int,
+ max_files: int,
+ max_total_bytes: int,
+ output: list[tuple[str, bytes]],
+ total: list[int],
+ remaining_entries: list[int],
+ remaining_directories: list[int],
+ depth: int,
+) -> None:
+ if depth > 8:
+ return
+ with os.scandir(directory_fd) as iterator:
+ for entry in iterator:
+ if len(output) >= max_files or total[0] >= max_total_bytes:
+ return
+ if remaining_entries[0] <= 0:
+ raise UnsafeWorkspacePathError('Sandbox outbox exceeds the directory-entry limit')
+ remaining_entries[0] -= 1
+ name = _component(entry.name)
+ relative = f'{prefix}/{name}' if prefix else name
+ if entry.is_symlink():
+ continue
+ if entry.is_dir(follow_symlinks=False):
+ if remaining_directories[0] <= 0:
+ raise UnsafeWorkspacePathError('Sandbox outbox exceeds the directory limit')
+ remaining_directories[0] -= 1
+ try:
+ child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=directory_fd)
+ except OSError:
+ continue
+ try:
+ _read_directory(
+ child_fd,
+ prefix=relative,
+ max_file_bytes=max_file_bytes,
+ max_files=max_files,
+ max_total_bytes=max_total_bytes,
+ output=output,
+ total=total,
+ remaining_entries=remaining_entries,
+ remaining_directories=remaining_directories,
+ depth=depth + 1,
+ )
+ finally:
+ os.close(child_fd)
+ continue
+ try:
+ file_fd = os.open(name, _FILE_READ_FLAGS, dir_fd=directory_fd)
+ except OSError:
+ continue
+ try:
+ metadata = os.fstat(file_fd)
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > max_file_bytes:
+ continue
+ remaining = max_total_bytes - total[0]
+ if metadata.st_size > remaining:
+ continue
+ with os.fdopen(file_fd, 'rb', closefd=False) as file_obj:
+ data = file_obj.read(max_file_bytes + 1)
+ if len(data) > max_file_bytes or len(data) > remaining:
+ continue
+ output.append((relative, data))
+ total[0] += len(data)
+ finally:
+ os.close(file_fd)
+
+
+def read_regular_files(
+ root: str,
+ subdir: str,
+ query_key: str,
+ *,
+ max_file_bytes: int,
+ max_files: int,
+ max_total_bytes: int,
+ max_entries: int = 512,
+ max_directories: int = 64,
+) -> list[tuple[str, bytes]]:
+ """Read bounded regular files without following tenant-created links."""
+
+ output: list[tuple[str, bytes]] = []
+ try:
+ with _query_fd(root, subdir, query_key, create=False) as query_fd:
+ _read_directory(
+ query_fd,
+ prefix='',
+ max_file_bytes=max_file_bytes,
+ max_files=max_files,
+ max_total_bytes=max_total_bytes,
+ output=output,
+ total=[0],
+ remaining_entries=[max_entries],
+ remaining_directories=[max_directories],
+ depth=0,
+ )
+ except (FileNotFoundError, UnsafeWorkspacePathError):
+ # A missing directory is an empty outbox. An unsafe existing path is
+ # deliberately surfaced to the caller rather than followed.
+ if os.path.lexists(os.path.join(root, subdir, query_key)):
+ raise
+ return output
+
+
+def reset_directory(root: str, subdir: str, query_key: str) -> None:
+ with _query_fd(root, subdir, query_key, create=True, reset=True):
+ return
+
+
+def purge_subdirectory(root: str, subdir: str) -> None:
+ """Remove one known subtree without following a hostile replacement link."""
+
+ with _root_fd(root) as root_fd:
+ _remove_entry(root_fd, _component(subdir))
diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py
index 809a94130..56c8ae59b 100644
--- a/src/langbot/pkg/box/service.py
+++ b/src/langbot/pkg/box/service.py
@@ -5,16 +5,26 @@ import collections
import contextlib
import datetime as _dt
import enum
+import hashlib
import json
import os
+import secrets
from typing import TYPE_CHECKING
import pydantic
from langbot_plugin.box.client import BoxRuntimeClient
+from langbot_plugin.entities.io.context import ActionContext
+from langbot_plugin.box.tenancy import box_namespace
+from langbot_plugin.box.security import BOX_SHARED_WORKSPACE_PROBE_PREFIX
+from .admission import SandboxAdmissionController, require_cloud_admission_policy
from .connector import BoxRuntimeConnector, _get_box_config
+from . import secure_fs
from ..telemetry import features as telemetry_features
-from langbot_plugin.box.errors import BoxError, BoxValidationError
+from ..utils import httpclient
+from ..api.http.context import ExecutionContext
+from ..api.http.service.tenant import TenantContext, require_workspace_uuid
+from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidationError
from langbot_plugin.box.models import (
BUILTIN_PROFILES,
BoxExecutionResult,
@@ -28,6 +38,55 @@ _INT_ADAPTER = pydantic.TypeAdapter(int)
_UTC = _dt.timezone.utc
_MAX_RECENT_ERRORS = 50
_MIB = 1024 * 1024
+_DEFAULT_MAX_WORKSPACE_ENTRIES = 100_000
+_HARD_MAX_WORKSPACE_ENTRIES = 1_000_000
+
+
+def _create_shared_workspace_probe(root: str, marker_name: str, payload: bytes) -> None:
+ """Create and durably flush a no-follow probe without blocking the event loop."""
+
+ directory_flags = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0)
+ nofollow = getattr(os, 'O_NOFOLLOW', 0)
+ root_fd: int | None = None
+ marker_fd: int | None = None
+ marker_created = False
+ try:
+ root_fd = os.open(root, directory_flags | nofollow)
+ marker_fd = os.open(
+ marker_name,
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | nofollow,
+ 0o600,
+ dir_fd=root_fd,
+ )
+ marker_created = True
+ remaining = memoryview(payload)
+ while remaining:
+ written = os.write(marker_fd, remaining)
+ if written <= 0:
+ raise BoxValidationError('Failed to write Cloud Box shared-volume probe')
+ remaining = remaining[written:]
+ os.fsync(marker_fd)
+ except Exception:
+ if root_fd is not None and marker_created:
+ with contextlib.suppress(FileNotFoundError):
+ os.unlink(marker_name, dir_fd=root_fd)
+ raise
+ finally:
+ if marker_fd is not None:
+ os.close(marker_fd)
+ if root_fd is not None:
+ os.close(root_fd)
+
+
+def _remove_shared_workspace_probe(root: str, marker_name: str) -> None:
+ directory_flags = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0)
+ nofollow = getattr(os, 'O_NOFOLLOW', 0)
+ root_fd = os.open(root, directory_flags | nofollow)
+ try:
+ with contextlib.suppress(FileNotFoundError):
+ os.unlink(marker_name, dir_fd=root_fd)
+ finally:
+ os.close(root_fd)
def _is_path_under(path: str, root: str) -> bool:
@@ -48,6 +107,7 @@ class BoxService:
output_limit_chars: int = 4000,
):
self.ap = ap
+ self._cloud_managed = bool(getattr(getattr(ap, 'deployment', None), 'multi_workspace_enabled', False))
self._enabled = self._load_enabled()
self._runtime_connector: BoxRuntimeConnector | None = None
if client is None:
@@ -64,6 +124,18 @@ class BoxService:
self.profile = self._load_profile()
self.custom_image = self._load_custom_image()
self.workspace_quota_mb = self._load_workspace_quota_mb()
+ self._admission_policy = (
+ require_cloud_admission_policy(_get_box_config(ap).get('admission')) if self._cloud_managed else None
+ )
+ self._admission = (
+ SandboxAdmissionController(
+ ap,
+ self.client,
+ policy=self._admission_policy,
+ )
+ if self._cloud_managed and self._admission_policy is not None
+ else None
+ )
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
self._shutdown_task = None
self._reconnect_task: asyncio.Task | None = None
@@ -84,6 +156,10 @@ class BoxService:
``available = False`` to consumers, but distinguished in get_status."""
return self._enabled
+ @property
+ def managed_admission_required(self) -> bool:
+ return self._cloud_managed
+
async def initialize(self):
if not self._enabled:
# Disabled by config: do NOT connect to a remote runtime, do NOT
@@ -102,19 +178,54 @@ class BoxService:
else:
await self.client.initialize()
self._ensure_default_workspace()
+ await self._verify_cloud_runtime()
self._available = True
self._connector_error = ''
self.ap.logger.info(
f'LangBot Box runtime initialized: profile={self.profile.name} '
f'default_workspace={self.default_workspace or "(none)"}'
)
- await self._purge_attachment_dirs()
+ # Cloud query directories use globally opaque query UUIDs. Never
+ # sweep all tenants when a future replica joins the same logical
+ # instance; that could delete another replica's in-flight files.
+ if not self._cloud_managed:
+ await self._purge_attachment_dirs()
except Exception as exc:
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
self._available = False
self._connector_error = str(exc)
- if self._runtime_connector is not None:
- await self._on_runtime_disconnect(self._runtime_connector)
+ if self._cloud_managed:
+ await self._abort_failed_cloud_initialization()
+ raise
+
+ async def _abort_failed_cloud_initialization(self) -> None:
+ """Close a connected Cloud transport before propagating readiness failure.
+
+ Connector initialization starts control and heartbeat tasks before Core
+ performs the stricter Cloud readiness challenge. If that challenge
+ fails, startup must remain fail-closed without leaving those tasks free
+ to schedule reconnect work while the application loop is unwinding.
+ """
+
+ self._closing = True
+ self._available = False
+ reconnect_task = self._reconnect_task
+ self._reconnect_task = None
+ self._reconnecting = False
+ if reconnect_task is not None and reconnect_task is not asyncio.current_task():
+ reconnect_task.cancel()
+ await asyncio.gather(reconnect_task, return_exceptions=True)
+
+ connector = self._runtime_connector
+ if connector is None:
+ return
+ connector.runtime_disconnect_callback = None
+ try:
+ await connector.aclose()
+ except Exception:
+ # Cleanup failure must not replace the readiness error which caused
+ # Cloud startup to fail closed.
+ self.ap.logger.exception('Failed to close Box runtime after Cloud readiness validation failed')
async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None:
"""Called by the connector when the Box runtime connection drops.
@@ -125,13 +236,28 @@ class BoxService:
"""
if not self._enabled or self._closing:
return
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ return
+ if loop.is_closed():
+ return
if self._reconnect_task is not None and not self._reconnect_task.done():
return # Another reconnect loop is already running
self._reconnecting = True
self._available = False
self._connector_error = 'Disconnected from Box runtime'
self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.')
- self._reconnect_task = asyncio.create_task(self._reconnect_loop(connector))
+ reconnect = self._reconnect_loop(connector)
+ try:
+ self._reconnect_task = loop.create_task(reconnect)
+ except RuntimeError:
+ # The loop may begin closing between get_running_loop() and task
+ # creation. Explicitly close the coroutine so shutdown emits no
+ # "coroutine was never awaited" warning.
+ reconnect.close()
+ self._reconnecting = False
+ self._reconnect_task = None
async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None:
"""Retry reconnection with exponential backoff (3s → 60s max)."""
@@ -144,12 +270,14 @@ class BoxService:
try:
await connector.reconnect()
self._ensure_default_workspace()
- await self._purge_attachment_dirs()
+ await self._verify_cloud_runtime()
+ if not self._cloud_managed:
+ await self._purge_attachment_dirs()
self._available = True
self._connector_error = ''
skill_mgr = getattr(self.ap, 'skill_mgr', None)
reload_skills = getattr(skill_mgr, 'reload_skills', None)
- if callable(reload_skills):
+ if callable(reload_skills) and not self._cloud_managed:
await reload_skills()
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
return
@@ -161,6 +289,66 @@ class BoxService:
self._reconnecting = False
self._reconnect_task = None
+ async def _verify_cloud_runtime(self) -> None:
+ if not self._cloud_managed:
+ return
+ self._ensure_cloud_shared_workspace()
+ await self._challenge_cloud_shared_workspace()
+ backend_info = await self.client.get_backend_info()
+ if (
+ not isinstance(backend_info, dict)
+ or backend_info.get('name') != 'nsjail'
+ or backend_info.get('available') is not True
+ ):
+ raise BoxValidationError('Cloud Box nsjail isolation readiness failed')
+
+ async def _challenge_cloud_shared_workspace(self) -> None:
+ """Prove Core and Box Runtime see the same durable filesystem.
+
+ Equal configured path strings are not evidence of a shared container
+ volume. Core creates one high-entropy, no-follow marker under its
+ canonical root and the authenticated Runtime host-control action reads
+ that basename only. Any mismatch fails Cloud startup/reconnect closed.
+ """
+
+ if self.default_workspace is None:
+ raise BoxValidationError('Cloud Box shared default_workspace is unavailable')
+
+ marker_name = f'{BOX_SHARED_WORKSPACE_PROBE_PREFIX}{secrets.token_hex(16)}'
+ marker_payload = secrets.token_bytes(64)
+ expected_digest = hashlib.sha256(marker_payload).hexdigest()
+ probe_created = False
+ try:
+ await asyncio.to_thread(
+ _create_shared_workspace_probe,
+ self.default_workspace,
+ marker_name,
+ marker_payload,
+ )
+ probe_created = True
+
+ result = await self.client.verify_shared_workspace(marker_name)
+ if (
+ not isinstance(result, dict)
+ or result.get('marker_name') != marker_name
+ or result.get('size') != len(marker_payload)
+ or not secrets.compare_digest(str(result.get('sha256') or ''), expected_digest)
+ ):
+ raise BoxValidationError(
+ 'Cloud Box Core and Runtime do not share the configured durable Workspace volume'
+ )
+ except BoxValidationError:
+ raise
+ except Exception as exc:
+ raise BoxValidationError('Cloud Box shared durable Workspace volume verification failed') from exc
+ finally:
+ if probe_created:
+ await asyncio.to_thread(
+ _remove_shared_workspace_probe,
+ self.default_workspace,
+ marker_name,
+ )
+
@property
def available(self) -> bool:
return self._available
@@ -191,6 +379,184 @@ class BoxService:
return False
return not self._runtime_connector.uses_websocket()
+ @staticmethod
+ def _execution_context(context: TenantContext) -> ExecutionContext:
+ workspace_uuid = require_workspace_uuid(context)
+ instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
+ generation = getattr(context, 'placement_generation', None)
+ if not instance_uuid:
+ raise BoxValidationError('Box operations require an explicit instance UUID')
+ if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
+ raise BoxValidationError('Box operations require a positive placement generation')
+ return ExecutionContext(
+ instance_uuid=instance_uuid,
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ bot_uuid=getattr(context, 'bot_uuid', None),
+ pipeline_uuid=getattr(context, 'pipeline_uuid', None),
+ query_uuid=getattr(context, 'query_uuid', None),
+ entitlement_revision=getattr(context, 'entitlement_revision', 0),
+ )
+
+ @classmethod
+ def _query_execution_context(cls, query: pipeline_query.Query) -> ExecutionContext:
+ attached_context = getattr(query, '_execution_context', None)
+ if isinstance(attached_context, ExecutionContext):
+ return cls._execution_context(attached_context)
+ return cls._execution_context(
+ ExecutionContext(
+ instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
+ workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
+ placement_generation=getattr(query, 'placement_generation', 0) or 0,
+ bot_uuid=getattr(query, 'bot_uuid', None),
+ pipeline_uuid=getattr(query, 'pipeline_uuid', None),
+ query_uuid=getattr(query, 'query_uuid', None),
+ entitlement_revision=getattr(query, 'entitlement_revision', 0),
+ )
+ )
+
+ @classmethod
+ def _action_context(cls, context: TenantContext) -> ActionContext:
+ execution_context = cls._execution_context(context)
+ return ActionContext(
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ )
+
+ async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
+ """Resolve and fence a tenant context before touching shared Box state."""
+
+ execution_context = self._execution_context(context)
+ binding = await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise BoxValidationError('Box execution context belongs to another LangBot instance')
+ if (
+ str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
+ or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
+ ):
+ raise BoxValidationError('Box execution context belongs to a stale Workspace placement')
+ return execution_context
+
+ async def require_workspace_sandbox(self, context: TenantContext) -> ExecutionContext:
+ """Fence a Workspace and install its short-lived Cloud admission grant.
+
+ OSS keeps the existing singleton behavior and does not require a
+ Control Plane entitlement. Cloud always resolves a fresh generic
+ entitlement before a sandbox-visible operation.
+ """
+
+ execution_context = await self._validated_execution_context(context)
+ await self._require_validated_workspace_sandbox(execution_context)
+ return execution_context
+
+ async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
+ if not self._available:
+ raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
+ if self._cloud_managed:
+ if self._admission is None:
+ raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
+ await self._admission.require(execution_context)
+
+ async def is_workspace_sandbox_available(self, context: TenantContext) -> bool:
+ """Return tenant-specific availability for UI and tool discovery.
+
+ This method deliberately catches entitlement failures so callers can
+ hide tools without leaking plan details. Direct execution APIs use
+ :meth:`require_workspace_sandbox` and retain an explicit failure.
+ """
+
+ if not self._available:
+ return False
+ try:
+ await self.require_workspace_sandbox(context)
+ return True
+ except Exception:
+ return False
+
+ def _managed_policy_payload(
+ self,
+ context: TenantContext,
+ spec_payload: dict,
+ ) -> dict:
+ """Reject tenant-owned policy fields and apply the Cloud hard policy."""
+
+ payload = dict(spec_payload)
+ if not self._cloud_managed:
+ return payload
+ policy = self._admission_policy
+ if policy is None:
+ raise BoxAdmissionError('Cloud Box sandbox admission policy is unavailable')
+
+ forged_fields = {
+ 'plan',
+ 'subscription',
+ 'managed_sandbox',
+ 'entitlement',
+ 'entitlement_revision',
+ 'max_sessions',
+ 'max_managed_processes',
+ 'backend',
+ }
+ submitted_forged_fields = sorted(forged_fields.intersection(payload))
+ if submitted_forged_fields:
+ raise BoxAdmissionError(
+ 'Managed sandbox policy fields are host-controlled: ' + ', '.join(submitted_forged_fields)
+ )
+
+ submitted_session_id = str(payload.get('session_id', '') or '').strip()
+ if submitted_session_id and submitted_session_id != policy.logical_session_id:
+ raise BoxAdmissionError('Managed sandbox session_id is runtime-owned')
+ submitted_network = str(getattr(payload.get('network'), 'value', payload.get('network', 'off')) or 'off')
+ if submitted_network != 'off':
+ raise BoxAdmissionError('Managed sandbox network access is disabled')
+ if payload.get('extra_mounts'):
+ raise BoxAdmissionError('Managed sandbox additional host mounts are disabled')
+ submitted_mount_path = str(payload.get('mount_path', '/workspace') or '/workspace')
+ if submitted_mount_path != '/workspace':
+ raise BoxAdmissionError('Managed sandbox mount_path is runtime-owned')
+
+ canonical_host_path = self._tenant_workspace(context)
+ if canonical_host_path is None:
+ raise BoxAdmissionError('Managed sandbox Workspace path is unavailable')
+ submitted_host_path = str(payload.get('host_path', '') or '').strip()
+ if submitted_host_path and os.path.realpath(submitted_host_path) != os.path.realpath(canonical_host_path):
+ raise BoxAdmissionError('Managed sandbox host_path is runtime-owned')
+
+ timeout = payload.get('timeout_sec', policy.max_timeout_sec)
+ if isinstance(timeout, bool) or not isinstance(timeout, int):
+ raise BoxValidationError('timeout_sec must be an integer')
+ payload.update(
+ {
+ 'session_id': policy.logical_session_id,
+ 'network': 'off',
+ 'host_path': canonical_host_path,
+ 'mount_path': '/workspace',
+ 'extra_mounts': [],
+ 'persistent': True,
+ 'timeout_sec': min(timeout, policy.max_timeout_sec),
+ 'cpus': policy.cpus,
+ 'memory_mb': policy.memory_mb,
+ 'pids_limit': policy.pids_limit,
+ 'read_only_rootfs': policy.read_only_rootfs,
+ 'workspace_quota_mb': policy.workspace_quota_mb,
+ }
+ )
+ return payload
+
+ def _reject_cloud_managed_process(self) -> None:
+ if self._cloud_managed:
+ raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
+
+ def _tenant_workspace(self, context: TenantContext) -> str | None:
+ if self.default_workspace is None:
+ return None
+ namespace = box_namespace(self._action_context(context))
+ return os.path.join(self.default_workspace, 'tenants', namespace)
+
async def execute_spec_payload(
self,
spec_payload: dict,
@@ -200,6 +566,15 @@ class BoxService:
) -> dict:
if not self._available:
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
+ execution_context = await self._validated_execution_context(self._query_execution_context(query))
+ spec_payload = self._managed_policy_payload(execution_context, spec_payload)
+ await self._require_validated_workspace_sandbox(execution_context)
+ if spec_payload.get('host_path') in (None, ''):
+ tenant_workspace = self._tenant_workspace(execution_context)
+ if tenant_workspace is not None:
+ spec_payload['host_path'] = tenant_workspace
+ if self.shares_filesystem_with_box:
+ os.makedirs(tenant_workspace, exist_ok=True)
try:
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
except BoxError as exc:
@@ -216,14 +591,22 @@ class BoxService:
self._record_error(exc, query)
raise
try:
- result = await self.client.execute(spec)
+ result = await self.client.execute(
+ spec,
+ action_context=self._action_context(execution_context),
+ )
+ # A placement may be cut over while a long-running sandbox call is
+ # in flight. Never accept a result produced by the superseded
+ # generation. Runtime-side generation fencing prevents new work;
+ # this second Core check closes the response race.
+ await self._validated_execution_context(execution_context)
except BoxError as exc:
self._record_error(exc, query)
raise
try:
await self._enforce_workspace_quota(spec, phase='after execution')
except BoxError as exc:
- await self._cleanup_exceeded_session(spec)
+ await self._cleanup_exceeded_session(execution_context, spec)
self._record_error(exc, query)
raise
self.ap.logger.info(
@@ -244,6 +627,8 @@ class BoxService:
by editing the pipeline config directly through the API (which only
gates the web UI).
"""
+ if self._cloud_managed:
+ return 'global'
forced_template = self._forced_box_session_id_template()
if forced_template:
template = forced_template
@@ -295,6 +680,8 @@ class BoxService:
skills it discovered on its own filesystem, so the path is valid there
by construction.
"""
+ if self._cloud_managed:
+ return []
skill_mgr = getattr(self.ap, 'skill_mgr', None)
if skill_mgr is None:
return []
@@ -325,13 +712,21 @@ class BoxService:
)
return mounts
- async def execute_tool(self, parameters: dict, query: pipeline_query.Query) -> dict:
+ async def execute_tool(
+ self,
+ parameters: dict,
+ query: pipeline_query.Query,
+ *,
+ skill_name: str | None = None,
+ ) -> dict:
"""Execute an agent-facing ``exec`` tool call.
Translates the agent-facing ``command`` field to the internal
``BoxSpec.cmd`` field and injects the session id from the query.
"""
spec_payload: dict = {'cmd': parameters['command']}
+ if skill_name is not None:
+ spec_payload['skill_name'] = skill_name
# Pass through allowed agent-facing fields
for key in ('workdir', 'timeout_sec', 'env'):
@@ -347,6 +742,29 @@ class BoxService:
return await self.execute_spec_payload(spec_payload, query)
+ async def execute_in_context(
+ self,
+ context: TenantContext,
+ spec_payload: dict,
+ *,
+ skip_host_mount_validation: bool = False,
+ ) -> BoxExecutionResult:
+ """Execute trusted internal Box work inside one Workspace namespace."""
+
+ execution_context = await self._validated_execution_context(context)
+ payload = self._managed_policy_payload(execution_context, spec_payload)
+ await self._require_validated_workspace_sandbox(execution_context)
+ if payload.get('host_path') in (None, ''):
+ tenant_workspace = self._tenant_workspace(execution_context)
+ if tenant_workspace is not None:
+ payload['host_path'] = tenant_workspace
+ if self.shares_filesystem_with_box:
+ os.makedirs(tenant_workspace, exist_ok=True)
+ spec = self.build_spec(payload, skip_host_mount_validation=skip_host_mount_validation)
+ result = await self.client.execute(spec, action_context=self._action_context(execution_context))
+ await self._validated_execution_context(execution_context)
+ return result
+
# ── Attachment passthrough (inbound / outbound) ──────────────────
#
# IM/webchat attachments (images, voices, files) reach the LLM as
@@ -375,11 +793,23 @@ class BoxService:
# Hard cap on a single attachment. The HTTP upload endpoints already cap
# uploads at 10MiB; keep parity.
_ATTACHMENT_MAX_BYTES = 10 * _MIB
+ _ATTACHMENT_MAX_FILES = 20
+ _ATTACHMENT_MAX_TOTAL_BYTES = 50 * _MIB
# Conservative cap for the exec FALLBACK path only (ARG_MAX / stdout
# truncation). The host-filesystem path has no such limit.
_EXEC_FALLBACK_MAX_BYTES = 256 * 1024
- def _host_query_dir(self, subdir: str, query_id) -> str | None:
+ def _attachment_query_key(self, query: pipeline_query.Query) -> str:
+ query_uuid = str(getattr(query, 'query_uuid', '') or '').strip()
+ if query_uuid:
+ if query_uuid in {'.', '..'} or '/' in query_uuid or '\\' in query_uuid or '\x00' in query_uuid:
+ raise BoxValidationError('Query attachment identity is invalid')
+ return query_uuid
+ if self._cloud_managed:
+ raise BoxValidationError('Cloud attachment transfer requires query_uuid')
+ return str(query.query_id)
+
+ def _host_query_dir(self, subdir: str, query: pipeline_query.Query) -> str | None:
"""Host path for ``/workspace//`` when LangBot can
access the bind-mounted workspace directly, else ``None``.
@@ -389,10 +819,10 @@ class BoxService:
to the sandbox (and vice-versa). It is ``None`` / not a local dir for
E2B and remote runtimes, where we must fall back to the exec channel.
"""
- root = self.default_workspace
- if not root or not os.path.isdir(root):
+ root = self._tenant_workspace(self._query_execution_context(query))
+ if not root or not os.path.isdir(root) or os.path.islink(root):
return None
- return os.path.join(root, subdir, str(query_id))
+ return os.path.join(root, subdir, self._attachment_query_key(query))
async def _purge_attachment_dirs(self) -> None:
"""Remove leftover inbox/outbox directories on startup.
@@ -402,30 +832,42 @@ class BoxService:
a previous process would otherwise be silently reused — leaking a prior
run's inbound files and re-sending stale outbound files.
- Outbox files are written by the sandbox **container**, which runs as
- root over the bind-mount, so the LangBot host process (a non-root user)
- cannot ``rmtree`` them. We therefore try a host-side delete first (fast,
- works for host-owned inbox files) and, for anything that survives,
- delete from *inside* the sandbox via exec where the container's root can
- remove its own files. Best-effort: never block startup.
+ Tenant workspaces live below ``default_workspace/tenants``. Startup has
+ no authenticated Workspace context, so cleanup is deliberately limited
+ to direct host-filesystem deletion. It must never issue an unscoped Box
+ exec merely to remove root-owned container output.
"""
root = self.default_workspace
if not root or not os.path.isdir(root):
return
- import shutil
-
host_survivors: list[str] = []
def _host_purge() -> list[str]:
+ candidates: list[tuple[str, str]] = [
+ (root, self.INBOX_SUBDIR),
+ (root, self.OUTBOX_SUBDIR),
+ ]
+ tenants_root = os.path.join(root, 'tenants')
+ if os.path.isdir(tenants_root):
+ with os.scandir(tenants_root) as tenant_entries:
+ for tenant_entry in tenant_entries:
+ if not tenant_entry.is_dir(follow_symlinks=False):
+ continue
+ candidates.extend(
+ [
+ (tenant_entry.path, self.INBOX_SUBDIR),
+ (tenant_entry.path, self.OUTBOX_SUBDIR),
+ ]
+ )
survivors: list[str] = []
- for subdir in (self.INBOX_SUBDIR, self.OUTBOX_SUBDIR):
- path = os.path.join(root, subdir)
- if not os.path.isdir(path):
- continue
- shutil.rmtree(path, ignore_errors=True)
- if os.path.exists(path):
- survivors.append(subdir)
+ for candidate_root, subdir in candidates:
+ path = os.path.join(candidate_root, subdir)
+ try:
+ secure_fs.purge_subdirectory(candidate_root, subdir)
+ except OSError:
+ if os.path.lexists(path):
+ survivors.append(path)
return survivors
try:
@@ -438,18 +880,11 @@ class BoxService:
self.ap.logger.info('Purged leftover sandbox attachment dirs from a previous process.')
return
- # Root-owned leftovers (container output): delete from inside the box.
- targets = ' '.join(f'/workspace/{sub}' for sub in host_survivors)
- try:
- spec = self.build_spec({'cmd': f'rm -rf {targets}', 'session_id': '__startup_purge__', 'timeout_sec': 30})
- await self.client.execute(spec)
- self.ap.logger.info(
- f'Purged root-owned leftover sandbox attachment dirs via sandbox exec: {host_survivors}'
- )
- except Exception as exc:
- self.ap.logger.warning(
- f'Failed to purge root-owned sandbox attachment dirs {host_survivors} via exec: {exc}'
- )
+ self.ap.logger.warning(
+ 'Could not purge root-owned sandbox attachment directories from the host; '
+ 'skipping an unsafe unscoped Box exec because startup has no trusted '
+ f'Workspace context: {host_survivors}'
+ )
@staticmethod
def _sanitize_attachment_name(name: str, fallback: str) -> str:
@@ -484,7 +919,13 @@ class BoxService:
mime = data[5:split_index]
data = data[split_index + 8 :]
try:
- return _b64.b64decode(data), mime
+ max_encoded_bytes = 4 * ((BoxService._ATTACHMENT_MAX_BYTES + 2) // 3)
+ if not isinstance(data, (str, bytes)) or len(data) > max_encoded_bytes:
+ return None
+ decoded = _b64.b64decode(data)
+ if len(decoded) > BoxService._ATTACHMENT_MAX_BYTES:
+ return None
+ return decoded, mime
except Exception:
return None
@@ -493,10 +934,25 @@ class BoxService:
try:
import httpx
- async with httpx.AsyncClient(timeout=30) as client:
- resp = await client.get(url)
- resp.raise_for_status()
- return resp.content, resp.headers.get('Content-Type', 'application/octet-stream')
+ async with httpx.AsyncClient(
+ timeout=30,
+ event_hooks=httpclient.httpx_response_limit_hooks(BoxService._ATTACHMENT_MAX_BYTES),
+ ) as client:
+ async with client.stream('GET', url) as resp:
+ resp.raise_for_status()
+ declared_size = resp.headers.get('content-length')
+ if declared_size is not None:
+ try:
+ if int(declared_size) > BoxService._ATTACHMENT_MAX_BYTES:
+ return None
+ except ValueError:
+ pass
+ body = bytearray()
+ async for chunk in resp.aiter_bytes(chunk_size=64 * 1024):
+ body.extend(chunk)
+ if len(body) > BoxService._ATTACHMENT_MAX_BYTES:
+ return None
+ return bytes(body), resp.headers.get('Content-Type', 'application/octet-stream')
except Exception:
return None
@@ -505,8 +961,13 @@ class BoxService:
try:
import aiofiles
+ if await asyncio.to_thread(os.path.getsize, path) > BoxService._ATTACHMENT_MAX_BYTES:
+ return None
async with aiofiles.open(path, 'rb') as f:
- return await f.read(), 'application/octet-stream'
+ data = await f.read(BoxService._ATTACHMENT_MAX_BYTES + 1)
+ if len(data) > BoxService._ATTACHMENT_MAX_BYTES:
+ return None
+ return data, 'application/octet-stream'
except Exception:
return None
@@ -529,7 +990,7 @@ class BoxService:
if not files:
return []
- host_dir = self._host_query_dir(subdir, query.query_id)
+ host_dir = self._host_query_dir(subdir, query)
if host_dir is not None:
return await asyncio.to_thread(self._write_files_host, host_dir, target_mount_dir, files)
@@ -547,14 +1008,15 @@ class BoxService:
(the webchat session uses small sequential ids) never inherits stale
files from an earlier turn.
"""
- import shutil
-
- shutil.rmtree(host_dir, ignore_errors=True)
- os.makedirs(host_dir, exist_ok=True)
+ query_key = os.path.basename(host_dir)
+ subdir = os.path.basename(os.path.dirname(host_dir))
+ tenant_root = os.path.dirname(os.path.dirname(host_dir))
+ try:
+ secure_fs.write_files(tenant_root, subdir, query_key, files)
+ except secure_fs.UnsafeWorkspacePathError as exc:
+ raise BoxValidationError('Sandbox attachment path contains an unsafe symbolic link') from exc
written: list[str] = []
- for name, data in files:
- with open(os.path.join(host_dir, name), 'wb') as fh:
- fh.write(data)
+ for name, _data in files:
written.append(f'{target_mount_dir}/{name}')
return written
@@ -625,6 +1087,8 @@ class BoxService:
"""
if not self._available:
return []
+ if self._cloud_managed:
+ await self.require_workspace_sandbox(self._query_execution_context(query))
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -673,7 +1137,8 @@ class BoxService:
if not pending:
return []
- target_dir = f'{self.INBOX_MOUNT_DIR}/{query.query_id}'
+ query_key = self._attachment_query_key(query)
+ target_dir = f'{self.INBOX_MOUNT_DIR}/{query_key}'
written = await self._write_files_into_sandbox(query, self.INBOX_SUBDIR, target_dir, pending)
written_basenames = {os.path.basename(p) for p in written}
@@ -701,8 +1166,10 @@ class BoxService:
"""
if not self._available:
return []
+ if self._cloud_managed:
+ await self.require_workspace_sandbox(self._query_execution_context(query))
- host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query.query_id)
+ host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query)
if host_dir is not None:
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
else:
@@ -724,22 +1191,21 @@ class BoxService:
"""Read outbox files straight off the bind-mounted host directory."""
import base64 as _b64
- entries: list[dict] = []
- if not os.path.isdir(host_dir):
- return entries
- for root, _dirs, names in os.walk(host_dir):
- for name in sorted(names):
- path = os.path.join(root, name)
- try:
- if os.path.getsize(path) > self._ATTACHMENT_MAX_BYTES:
- continue
- with open(path, 'rb') as fh:
- data = fh.read()
- except OSError:
- continue
- rel = os.path.relpath(path, host_dir)
- entries.append({'name': rel, 'b64': _b64.b64encode(data).decode('ascii')})
- return entries
+ query_key = os.path.basename(host_dir)
+ subdir = os.path.basename(os.path.dirname(host_dir))
+ tenant_root = os.path.dirname(os.path.dirname(host_dir))
+ try:
+ files = secure_fs.read_regular_files(
+ tenant_root,
+ subdir,
+ query_key,
+ max_file_bytes=self._ATTACHMENT_MAX_BYTES,
+ max_files=self._ATTACHMENT_MAX_FILES,
+ max_total_bytes=self._ATTACHMENT_MAX_TOTAL_BYTES,
+ )
+ except secure_fs.UnsafeWorkspacePathError as exc:
+ raise BoxValidationError('Sandbox outbox contains an unsafe symbolic link') from exc
+ return [{'name': name, 'b64': _b64.b64encode(data).decode('ascii')} for name, data in files]
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
"""Fallback: read the outbox over the exec channel (E2B / remote).
@@ -749,26 +1215,54 @@ class BoxService:
"""
import json as _json
- target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}'
- max_bytes = self._EXEC_FALLBACK_MAX_BYTES
+ target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}'
+ max_file_bytes = self._EXEC_FALLBACK_MAX_BYTES
+ max_files = self._ATTACHMENT_MAX_FILES
+ max_total_bytes = max_file_bytes * max_files
+ max_scan_entries = 1000
script = (
'import base64, json, os\n'
f'target = {target_dir!r}\n'
- f'max_bytes = {max_bytes}\n'
+ f'max_file_bytes = {max_file_bytes}\n'
+ f'max_files = {max_files}\n'
+ f'max_total_bytes = {max_total_bytes}\n'
+ f'max_scan_entries = {max_scan_entries}\n'
'out = []\n'
+ 'total_bytes = 0\n'
+ 'scanned_entries = 0\n'
+ 'stack = [target]\n'
'if os.path.isdir(target):\n'
- ' for root, _dirs, names in os.walk(target):\n'
- ' for n in sorted(names):\n'
- ' p = os.path.join(root, n)\n'
+ ' while stack and len(out) < max_files and scanned_entries < max_scan_entries:\n'
+ ' current = stack.pop()\n'
+ ' try:\n'
+ ' with os.scandir(current) as iterator:\n'
+ ' entries = sorted(iterator, key=lambda item: item.name, reverse=True)\n'
+ ' except OSError:\n'
+ ' continue\n'
+ ' for entry in entries:\n'
+ ' scanned_entries += 1\n'
+ ' if scanned_entries > max_scan_entries:\n'
+ ' break\n'
' try:\n'
- ' if os.path.getsize(p) > max_bytes:\n'
+ ' if entry.is_dir(follow_symlinks=False):\n'
+ ' stack.append(entry.path)\n'
+ ' continue\n'
+ ' if not entry.is_file(follow_symlinks=False):\n'
+ ' continue\n'
+ ' size = entry.stat(follow_symlinks=False).st_size\n'
+ ' if size > max_file_bytes or total_bytes + size > max_total_bytes:\n'
+ ' continue\n'
+ " with open(entry.path, 'rb') as f:\n"
+ ' data = f.read(max_file_bytes + 1)\n'
+ ' if len(data) > max_file_bytes or total_bytes + len(data) > max_total_bytes:\n'
' continue\n'
- " with open(p, 'rb') as f:\n"
- ' data = f.read()\n'
' except OSError:\n'
' continue\n'
- ' rel = os.path.relpath(p, target)\n'
+ ' rel = os.path.relpath(entry.path, target)\n'
" out.append({'name': rel, 'b64': base64.b64encode(data).decode('ascii')})\n"
+ ' total_bytes += len(data)\n'
+ ' if len(out) >= max_files:\n'
+ ' break\n'
'print(json.dumps(out))\n'
)
result = await self.execute_tool(
@@ -794,16 +1288,19 @@ class BoxService:
container's root can remove its own files. Best-effort: never raise
into the pipeline.
"""
- target_dir = f'{self.OUTBOX_MOUNT_DIR}/{query.query_id}'
+ target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}'
if host_dir is not None:
- import shutil
def _clear() -> bool:
- shutil.rmtree(host_dir, ignore_errors=True)
- survived = os.path.exists(host_dir) and bool(os.listdir(host_dir))
- os.makedirs(host_dir, exist_ok=True)
- return survived
+ query_key = os.path.basename(host_dir)
+ subdir = os.path.basename(os.path.dirname(host_dir))
+ tenant_root = os.path.dirname(os.path.dirname(host_dir))
+ try:
+ secure_fs.reset_directory(tenant_root, subdir, query_key)
+ return False
+ except OSError:
+ return True
survived = await asyncio.to_thread(_clear)
if not survived:
@@ -872,11 +1369,12 @@ class BoxService:
elif self._runtime_connector is not None:
self._runtime_connector.dispose()
- async def get_sessions(self) -> list[dict]:
+ async def get_sessions(self, context: TenantContext) -> list[dict]:
if not self._available:
return []
+ execution_context = await self.require_workspace_sandbox(context)
try:
- return await self.client.get_sessions()
+ return await self.client.get_sessions(action_context=self._action_context(execution_context))
except Exception:
return []
@@ -904,21 +1402,74 @@ class BoxService:
self._validate_host_mount(spec)
return spec
- async def create_session(self, spec_payload: dict, *, skip_host_mount_validation: bool = False) -> dict:
+ async def create_session(
+ self,
+ context: TenantContext,
+ spec_payload: dict,
+ *,
+ skip_host_mount_validation: bool = False,
+ ) -> dict:
+ execution_context = await self._validated_execution_context(context)
+ spec_payload = self._managed_policy_payload(execution_context, spec_payload)
+ await self._require_validated_workspace_sandbox(execution_context)
+ if spec_payload.get('host_path') in (None, ''):
+ tenant_workspace = self._tenant_workspace(execution_context)
+ if tenant_workspace is not None:
+ spec_payload['host_path'] = tenant_workspace
+ if self.shares_filesystem_with_box:
+ os.makedirs(tenant_workspace, exist_ok=True)
spec = self.build_spec(spec_payload, skip_host_mount_validation=skip_host_mount_validation)
- return await self.client.create_session(spec)
+ return await self.client.create_session(spec, action_context=self._action_context(execution_context))
- async def start_managed_process(self, session_id: str, process_payload: dict) -> BoxManagedProcessInfo:
+ async def start_managed_process(
+ self,
+ context: TenantContext,
+ session_id: str,
+ process_payload: dict,
+ ) -> BoxManagedProcessInfo:
+ self._reject_cloud_managed_process()
+ execution_context = await self._validated_execution_context(context)
process_spec = BoxManagedProcessSpec.model_validate(process_payload)
- return await self.client.start_managed_process(session_id, process_spec)
+ return await self.client.start_managed_process(
+ session_id,
+ process_spec,
+ action_context=self._action_context(execution_context),
+ )
- async def get_managed_process(self, session_id: str, process_id: str = 'default') -> BoxManagedProcessInfo:
- return await self.client.get_managed_process(session_id, process_id)
+ async def get_managed_process(
+ self,
+ context: TenantContext,
+ session_id: str,
+ process_id: str = 'default',
+ ) -> BoxManagedProcessInfo:
+ self._reject_cloud_managed_process()
+ execution_context = await self._validated_execution_context(context)
+ return await self.client.get_managed_process(
+ session_id,
+ process_id,
+ action_context=self._action_context(execution_context),
+ )
- async def stop_managed_process(self, session_id: str, process_id: str = 'default') -> None:
- return await self.client.stop_managed_process(session_id, process_id)
+ async def stop_managed_process(
+ self,
+ context: TenantContext,
+ session_id: str,
+ process_id: str = 'default',
+ ) -> None:
+ self._reject_cloud_managed_process()
+ execution_context = await self._validated_execution_context(context)
+ return await self.client.stop_managed_process(
+ session_id,
+ process_id,
+ action_context=self._action_context(execution_context),
+ )
- def get_managed_process_websocket_url(self, session_id: str, process_id: str = 'default') -> str:
+ def _get_managed_process_websocket_url(
+ self,
+ context: TenantContext,
+ session_id: str,
+ process_id: str = 'default',
+ ) -> str:
getter = getattr(self.client, 'get_managed_process_websocket_url', None)
if getter is None:
raise BoxValidationError('box runtime client does not support managed process websocket attach')
@@ -927,52 +1478,144 @@ class BoxService:
if self._runtime_connector is not None
else 'http://127.0.0.1:5410'
)
- return getter(session_id, ws_relay_base_url, process_id)
+ return getter(
+ session_id,
+ ws_relay_base_url,
+ process_id,
+ action_context=self._action_context(context),
+ )
- async def list_skills(self) -> list[dict]:
- return await self.client.list_skills()
+ async def get_managed_process_websocket_connection(
+ self,
+ context: TenantContext,
+ session_id: str,
+ process_id: str = 'default',
+ ) -> tuple[str, dict[str, str]]:
+ """Resolve a relay URL and headers after fencing the placement.
- async def get_skill(self, name: str) -> dict | None:
- return await self.client.get_skill(name)
+ The shared Box control secret is transported only in headers. The
+ Workspace and generation headers bind the relay to the same trusted
+ execution context used by the action RPC that created the process.
+ """
- async def create_skill(self, skill: dict) -> dict:
- return await self.client.create_skill(skill)
+ self._reject_cloud_managed_process()
+ execution_context = await self._validated_execution_context(context)
+ if self._runtime_connector is None:
+ raise BoxValidationError(
+ 'box runtime connector does not support authenticated managed process websocket attach'
+ )
+ action_context = self._action_context(execution_context)
+ return (
+ self._get_managed_process_websocket_url(
+ execution_context,
+ session_id,
+ process_id,
+ ),
+ self._runtime_connector.get_relay_headers(action_context),
+ )
- async def update_skill(self, name: str, skill: dict) -> dict:
- return await self.client.update_skill(name, skill)
+ async def list_skills(self, context: TenantContext) -> list[dict]:
+ execution_context = await self._validated_skill_execution_context(context)
+ return await self.client.list_skills(action_context=self._action_context(execution_context))
- async def delete_skill(self, name: str) -> None:
- await self.client.delete_skill(name)
+ async def get_skill(self, context: TenantContext, name: str) -> dict | None:
+ execution_context = await self._validated_skill_execution_context(context)
+ return await self.client.get_skill(name, action_context=self._action_context(execution_context))
- async def scan_skill_directory(self, path: str) -> dict:
- return await self.client.scan_skill_directory(path)
+ async def create_skill(self, context: TenantContext, skill: dict) -> dict:
+ execution_context = await self._validated_skill_execution_context(context)
+ payload = dict(skill)
+ payload.pop('workspace_uuid', None)
+ if self._cloud_managed and str(payload.get('package_root', '') or '').strip():
+ raise BoxAdmissionError('Cloud skill package_root is runtime-owned')
+ if self._cloud_managed:
+ payload.pop('package_root', None)
+ return await self.client.create_skill(payload, action_context=self._action_context(execution_context))
+
+ async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
+ execution_context = await self._validated_skill_execution_context(context)
+ payload = dict(skill)
+ payload.pop('workspace_uuid', None)
+ if self._cloud_managed:
+ # The runtime already owns the package path for an existing skill.
+ # A serialized read response may contain it, but it is never an
+ # authority-bearing update field in shared Cloud mode.
+ payload.pop('package_root', None)
+ return await self.client.update_skill(
+ name,
+ payload,
+ action_context=self._action_context(execution_context),
+ )
+
+ async def delete_skill(self, context: TenantContext, name: str) -> None:
+ execution_context = await self._validated_skill_execution_context(context)
+ await self.client.delete_skill(name, action_context=self._action_context(execution_context))
+
+ async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
+ execution_context = await self._validated_skill_execution_context(context)
+ if self._cloud_managed:
+ raise BoxAdmissionError('Scanning arbitrary host skill directories is disabled in Cloud')
+ return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context))
+
+ async def _validated_skill_execution_context(self, context: TenantContext) -> ExecutionContext:
+ execution_context = await self._validated_execution_context(context)
+ await self._require_validated_workspace_sandbox(execution_context)
+ return execution_context
async def list_skill_files(
self,
+ context: TenantContext,
name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
- return await self.client.list_skill_files(name, path, include_hidden, max_entries)
+ execution_context = await self._validated_skill_execution_context(context)
+ return await self.client.list_skill_files(
+ name,
+ path,
+ include_hidden,
+ max_entries,
+ action_context=self._action_context(execution_context),
+ )
- async def read_skill_file(self, name: str, path: str) -> dict:
- return await self.client.read_skill_file(name, path)
+ async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
+ execution_context = await self._validated_skill_execution_context(context)
+ return await self.client.read_skill_file(
+ name,
+ path,
+ action_context=self._action_context(execution_context),
+ )
- async def write_skill_file(self, name: str, path: str, content: str) -> dict:
- return await self.client.write_skill_file(name, path, content)
+ async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
+ execution_context = await self._validated_skill_execution_context(context)
+ return await self.client.write_skill_file(
+ name,
+ path,
+ content,
+ action_context=self._action_context(execution_context),
+ )
async def preview_skill_zip(
self,
+ context: TenantContext,
file_bytes: bytes,
filename: str,
source_subdir: str = '',
target_suffix: str = 'upload',
) -> list[dict]:
- return await self.client.preview_skill_zip(file_bytes, filename, source_subdir, target_suffix)
+ execution_context = await self._validated_skill_execution_context(context)
+ return await self.client.preview_skill_zip(
+ file_bytes,
+ filename,
+ source_subdir,
+ target_suffix,
+ action_context=self._action_context(execution_context),
+ )
async def install_skill_zip(
self,
+ context: TenantContext,
file_bytes: bytes,
filename: str,
source_paths: list[str] | None = None,
@@ -980,6 +1623,7 @@ class BoxService:
source_subdir: str = '',
target_suffix: str = 'upload',
) -> list[dict]:
+ execution_context = await self._validated_skill_execution_context(context)
return await self.client.install_skill_zip(
file_bytes,
filename,
@@ -987,6 +1631,7 @@ class BoxService:
source_path,
source_subdir,
target_suffix,
+ action_context=self._action_context(execution_context),
)
def _serialize_result(self, result: BoxExecutionResult) -> dict:
@@ -1199,6 +1844,20 @@ class BoxService:
allowed_roots = ', '.join(self.allowed_mount_roots)
raise BoxValidationError(f'box.local.default_workspace is outside allowed_mount_roots: {allowed_roots}')
+ def _ensure_cloud_shared_workspace(self) -> None:
+ """Require the Core-side view of the Cloud Box durable volume."""
+
+ if self.default_workspace is None:
+ raise BoxValidationError('Cloud Box requires box.local.default_workspace')
+ if not os.path.isabs(self.default_workspace) or not os.path.isdir(self.default_workspace):
+ raise BoxValidationError('Cloud Box shared default_workspace must be an existing absolute directory')
+ if not os.access(self.default_workspace, os.R_OK | os.W_OK | os.X_OK):
+ raise BoxValidationError('Cloud Box shared default_workspace must be writable by LangBot Core')
+ if not self.allowed_mount_roots or not any(
+ _is_path_under(self.default_workspace, allowed_root) for allowed_root in self.allowed_mount_roots
+ ):
+ raise BoxValidationError('Cloud Box shared default_workspace is outside allowed_mount_roots')
+
def _validate_host_mount(self, spec: BoxSpec):
if spec.host_path is None:
return
@@ -1259,29 +1918,50 @@ class BoxService:
if normalized_timeout > profile.max_timeout_sec:
params['timeout_sec'] = profile.max_timeout_sec
- def _get_workspace_size_bytes(self, root: str) -> int:
- total = 0
+ def _max_workspace_entries(self) -> int:
+ data = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
+ try:
+ configured = int(
+ data.get('box', {}).get('limits', {}).get('max_workspace_entries', _DEFAULT_MAX_WORKSPACE_ENTRIES)
+ )
+ except (AttributeError, TypeError, ValueError):
+ configured = _DEFAULT_MAX_WORKSPACE_ENTRIES
+ return min(max(configured, 1), _HARD_MAX_WORKSPACE_ENTRIES)
- def _walk(path: str):
- nonlocal total
+ @staticmethod
+ def _get_workspace_usage(
+ root: str,
+ *,
+ stop_after_bytes: int,
+ max_entries: int,
+ ) -> tuple[int, int, bool]:
+ """Scan depth-first without recursion and stop at either hard bound."""
+
+ total = 0
+ entries_seen = 0
+ directories = [root]
+ while directories:
+ path = directories.pop()
try:
with os.scandir(path) as entries:
for entry in entries:
+ entries_seen += 1
+ if entries_seen > max_entries:
+ return total, entries_seen, True
try:
if entry.is_symlink():
total += entry.stat(follow_symlinks=False).st_size
- continue
- if entry.is_dir(follow_symlinks=False):
- _walk(entry.path)
- continue
- total += entry.stat(follow_symlinks=False).st_size
+ elif entry.is_dir(follow_symlinks=False):
+ directories.append(entry.path)
+ else:
+ total += entry.stat(follow_symlinks=False).st_size
except FileNotFoundError:
continue
+ if total > stop_after_bytes:
+ return total, entries_seen, False
except FileNotFoundError:
- return
-
- _walk(root)
- return total
+ continue
+ return total, entries_seen, False
async def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
if spec.host_path is None or spec.workspace_quota_mb <= 0:
@@ -1294,20 +1974,34 @@ class BoxService:
# Walk the workspace off the event loop — this runs on every
# quota-enforced exec, and a large tree would otherwise block the whole
# asyncio runtime (all bots/pipelines) for the duration of the scan.
- used_bytes = await asyncio.to_thread(self._get_workspace_size_bytes, host_path)
limit_bytes = spec.workspace_quota_mb * _MIB
+ max_entries = self._max_workspace_entries()
+ used_bytes, entries_seen, entry_limit_exceeded = await asyncio.to_thread(
+ self._get_workspace_usage,
+ host_path,
+ stop_after_bytes=limit_bytes,
+ max_entries=max_entries,
+ )
+ if entry_limit_exceeded:
+ raise BoxValidationError(
+ f'workspace entry limit exceeded {phase}: '
+ f'entries>{max_entries} host_path={host_path} session_id={spec.session_id}'
+ )
if used_bytes <= limit_bytes:
return
raise BoxValidationError(
f'workspace quota exceeded {phase}: '
f'used={used_bytes} bytes limit={limit_bytes} bytes '
- f'host_path={host_path} session_id={spec.session_id}'
+ f'entries={entries_seen} host_path={host_path} session_id={spec.session_id}'
)
- async def _cleanup_exceeded_session(self, spec: BoxSpec) -> None:
+ async def _cleanup_exceeded_session(self, context: TenantContext, spec: BoxSpec) -> None:
try:
- await self.client.delete_session(spec.session_id)
+ await self.client.delete_session(
+ spec.session_id,
+ action_context=self._action_context(context),
+ )
except Exception as exc:
self.ap.logger.warning(
'Failed to clean up Box session after workspace quota was exceeded: '
@@ -1324,19 +2018,27 @@ class BoxService:
'type': type(exc).__name__,
'message': str(exc),
'query_id': str(query.query_id),
+ 'instance_uuid': str(getattr(query, 'instance_uuid', '') or ''),
+ 'workspace_uuid': str(getattr(query, 'workspace_uuid', '') or ''),
}
)
- def get_recent_errors(self) -> list[dict]:
- return list(self._recent_errors)
+ def get_recent_errors(self, context: TenantContext) -> list[dict]:
+ execution_context = self._execution_context(context)
+ return [
+ error
+ for error in self._recent_errors
+ if error.get('instance_uuid') == execution_context.instance_uuid
+ and error.get('workspace_uuid') == execution_context.workspace_uuid
+ ]
- def get_system_guidance(self, query_id=None) -> str:
+ def get_system_guidance(self, query: pipeline_query.Query | int | str | None = None) -> str:
"""Return LLM system-prompt guidance for the exec tool.
All execution-specific prompt text is kept here so that callers
(e.g. LocalAgentRunner) stay free of box domain knowledge.
- ``query_id`` is the current turn's pipeline query id. When provided,
+ ``query`` is the current turn's pipeline query. When provided,
the guidance ALWAYS advertises the per-query outbox path so the agent
knows how to deliver generated files back to the user — even on turns
where the user sent no inbound attachment (e.g. "generate a QR code"),
@@ -1357,8 +2059,17 @@ class BoxService:
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
'user for directory parameters unless they explicitly need a different directory.'
)
- if query_id is not None:
- outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_id}'
+ if query is not None:
+ if not isinstance(query, (int, str)):
+ query_key = self._attachment_query_key(query)
+ else:
+ # Backwards compatibility for OSS callers/tests that passed
+ # the old process-local integer identity. Cloud callers must
+ # pass the full Query so an opaque UUID is always advertised.
+ if self._cloud_managed:
+ raise BoxValidationError('Cloud outbox guidance requires a pipeline Query')
+ query_key = str(query)
+ outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_key}'
guidance += (
f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, '
f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be '
@@ -1366,17 +2077,30 @@ class BoxService:
)
return guidance
- async def get_status(self) -> dict:
+ async def get_backend_status(self) -> dict:
+ """Return instance-level backend readiness without tenant resource data."""
+
+ if not self._available:
+ return {'available': False, 'enabled': self._enabled, 'connector_error': self._connector_error}
+ backend = await self.client.get_backend_info()
+ return {'available': bool(backend.get('available', False)), 'enabled': self._enabled, 'backend': backend}
+
+ async def get_status(self, context: TenantContext) -> dict:
+ execution_context = await self._validated_execution_context(context)
+ if self._cloud_managed and self._available:
+ await self._require_validated_workspace_sandbox(execution_context)
+ action_context = self._action_context(execution_context)
+ recent_error_count = len(self.get_recent_errors(execution_context))
if not self._available:
return {
'available': False,
'enabled': self._enabled,
'profile': self.profile.name,
- 'recent_error_count': len(self._recent_errors),
+ 'recent_error_count': recent_error_count,
'connector_error': self._connector_error,
}
try:
- runtime_status = await self.client.get_status()
+ runtime_status = await self.client.get_status(action_context=action_context)
except Exception as exc:
# RPC failed — the runtime likely just disconnected and the
# heartbeat hasn't flipped _available yet.
@@ -1384,7 +2108,7 @@ class BoxService:
'available': False,
'enabled': self._enabled,
'profile': self.profile.name,
- 'recent_error_count': len(self._recent_errors),
+ 'recent_error_count': recent_error_count,
'connector_error': str(exc),
}
# Backend state can be unavailable even when the connector is healthy
@@ -1402,7 +2126,7 @@ class BoxService:
'available': backend_ok,
'enabled': self._enabled,
'profile': self.profile.name,
- 'recent_error_count': len(self._recent_errors),
+ 'recent_error_count': recent_error_count,
}
if not backend_ok and 'connector_error' not in payload:
backend_name = backend_info.get('name') if backend_info else None
diff --git a/src/langbot/pkg/box/workspace.py b/src/langbot/pkg/box/workspace.py
index 26d1a41e9..3dd91c67b 100644
--- a/src/langbot/pkg/box/workspace.py
+++ b/src/langbot/pkg/box/workspace.py
@@ -126,24 +126,30 @@ def should_prepare_python_env(host_path: str | None) -> bool:
return bool(list_python_manifest_files(normalized_root))
-def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace') -> str:
+def wrap_python_command_with_env(
+ command: str,
+ *,
+ mount_path: str = '/workspace',
+ state_path: str | None = None,
+) -> str:
"""Wrap a command with a reusable sandbox-local Python env bootstrap.
- This is the generic "workspace is a Python project" path used by mutable
- workspaces such as skills. Read-only installation strategies stay in the
- higher-level caller because they are application policy, not workspace
- semantics.
+ ``mount_path`` is always the source tree used for manifest hashing and
+ installation. ``state_path`` may point at a separate writable directory
+ for read-only source mounts; when omitted, legacy mutable-workspace behavior
+ stores the environment beside the source.
"""
+ writable_state_path = state_path or mount_path
bootstrap = textwrap.dedent(
f"""
set -e
- _LB_VENV_DIR="{mount_path}/.venv"
- _LB_META_DIR="{mount_path}/.langbot"
+ _LB_VENV_DIR="{writable_state_path}/.venv"
+ _LB_META_DIR="{writable_state_path}/.langbot"
_LB_META_FILE="$_LB_META_DIR/python-env.json"
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
- _LB_TMP_DIR="{mount_path}/.tmp"
- _LB_PIP_CACHE_DIR="{mount_path}/.cache/pip"
+ _LB_TMP_DIR="{writable_state_path}/.tmp"
+ _LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
@@ -165,17 +171,23 @@ def wrap_python_command_with_env(command: str, *, mount_path: str = '/workspace'
import sys
root = "{mount_path}"
+ max_manifest_bytes = 10 * 1024 * 1024
digest = hashlib.sha256()
manifest_files = []
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
path = os.path.join(root, rel)
if not os.path.isfile(path):
continue
+ if os.path.getsize(path) > max_manifest_bytes:
+ raise RuntimeError(
+ f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
+ )
manifest_files.append(rel)
with open(path, "rb") as handle:
digest.update(rel.encode("utf-8"))
digest.update(b"\\0")
- digest.update(handle.read())
+ while chunk := handle.read(1024 * 1024):
+ digest.update(chunk)
digest.update(b"\\0")
print(
@@ -274,6 +286,7 @@ class BoxWorkspaceSession:
def __init__(
self,
box_service,
+ execution_context,
session_id: str,
*,
host_path: str | None = None,
@@ -290,6 +303,7 @@ class BoxWorkspaceSession:
persistent: bool = False,
):
self.box_service = box_service
+ self.execution_context = execution_context
self.session_id = session_id
self.host_path = host_path
self.host_path_mode = host_path_mode
@@ -363,7 +377,7 @@ class BoxWorkspaceSession:
timeout_sec: int | None = None,
):
payload = self.build_exec_payload(cmd, workdir=workdir, env=env, timeout_sec=timeout_sec)
- return await self.box_service.client.execute(self.box_service.build_spec(payload))
+ return await self.box_service.execute_in_context(self.execution_context, payload)
async def execute_for_query(
self,
@@ -378,7 +392,7 @@ class BoxWorkspaceSession:
return await self.box_service.execute_spec_payload(payload, query)
async def create_session(self):
- return await self.box_service.create_session(self.build_session_payload())
+ return await self.box_service.create_session(self.execution_context, self.build_session_payload())
def build_process_payload(
self,
@@ -415,16 +429,26 @@ class BoxWorkspaceSession:
):
payload = self.build_process_payload(command, args, env=env, cwd=cwd)
payload['process_id'] = process_id
- return await self.box_service.start_managed_process(self.session_id, payload)
+ return await self.box_service.start_managed_process(self.execution_context, self.session_id, payload)
async def get_managed_process(self, process_id: str = 'default'):
- return await self.box_service.get_managed_process(self.session_id, process_id)
+ return await self.box_service.get_managed_process(self.execution_context, self.session_id, process_id)
async def stop_managed_process(self, process_id: str = 'default') -> None:
- await self.box_service.stop_managed_process(self.session_id, process_id)
+ await self.box_service.stop_managed_process(self.execution_context, self.session_id, process_id)
- def get_managed_process_websocket_url(self, process_id: str = 'default') -> str:
- return self.box_service.get_managed_process_websocket_url(self.session_id, process_id)
+ async def get_managed_process_websocket_connection(
+ self,
+ process_id: str = 'default',
+ ) -> tuple[str, dict[str, str]]:
+ return await self.box_service.get_managed_process_websocket_connection(
+ self.execution_context,
+ self.session_id,
+ process_id,
+ )
async def cleanup(self) -> None:
- await self.box_service.client.delete_session(self.session_id)
+ await self.box_service.client.delete_session(
+ self.session_id,
+ action_context=self.box_service._action_context(self.execution_context),
+ )
diff --git a/src/langbot/pkg/cloud/__init__.py b/src/langbot/pkg/cloud/__init__.py
new file mode 100644
index 000000000..ada0c48cf
--- /dev/null
+++ b/src/langbot/pkg/cloud/__init__.py
@@ -0,0 +1,51 @@
+"""Contracts used by the optional closed Cloud control-plane bootstrap."""
+
+from .bootstrap import (
+ CloudBootstrapError,
+ CloudManifestProvider,
+ CloudManifestRefreshService,
+ OpenSourceDeployment,
+ VerifiedCloudDeployment,
+ resolve_deployment,
+)
+from .directory import (
+ DirectoryDelta,
+ DirectoryEvent,
+ DirectoryEventBatch,
+ DirectoryMember,
+ DirectoryProjectionProvider,
+ DirectoryProjectionUnavailableError,
+ DirectorySnapshot,
+ DirectoryWorkspace,
+)
+from .directory_projection import DirectoryProjectionService
+from .entitlements import (
+ EntitlementProvider,
+ EntitlementResolver,
+ EntitlementSnapshot,
+ EntitlementUnavailableError,
+ OpenSourceEntitlementProvider,
+)
+
+__all__ = [
+ 'CloudBootstrapError',
+ 'CloudManifestProvider',
+ 'CloudManifestRefreshService',
+ 'DirectoryDelta',
+ 'DirectoryEvent',
+ 'DirectoryEventBatch',
+ 'DirectoryMember',
+ 'DirectoryProjectionProvider',
+ 'DirectoryProjectionService',
+ 'DirectoryProjectionUnavailableError',
+ 'DirectorySnapshot',
+ 'DirectoryWorkspace',
+ 'EntitlementProvider',
+ 'EntitlementResolver',
+ 'EntitlementSnapshot',
+ 'EntitlementUnavailableError',
+ 'OpenSourceDeployment',
+ 'OpenSourceEntitlementProvider',
+ 'VerifiedCloudDeployment',
+ 'resolve_deployment',
+]
diff --git a/src/langbot/pkg/cloud/bootstrap.py b/src/langbot/pkg/cloud/bootstrap.py
new file mode 100644
index 000000000..8e9ee1886
--- /dev/null
+++ b/src/langbot/pkg/cloud/bootstrap.py
@@ -0,0 +1,408 @@
+from __future__ import annotations
+
+import asyncio
+import dataclasses
+import importlib.metadata
+import inspect
+import os
+import threading
+import time
+from collections.abc import Awaitable, Callable
+from typing import Any, Protocol, runtime_checkable
+
+from ..workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
+from .directory import DirectoryProjectionProvider, directory_projection_limits_from_config
+from .entitlements import EntitlementProvider, OpenSourceEntitlementProvider
+
+
+CLOUD_BOOTSTRAP_ENTRY_POINT = 'langbot.cloud_bootstrap'
+REQUIRED_TENANT_ISOLATION_VERSION = 2
+SUPPORTED_PGVECTOR_DIMENSIONS = frozenset({384, 512, 768, 1024, 1536})
+
+
+class CloudBootstrapError(RuntimeError):
+ """Fail-closed Cloud bootstrap validation error."""
+
+
+class CloudRuntimeUnavailableError(CloudBootstrapError):
+ """The verified Cloud receipt no longer admits runtime work."""
+
+
+@runtime_checkable
+class CloudManifestProvider(Protocol):
+ """Closed adapter responsible for renewing the signed deployment receipt."""
+
+ async def refresh_manifest(self) -> VerifiedCloudDeployment:
+ """Fetch, verify, and return the newest deployment receipt."""
+
+ async def aclose(self) -> None:
+ """Release control-plane transport resources."""
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class OpenSourceDeployment:
+ """Default deployment selected when no closed bootstrap is installed."""
+
+ mode: str = 'oss'
+ workspace_policy: SingleWorkspacePolicy = dataclasses.field(default_factory=SingleWorkspacePolicy)
+ entitlement_provider: OpenSourceEntitlementProvider = dataclasses.field(
+ default_factory=OpenSourceEntitlementProvider
+ )
+ directory_provider: None = None
+ manifest_provider: None = None
+ persistence_mode: str = 'oss_compat'
+ required_vector_backend: str | None = None
+
+ @property
+ def multi_workspace_enabled(self) -> bool:
+ return False
+
+ def validate_instance_config(self, config: dict[str, Any]) -> None:
+ del config
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class VerifiedCloudDeployment:
+ """Receipt returned only after the closed package verifies a Manifest.
+
+ Core deliberately does not accept a config flag as a substitute for this
+ object. The closed entry point owns root-key/JWS verification and the
+ entitlement adapter; open Core validates the receipt's runtime invariants.
+ """
+
+ instance_uuid: str
+ manifest_jti: str
+ manifest_generation: int
+ expires_at: int
+ release: str
+ capabilities: frozenset[str]
+ tenant_isolation_version: int
+ entitlement_provider: EntitlementProvider
+ directory_provider: DirectoryProjectionProvider
+ manifest_provider: CloudManifestProvider
+ verification_key_id: str
+ mode: str = dataclasses.field(default='cloud', init=False)
+ workspace_policy: CloudWorkspacePolicy = dataclasses.field(default_factory=CloudWorkspacePolicy, init=False)
+ persistence_mode: str = dataclasses.field(default='cloud_runtime', init=False)
+ required_vector_backend: str = dataclasses.field(default='pgvector', init=False)
+
+ @property
+ def multi_workspace_enabled(self) -> bool:
+ return True
+
+ def validate(self, expected_instance_uuid: str, *, now: int | None = None) -> None:
+ current_time = int(time.time()) if now is None else now
+ if not self.instance_uuid or self.instance_uuid != expected_instance_uuid:
+ raise CloudBootstrapError('Verified Cloud Manifest targets another LangBot instance')
+ if not self.manifest_jti or not self.verification_key_id:
+ raise CloudBootstrapError('Verified Cloud Manifest receipt is incomplete')
+ if isinstance(self.manifest_generation, bool) or self.manifest_generation <= 0:
+ raise CloudBootstrapError('Verified Cloud Manifest generation must be positive')
+ if self.expires_at <= current_time:
+ raise CloudBootstrapError('Verified Cloud Manifest is expired')
+ if self.tenant_isolation_version < REQUIRED_TENANT_ISOLATION_VERSION:
+ raise CloudBootstrapError('Verified Cloud Manifest requires an unsupported tenant isolation version')
+ if 'multi_workspace_v2' not in self.capabilities:
+ raise CloudBootstrapError('Verified Cloud Manifest does not grant multi_workspace_v2')
+ if not isinstance(self.entitlement_provider, EntitlementProvider):
+ raise CloudBootstrapError('Verified Cloud bootstrap did not provide an entitlement adapter')
+ if not isinstance(self.directory_provider, DirectoryProjectionProvider):
+ raise CloudBootstrapError('Verified Cloud bootstrap did not provide a directory adapter')
+ if not isinstance(self.manifest_provider, CloudManifestProvider):
+ raise CloudBootstrapError('Verified Cloud bootstrap did not provide a Manifest renewal adapter')
+
+ def validate_instance_config(self, config: dict[str, Any]) -> None:
+ try:
+ directory_projection_limits_from_config(config)
+ except (TypeError, ValueError) as exc:
+ raise CloudBootstrapError(f'Cloud directory limits are invalid: {exc}') from exc
+ if config.get('database', {}).get('use') != 'postgresql':
+ raise CloudBootstrapError('Cloud runtime requires database.use=postgresql')
+ if config.get('vdb', {}).get('use') != self.required_vector_backend:
+ raise CloudBootstrapError('Cloud runtime requires vdb.use=pgvector')
+ pgvector_config = config.get('vdb', {}).get('pgvector', {})
+ if pgvector_config.get('use_business_database') is not True:
+ raise CloudBootstrapError('Cloud runtime requires vdb.pgvector.use_business_database=true')
+ dimensions = pgvector_config.get('allowed_dimensions')
+ if (
+ not isinstance(dimensions, list)
+ or not dimensions
+ or any(isinstance(item, bool) or not isinstance(item, int) for item in dimensions)
+ or not set(dimensions).issubset(SUPPORTED_PGVECTOR_DIMENSIONS)
+ ):
+ supported = ', '.join(str(item) for item in sorted(SUPPORTED_PGVECTOR_DIMENSIONS))
+ raise CloudBootstrapError(f'Cloud pgvector allowed_dimensions must be a non-empty subset of: {supported}')
+ if config.get('mcp', {}).get('stdio', {}).get('enabled', True) is not False:
+ raise CloudBootstrapError('Cloud runtime requires mcp.stdio.enabled=false')
+ plugin_worker = config.get('plugin', {}).get('worker', {})
+ if plugin_worker.get('require_hard_limits') is not True:
+ raise CloudBootstrapError('Cloud Runtime requires plugin.worker.require_hard_limits=true')
+ box_config = config.get('box', {})
+ if box_config.get('enabled') is not True:
+ raise CloudBootstrapError('Cloud runtime requires box.enabled=true')
+ if box_config.get('backend') != 'nsjail':
+ raise CloudBootstrapError('Cloud runtime requires box.backend=nsjail')
+ runtime_endpoint = str(box_config.get('runtime', {}).get('endpoint', '') or '').strip()
+ if not runtime_endpoint:
+ raise CloudBootstrapError('Cloud runtime requires a shared external box.runtime.endpoint')
+ admission = box_config.get('admission', {})
+ required_admission = {
+ 'required': True,
+ 'logical_session_id': 'global',
+ 'required_backend': 'nsjail',
+ 'max_sessions': 1,
+ 'max_managed_processes': 0,
+ }
+ if any(admission.get(name) != value for name, value in required_admission.items()):
+ raise CloudBootstrapError(
+ 'Cloud runtime requires grant-enforced Box admission with one global session and zero managed processes'
+ )
+ grant_ttl = admission.get('max_grant_ttl_sec')
+ if isinstance(grant_ttl, bool) or not isinstance(grant_ttl, int) or not 1 <= grant_ttl <= 300:
+ raise CloudBootstrapError('Cloud Box admission max_grant_ttl_sec must be between 1 and 300')
+ workspace_quota_mb = admission.get('workspace_quota_mb')
+ if isinstance(workspace_quota_mb, bool) or not isinstance(workspace_quota_mb, int) or workspace_quota_mb <= 0:
+ raise CloudBootstrapError('Cloud Box admission workspace_quota_mb must be a positive integer')
+ local_config = box_config.get('local', {})
+ host_root = str(local_config.get('host_root', '') or '').strip()
+ default_workspace = str(local_config.get('default_workspace', '') or '').strip()
+ allowed_mount_roots = local_config.get('allowed_mount_roots')
+ if not host_root or not os.path.isabs(host_root):
+ raise CloudBootstrapError('Cloud Box local.host_root must be an absolute shared-volume path')
+ if not default_workspace or not os.path.isabs(default_workspace):
+ raise CloudBootstrapError('Cloud Box local.default_workspace must be an absolute shared-volume path')
+ if (
+ not isinstance(allowed_mount_roots, list)
+ or not allowed_mount_roots
+ or any(not isinstance(root, str) or not os.path.isabs(root) for root in allowed_mount_roots)
+ ):
+ raise CloudBootstrapError('Cloud Box local.allowed_mount_roots must contain absolute shared-volume paths')
+ resolved_workspace = os.path.realpath(default_workspace)
+ if not any(
+ resolved_workspace == os.path.realpath(root)
+ or resolved_workspace.startswith(f'{os.path.realpath(root)}{os.sep}')
+ for root in allowed_mount_roots
+ ):
+ raise CloudBootstrapError('Cloud Box local.default_workspace must be under allowed_mount_roots')
+
+
+class CloudBootstrapProvider(Protocol):
+ def bootstrap(
+ self,
+ *,
+ instance_uuid: str,
+ instance_config: dict[str, Any],
+ ) -> VerifiedCloudDeployment | Awaitable[VerifiedCloudDeployment]: ...
+
+
+class DeploymentAdmissionGuard:
+ """Continuously enforce one verified deployment receipt.
+
+ Startup verification alone is insufficient because a long-running process
+ could otherwise keep serving after the signed Manifest expires. The guard
+ tracks both wall-clock expiry and a monotonic deadline so moving the system
+ clock backwards cannot extend an already admitted receipt.
+
+ A closed bootstrap may atomically replace the receipt with a strictly newer
+ Manifest generation after performing its own signature verification. The
+ logical instance and deployment mode cannot change during the process.
+ """
+
+ def __init__(
+ self,
+ instance_uuid: str,
+ deployment: OpenSourceDeployment | VerifiedCloudDeployment,
+ *,
+ wall_time: Callable[[], float] = time.time,
+ monotonic_time: Callable[[], float] = time.monotonic,
+ ) -> None:
+ self.instance_uuid = instance_uuid
+ self._wall_time = wall_time
+ self._monotonic_time = monotonic_time
+ self._lock = threading.Lock()
+ self._deployment = deployment
+ self._deadline: float | None = None
+ self._install_initial(deployment)
+
+ @property
+ def deployment(self) -> OpenSourceDeployment | VerifiedCloudDeployment:
+ with self._lock:
+ return self._deployment
+
+ def _install_initial(self, deployment: OpenSourceDeployment | VerifiedCloudDeployment) -> None:
+ now = int(self._wall_time())
+ if isinstance(deployment, VerifiedCloudDeployment):
+ deployment.validate(self.instance_uuid, now=now)
+ self._deadline = self._monotonic_time() + (deployment.expires_at - now)
+ elif not isinstance(deployment, OpenSourceDeployment):
+ raise TypeError('Deployment admission requires a verified deployment object')
+
+ @staticmethod
+ def _receipt_identity(deployment: VerifiedCloudDeployment) -> tuple[Any, ...]:
+ return (
+ deployment.instance_uuid,
+ deployment.manifest_jti,
+ deployment.manifest_generation,
+ deployment.expires_at,
+ deployment.release,
+ tuple(sorted(deployment.capabilities)),
+ deployment.tenant_isolation_version,
+ deployment.verification_key_id,
+ )
+
+ def replace(self, deployment: VerifiedCloudDeployment) -> None:
+ """Atomically install a verified, non-rollback Cloud receipt."""
+
+ now = int(self._wall_time())
+ deployment.validate(self.instance_uuid, now=now)
+ with self._lock:
+ current = self._deployment
+ if not isinstance(current, VerifiedCloudDeployment):
+ raise CloudRuntimeUnavailableError('Deployment mode cannot change while LangBot is running')
+ if deployment.manifest_generation < current.manifest_generation:
+ raise CloudRuntimeUnavailableError('Cloud Manifest generation rolled back')
+ if deployment.manifest_generation == current.manifest_generation and self._receipt_identity(
+ deployment
+ ) != self._receipt_identity(current):
+ raise CloudRuntimeUnavailableError('Cloud Manifest generation has conflicting contents')
+ self._deployment = deployment
+ self._deadline = self._monotonic_time() + (deployment.expires_at - now)
+
+ def require_active(self) -> OpenSourceDeployment | VerifiedCloudDeployment:
+ """Return the active deployment or fail closed after Manifest expiry."""
+
+ now = int(self._wall_time())
+ monotonic_now = self._monotonic_time()
+ with self._lock:
+ deployment = self._deployment
+ deadline = self._deadline
+ if isinstance(deployment, OpenSourceDeployment):
+ return deployment
+ try:
+ deployment.validate(self.instance_uuid, now=now)
+ except CloudBootstrapError as exc:
+ raise CloudRuntimeUnavailableError(str(exc)) from exc
+ if deadline is None or monotonic_now >= deadline:
+ raise CloudRuntimeUnavailableError('Verified Cloud Manifest is expired')
+ return deployment
+
+
+class CloudManifestRefreshService:
+ """Renew a short-lived verified Manifest before runtime admission expires."""
+
+ def __init__(
+ self,
+ admission: DeploymentAdmissionGuard,
+ provider: CloudManifestProvider,
+ logger: Any,
+ *,
+ wall_time: Callable[[], float] = time.time,
+ refresh_margin_seconds: int = 180,
+ maximum_sleep_seconds: int = 300,
+ ) -> None:
+ if not isinstance(provider, CloudManifestProvider):
+ raise TypeError('Cloud Manifest refresh requires a CloudManifestProvider')
+ if refresh_margin_seconds < 120:
+ raise ValueError('Cloud Manifest refresh margin must be at least 120 seconds')
+ if maximum_sleep_seconds <= 0:
+ raise ValueError('Cloud Manifest refresh maximum sleep must be positive')
+ self.admission = admission
+ self.provider = provider
+ self.logger = logger
+ self._wall_time = wall_time
+ self.refresh_margin_seconds = refresh_margin_seconds
+ self.maximum_sleep_seconds = maximum_sleep_seconds
+
+ def next_refresh_delay(self) -> float:
+ deployment = self.admission.deployment
+ if not isinstance(deployment, VerifiedCloudDeployment):
+ return float(self.maximum_sleep_seconds)
+ remaining = deployment.expires_at - self._wall_time()
+ return max(
+ 5.0,
+ min(
+ float(self.maximum_sleep_seconds),
+ remaining - self.refresh_margin_seconds,
+ ),
+ )
+
+ async def refresh_once(self) -> VerifiedCloudDeployment:
+ candidate = await self.provider.refresh_manifest()
+ if not isinstance(candidate, VerifiedCloudDeployment):
+ raise CloudBootstrapError('Cloud Manifest provider returned an invalid deployment receipt')
+ self.admission.replace(candidate)
+ return candidate
+
+ async def run(self) -> None:
+ retry_delay = 5.0
+ while True:
+ try:
+ await asyncio.sleep(self.next_refresh_delay())
+ await self.refresh_once()
+ retry_delay = 5.0
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ self.logger.exception('Cloud Manifest refresh failed')
+ await asyncio.sleep(retry_delay)
+ retry_delay = min(retry_delay * 2, 30.0)
+
+
+async def _invoke_provider(
+ loaded: Any,
+ *,
+ instance_uuid: str,
+ instance_config: dict[str, Any],
+) -> VerifiedCloudDeployment:
+ provider = loaded() if inspect.isclass(loaded) else loaded
+ bootstrap = getattr(provider, 'bootstrap', None)
+ if not callable(bootstrap):
+ raise CloudBootstrapError('Cloud bootstrap entry point must expose bootstrap()')
+ result = bootstrap(instance_uuid=instance_uuid, instance_config=instance_config)
+ if inspect.isawaitable(result):
+ result = await result
+ if not isinstance(result, VerifiedCloudDeployment):
+ raise CloudBootstrapError('Cloud bootstrap must return VerifiedCloudDeployment')
+ return result
+
+
+async def resolve_deployment(
+ *,
+ instance_uuid: str,
+ instance_config: dict[str, Any],
+ entry_points: Callable[[], Any] | None = None,
+ now: int | None = None,
+) -> OpenSourceDeployment | VerifiedCloudDeployment:
+ """Discover the optional closed bootstrap and validate its receipt.
+
+ Absence selects OSS singleton mode. Presence is fail-closed: duplicate,
+ broken, invalid, or expired providers never fall back to an OSS Workspace.
+ """
+
+ discover = entry_points or importlib.metadata.entry_points
+ discovered = discover()
+ if hasattr(discovered, 'select'):
+ candidates = list(discovered.select(group=CLOUD_BOOTSTRAP_ENTRY_POINT))
+ else: # Python/importlib compatibility for dict-like EntryPoints
+ candidates = list(discovered.get(CLOUD_BOOTSTRAP_ENTRY_POINT, ()))
+ if not candidates:
+ deployment = OpenSourceDeployment()
+ deployment.validate_instance_config(instance_config)
+ return deployment
+ if len(candidates) != 1:
+ raise CloudBootstrapError('Exactly one Cloud bootstrap provider may be installed')
+
+ try:
+ loaded = candidates[0].load()
+ deployment = await _invoke_provider(
+ loaded,
+ instance_uuid=instance_uuid,
+ instance_config=instance_config,
+ )
+ deployment.validate(instance_uuid, now=now)
+ deployment.validate_instance_config(instance_config)
+ return deployment
+ except CloudBootstrapError:
+ raise
+ except Exception as exc:
+ raise CloudBootstrapError('Closed Cloud bootstrap failed') from exc
diff --git a/src/langbot/pkg/cloud/directory.py b/src/langbot/pkg/cloud/directory.py
new file mode 100644
index 000000000..3232e6bed
--- /dev/null
+++ b/src/langbot/pkg/cloud/directory.py
@@ -0,0 +1,311 @@
+from __future__ import annotations
+
+import datetime
+from collections.abc import Sequence
+from typing import Any, Protocol, runtime_checkable
+
+import pydantic
+
+
+DEFAULT_MAX_ACTIVE_WORKSPACES = 1_000
+HARD_MAX_ACTIVE_WORKSPACES = 5_000
+DEFAULT_MAX_SNAPSHOT_WORKSPACES = 1_000
+HARD_MAX_SNAPSHOT_WORKSPACES = 5_000
+DEFAULT_MAX_SNAPSHOT_MEMBERSHIPS = 20_000
+HARD_MAX_SNAPSHOT_MEMBERSHIPS = 100_000
+DEFAULT_MAX_CONTROL_PLANE_RESPONSE_BYTES = 32 * 1024 * 1024
+HARD_MAX_CONTROL_PLANE_RESPONSE_BYTES = 64 * 1024 * 1024
+
+
+class DirectoryProjectionUnavailableError(RuntimeError):
+ """Raised when the verified Cloud directory cannot safely admit work."""
+
+
+class DirectoryProjectionLimits(pydantic.BaseModel):
+ """Instance-owned cardinality limits for verified Cloud directory data.
+
+ These are operational safety limits, not subscription entitlements. Core
+ fails the complete projection transaction when a limit is exceeded instead
+ of truncating an authoritative directory and accidentally hiding tenants.
+ The closed adapter consumes the same limits before priming entitlement
+ caches, and additionally bounds the HTTP response buffered for signature
+ verification.
+ """
+
+ model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
+
+ max_active_workspaces: int = pydantic.Field(
+ default=DEFAULT_MAX_ACTIVE_WORKSPACES,
+ ge=1,
+ le=HARD_MAX_ACTIVE_WORKSPACES,
+ )
+ max_snapshot_workspaces: int = pydantic.Field(
+ default=DEFAULT_MAX_SNAPSHOT_WORKSPACES,
+ ge=1,
+ le=HARD_MAX_SNAPSHOT_WORKSPACES,
+ )
+ max_snapshot_memberships: int = pydantic.Field(
+ default=DEFAULT_MAX_SNAPSHOT_MEMBERSHIPS,
+ ge=1,
+ le=HARD_MAX_SNAPSHOT_MEMBERSHIPS,
+ )
+ max_response_bytes: int = pydantic.Field(
+ default=DEFAULT_MAX_CONTROL_PLANE_RESPONSE_BYTES,
+ ge=1024 * 1024,
+ le=HARD_MAX_CONTROL_PLANE_RESPONSE_BYTES,
+ )
+
+ @pydantic.field_validator(
+ 'max_active_workspaces',
+ 'max_snapshot_workspaces',
+ 'max_snapshot_memberships',
+ 'max_response_bytes',
+ mode='before',
+ )
+ @classmethod
+ def _reject_boolean_limits(cls, value: object) -> object:
+ if isinstance(value, bool):
+ raise ValueError('must be an integer')
+ return value
+
+ @pydantic.model_validator(mode='after')
+ def _validate_workspace_limits(self) -> DirectoryProjectionLimits:
+ if self.max_snapshot_workspaces < self.max_active_workspaces:
+ raise ValueError('max_snapshot_workspaces must be greater than or equal to max_active_workspaces')
+ return self
+
+
+def directory_projection_limits_from_config(config: dict[str, Any]) -> DirectoryProjectionLimits:
+ """Parse typed Cloud directory limits from the instance configuration."""
+
+ cloud_config = config.get('cloud', {})
+ if not isinstance(cloud_config, dict):
+ raise ValueError('cloud must be a mapping')
+ directory_config = cloud_config.get('directory', {})
+ if not isinstance(directory_config, dict):
+ raise ValueError('cloud.directory must be a mapping')
+ return DirectoryProjectionLimits.model_validate(directory_config)
+
+
+class DirectoryMember(pydantic.BaseModel):
+ """One account membership published by the SaaS control plane."""
+
+ model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
+
+ membership_uuid: str = pydantic.Field(min_length=1, max_length=36)
+ account_uuid: str = pydantic.Field(min_length=1, max_length=36)
+ normalized_email: str = pydantic.Field(min_length=1, max_length=320)
+ display_name: str = pydantic.Field(min_length=1, max_length=255)
+ account_status: str = pydantic.Field(pattern=r'^(active|blocked|disabled|deleted)$')
+ role: str = pydantic.Field(pattern=r'^(owner|admin|member|developer|operator|viewer)$')
+ membership_status: str = pydantic.Field(pattern=r'^(active|invited|disabled|removed)$')
+ projection_revision: int = pydantic.Field(ge=1)
+ joined_at: datetime.datetime | None = None
+
+ @pydantic.field_validator('normalized_email')
+ @classmethod
+ def _normalize_email(cls, value: str) -> str:
+ normalized = value.strip().casefold()
+ if normalized != value:
+ raise ValueError('Directory email must already be normalized')
+ return normalized
+
+
+class DirectoryWorkspace(pydantic.BaseModel):
+ """One Workspace and its authoritative membership projection."""
+
+ model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
+
+ uuid: str = pydantic.Field(min_length=1, max_length=36)
+ name: str = pydantic.Field(min_length=1, max_length=255)
+ slug: str = pydantic.Field(min_length=1, max_length=255)
+ type: str = pydantic.Field(pattern=r'^(personal|team)$')
+ status: str = pydantic.Field(pattern=r'^(provisioning|active|suspended|archived|deleted)$')
+ created_by_account_uuid: str = pydantic.Field(min_length=1, max_length=36)
+ projection_revision: int = pydantic.Field(ge=1)
+ execution_generation: int = pydantic.Field(ge=1)
+ members: tuple[DirectoryMember, ...] = pydantic.Field(
+ default=(),
+ max_length=HARD_MAX_SNAPSHOT_MEMBERSHIPS,
+ )
+
+ @pydantic.field_validator('members', mode='before')
+ @classmethod
+ def _copy_members(cls, value: Sequence[DirectoryMember] | None) -> tuple[DirectoryMember, ...]:
+ return tuple(value or ())
+
+ @pydantic.model_validator(mode='after')
+ def _validate_members(self) -> DirectoryWorkspace:
+ membership_uuids: set[str] = set()
+ account_uuids: set[str] = set()
+ for member in self.members:
+ if member.membership_uuid in membership_uuids:
+ raise ValueError('Directory Workspace contains duplicate membership UUIDs')
+ if member.account_uuid in account_uuids:
+ raise ValueError('Directory Workspace contains duplicate account UUIDs')
+ membership_uuids.add(member.membership_uuid)
+ account_uuids.add(member.account_uuid)
+ if self.created_by_account_uuid not in account_uuids:
+ raise ValueError('Directory Workspace must include its creator')
+ return self
+
+
+class DirectorySnapshot(pydantic.BaseModel):
+ """Full signed directory state at one monotonic outbox cursor."""
+
+ model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
+
+ instance_uuid: str = pydantic.Field(min_length=1, max_length=255)
+ cursor: int = pydantic.Field(ge=0)
+ generated_at: datetime.datetime
+ workspaces: tuple[DirectoryWorkspace, ...] = pydantic.Field(
+ default=(),
+ max_length=HARD_MAX_SNAPSHOT_WORKSPACES,
+ )
+
+ @pydantic.field_validator('workspaces', mode='before')
+ @classmethod
+ def _copy_workspaces(cls, value: Sequence[DirectoryWorkspace] | None) -> tuple[DirectoryWorkspace, ...]:
+ return tuple(value or ())
+
+ @pydantic.model_validator(mode='after')
+ def _validate_workspaces(self) -> DirectorySnapshot:
+ workspace_uuids: set[str] = set()
+ slugs: set[str] = set()
+ membership_uuids: set[str] = set()
+ for workspace in self.workspaces:
+ if workspace.uuid in workspace_uuids:
+ raise ValueError('Directory snapshot contains duplicate Workspace UUIDs')
+ if workspace.slug in slugs:
+ raise ValueError('Directory snapshot contains duplicate Workspace slugs')
+ workspace_uuids.add(workspace.uuid)
+ slugs.add(workspace.slug)
+ for member in workspace.members:
+ if member.membership_uuid in membership_uuids:
+ raise ValueError('Directory snapshot contains duplicate membership UUIDs')
+ membership_uuids.add(member.membership_uuid)
+ return self
+
+
+class DirectoryDelta(pydantic.BaseModel):
+ """Signed authoritative state for an explicitly requested Workspace set."""
+
+ model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
+
+ instance_uuid: str = pydantic.Field(min_length=1, max_length=255)
+ requested_workspace_uuids: tuple[str, ...]
+ generated_at: datetime.datetime
+ workspaces: tuple[DirectoryWorkspace, ...] = ()
+
+ @pydantic.field_validator('requested_workspace_uuids', mode='before')
+ @classmethod
+ def _copy_requested_workspace_uuids(cls, value: Sequence[str]) -> tuple[str, ...]:
+ return tuple(value)
+
+ @pydantic.field_validator('workspaces', mode='before')
+ @classmethod
+ def _copy_workspaces(cls, value: Sequence[DirectoryWorkspace] | None) -> tuple[DirectoryWorkspace, ...]:
+ return tuple(value or ())
+
+ @pydantic.model_validator(mode='after')
+ def _validate_workspaces(self) -> DirectoryDelta:
+ requested = self.requested_workspace_uuids
+ if not requested or len(requested) > 100:
+ raise ValueError('Directory delta must request between 1 and 100 Workspaces')
+ if any(not workspace_uuid or len(workspace_uuid) > 36 for workspace_uuid in requested):
+ raise ValueError('Directory delta contains an invalid requested Workspace UUID')
+ if len(requested) != len(set(requested)):
+ raise ValueError('Directory delta contains duplicate requested Workspace UUIDs')
+
+ requested_set = set(requested)
+ workspace_uuids: set[str] = set()
+ slugs: set[str] = set()
+ membership_uuids: set[str] = set()
+ for workspace in self.workspaces:
+ if workspace.uuid in workspace_uuids:
+ raise ValueError('Directory delta contains duplicate Workspace UUIDs')
+ if workspace.uuid not in requested_set:
+ raise ValueError('Directory delta returned an unrequested Workspace')
+ if workspace.slug in slugs:
+ raise ValueError('Directory delta contains duplicate Workspace slugs')
+ workspace_uuids.add(workspace.uuid)
+ slugs.add(workspace.slug)
+ for member in workspace.members:
+ if member.membership_uuid in membership_uuids:
+ raise ValueError('Directory delta contains duplicate membership UUIDs')
+ membership_uuids.add(member.membership_uuid)
+ return self
+
+
+class DirectoryEvent(pydantic.BaseModel):
+ """One signed control-plane outbox notification."""
+
+ model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
+
+ cursor: int = pydantic.Field(ge=1)
+ uuid: str = pydantic.Field(min_length=1, max_length=36)
+ aggregate_uuid: str = pydantic.Field(min_length=1, max_length=36)
+ event_type: str = pydantic.Field(min_length=1, max_length=128)
+ revision: int = pydantic.Field(ge=1)
+ payload: dict[str, Any] = pydantic.Field(default_factory=dict)
+ created_at: datetime.datetime
+
+
+class DirectoryEventBatch(pydantic.BaseModel):
+ """Signed events returned after a caller-supplied directory cursor."""
+
+ model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
+
+ instance_uuid: str = pydantic.Field(min_length=1, max_length=255)
+ after_cursor: int = pydantic.Field(ge=0)
+ cursor: int = pydantic.Field(ge=0)
+ high_water_cursor: int = pydantic.Field(ge=0)
+ events: tuple[DirectoryEvent, ...] = ()
+
+ @pydantic.field_validator('events', mode='before')
+ @classmethod
+ def _copy_events(cls, value: Sequence[DirectoryEvent] | None) -> tuple[DirectoryEvent, ...]:
+ return tuple(value or ())
+
+ @pydantic.model_validator(mode='after')
+ def _validate_events(self) -> DirectoryEventBatch:
+ if self.cursor < self.after_cursor:
+ raise ValueError('Directory event cursor rolled back')
+ if self.high_water_cursor < self.cursor:
+ raise ValueError('Directory event high-water mark rolled back')
+ event_cursors = [event.cursor for event in self.events]
+ event_uuids = [event.uuid for event in self.events]
+ if event_cursors != sorted(event_cursors) or len(event_cursors) != len(set(event_cursors)):
+ raise ValueError('Directory events must have strictly increasing cursors')
+ if len(event_uuids) != len(set(event_uuids)):
+ raise ValueError('Directory event batch contains duplicate UUIDs')
+ if any(cursor <= self.after_cursor or cursor > self.cursor for cursor in event_cursors):
+ raise ValueError('Directory event falls outside the requested cursor window')
+ if not self.events and (self.cursor != self.after_cursor or self.high_water_cursor != self.after_cursor):
+ raise ValueError('Empty Directory event batch cannot advance or trail the high-water mark')
+ if self.events and self.cursor != self.events[-1].cursor:
+ raise ValueError('Directory event batch cursor must equal its final event cursor')
+ return self
+
+
+@runtime_checkable
+class DirectoryProjectionProvider(Protocol):
+ """Closed adapter that returns signature-verified control-plane data."""
+
+ async def fetch_snapshot(self, instance_uuid: str) -> DirectorySnapshot:
+ """Fetch and verify an authoritative full snapshot."""
+
+ async def fetch_events(
+ self,
+ instance_uuid: str,
+ after_cursor: int,
+ limit: int,
+ ) -> DirectoryEventBatch:
+ """Fetch and verify directory events after one process-local cursor."""
+
+ async def fetch_workspaces(
+ self,
+ instance_uuid: str,
+ workspace_uuids: tuple[str, ...],
+ ) -> DirectoryDelta:
+ """Fetch and verify authoritative state for an explicit Workspace set."""
diff --git a/src/langbot/pkg/cloud/directory_projection.py b/src/langbot/pkg/cloud/directory_projection.py
new file mode 100644
index 000000000..3d0f82e44
--- /dev/null
+++ b/src/langbot/pkg/cloud/directory_projection.py
@@ -0,0 +1,1132 @@
+from __future__ import annotations
+
+import asyncio
+import datetime
+import hashlib
+import json
+import time
+from collections.abc import Callable, Iterable
+from typing import TYPE_CHECKING, Any
+
+import sqlalchemy
+from sqlalchemy.dialects import postgresql, sqlite
+
+from ..entity.persistence.cloud_directory import DirectoryProjectionInbox, DirectoryProjectionState
+from ..entity.persistence.user import AccountSource, AccountStatus, User
+from ..entity.persistence.workspace import (
+ MembershipRole,
+ MembershipStatus,
+ Workspace,
+ WorkspaceExecutionSource,
+ WorkspaceExecutionState,
+ WorkspaceExecutionStatus,
+ WorkspaceMembership,
+ WorkspaceSource,
+ WorkspaceStatus,
+)
+from .directory import (
+ DirectoryDelta,
+ DirectoryEvent,
+ DirectoryEventBatch,
+ DirectoryMember,
+ DirectoryProjectionLimits,
+ DirectoryProjectionProvider,
+ DirectoryProjectionUnavailableError,
+ DirectorySnapshot,
+ DirectoryWorkspace,
+)
+from .entitlements import EntitlementResolver
+
+
+if TYPE_CHECKING:
+ from ..core.app import Application
+
+
+_ROLE_MAP = {
+ 'owner': MembershipRole.OWNER.value,
+ 'admin': MembershipRole.ADMIN.value,
+ # Space deliberately exposes a smaller product role vocabulary. A regular
+ # SaaS member receives the Core developer role; operator/viewer can be
+ # introduced later without changing the signed directory contract.
+ 'member': MembershipRole.DEVELOPER.value,
+ 'developer': MembershipRole.DEVELOPER.value,
+ 'operator': MembershipRole.OPERATOR.value,
+ 'viewer': MembershipRole.VIEWER.value,
+}
+_ACCOUNT_STATUS_MAP = {
+ 'active': AccountStatus.ACTIVE.value,
+ 'blocked': AccountStatus.DISABLED.value,
+ 'disabled': AccountStatus.DISABLED.value,
+ 'deleted': AccountStatus.DELETED.value,
+}
+_MEMBERSHIP_STATUS_MAP = {
+ 'active': MembershipStatus.ACTIVE.value,
+ 'invited': MembershipStatus.DISABLED.value,
+ 'disabled': MembershipStatus.DISABLED.value,
+ 'removed': MembershipStatus.REMOVED.value,
+}
+_INCREMENTAL_PROJECTION_FINGERPRINT = hashlib.sha256(b'langbot-directory-incremental-v1').hexdigest()
+_ACCOUNT_QUERY_CHUNK_SIZE = 500
+
+
+class _DirectorySnapshotSuperseded(DirectoryProjectionUnavailableError):
+ """A valid snapshot lost a race with a newer shared projection."""
+
+
+class DirectoryProjectionService:
+ """Project a verified SaaS directory into Core-owned tenant tables.
+
+ The closed adapter verifies transport signatures and returns immutable
+ models. Core owns database transactions, revision checks, execution fences,
+ and readiness. This keeps the ORM and PostgreSQL RLS boundary out of the
+ closed control-plane package.
+ """
+
+ def __init__(
+ self,
+ ap: Application,
+ provider: DirectoryProjectionProvider,
+ instance_uuid: str,
+ *,
+ sync_interval_seconds: float = 5.0,
+ max_staleness_seconds: float = 60.0,
+ event_limit: int = 100,
+ limits: DirectoryProjectionLimits | None = None,
+ monotonic_time: Callable[[], float] = time.monotonic,
+ ) -> None:
+ if not isinstance(provider, DirectoryProjectionProvider):
+ raise TypeError('Cloud directory projection requires a DirectoryProjectionProvider')
+ if not instance_uuid.strip():
+ raise ValueError('Cloud directory projection requires an instance UUID')
+ if sync_interval_seconds <= 0:
+ raise ValueError('Directory sync interval must be positive')
+ if max_staleness_seconds <= sync_interval_seconds:
+ raise ValueError('Directory max staleness must exceed the sync interval')
+ if event_limit <= 0 or event_limit > 100:
+ raise ValueError('Directory event limit must be between 1 and 100')
+ if limits is not None and not isinstance(limits, DirectoryProjectionLimits):
+ raise TypeError('Directory projection limits must be a DirectoryProjectionLimits value')
+ self.ap = ap
+ self.provider = provider
+ self.instance_uuid = instance_uuid.strip()
+ self.sync_interval_seconds = float(sync_interval_seconds)
+ self.max_staleness_seconds = float(max_staleness_seconds)
+ self.event_limit = event_limit
+ self.limits = limits or DirectoryProjectionLimits()
+ self._monotonic_time = monotonic_time
+ self._last_success_monotonic: float | None = None
+ self._ready = False
+ self._active_workspace_count = 0
+ self._last_batch_workspace_count = 0
+ self._last_batch_membership_count = 0
+ # Every runtime replica must consume the event stream independently:
+ # entitlement snapshots live in the closed adapter's process memory.
+ # The database cursor remains the shared projection high-water mark,
+ # while this cursor tracks what this process has actually observed.
+ self._consumer_cursor: int | None = None
+
+ async def initialize(self) -> None:
+ """Block Cloud startup until one full signed snapshot is committed."""
+
+ last_superseded: _DirectorySnapshotSuperseded | None = None
+ for _attempt in range(5):
+ snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
+ try:
+ await self.apply_snapshot(snapshot)
+ except _DirectorySnapshotSuperseded as exc:
+ last_superseded = exc
+ continue
+ self._consumer_cursor = snapshot.cursor
+ return
+ raise DirectoryProjectionUnavailableError(
+ 'Directory snapshot was repeatedly superseded by another runtime replica'
+ ) from last_superseded
+
+ async def run(self) -> None:
+ """Continuously refresh the directory and fail closed when it goes stale."""
+
+ delay = self.sync_interval_seconds
+ while True:
+ try:
+ await asyncio.sleep(delay)
+ await self.sync_once()
+ delay = self.sync_interval_seconds
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ self.ap.logger.exception('Cloud directory synchronization failed')
+ delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
+
+ async def sync_once(self) -> None:
+ cursor = self._consumer_cursor
+ if cursor is None:
+ await self.initialize()
+ return
+ batch = await self.provider.fetch_events(
+ self.instance_uuid,
+ cursor,
+ self.event_limit,
+ )
+ batch = DirectoryEventBatch.model_validate(batch.model_dump())
+ self._validate_batch(batch, expected_after_cursor=cursor)
+ if batch.events:
+ directory_revisions = self._directory_event_revisions(batch.events)
+ if directory_revisions:
+ requested_workspace_uuids = tuple(sorted(directory_revisions))
+ delta = await self.provider.fetch_workspaces(
+ self.instance_uuid,
+ requested_workspace_uuids,
+ )
+ await self.apply_delta(delta, batch)
+ else:
+ await self.apply_event_batch(batch)
+ self._consumer_cursor = batch.cursor
+ return
+ await self._touch_freshness(cursor)
+
+ def require_ready(self) -> None:
+ """Fail synchronously at execution admission when projection is stale."""
+
+ last_success = self._last_success_monotonic
+ if not self._ready or last_success is None:
+ raise DirectoryProjectionUnavailableError('Cloud directory projection is not ready')
+ if self._monotonic_time() - last_success >= self.max_staleness_seconds:
+ raise DirectoryProjectionUnavailableError('Cloud directory projection is stale')
+
+ def resource_snapshot(self) -> dict[str, int]:
+ """Return aggregate, tenant-free cardinality gauges for health checks."""
+
+ return {
+ 'active_workspaces': self._active_workspace_count,
+ 'max_active_workspaces': self.limits.max_active_workspaces,
+ 'last_batch_workspaces': self._last_batch_workspace_count,
+ 'last_batch_memberships': self._last_batch_membership_count,
+ 'max_snapshot_workspaces': self.limits.max_snapshot_workspaces,
+ 'max_snapshot_memberships': self.limits.max_snapshot_memberships,
+ }
+
+ def _validate_batch_capacity(
+ self,
+ workspaces: tuple[DirectoryWorkspace, ...],
+ *,
+ full_snapshot: bool,
+ ) -> tuple[int, int]:
+ workspace_count = len(workspaces)
+ if full_snapshot and workspace_count > self.limits.max_snapshot_workspaces:
+ raise DirectoryProjectionUnavailableError(
+ 'Directory snapshot Workspace capacity exceeded '
+ f'({workspace_count} > {self.limits.max_snapshot_workspaces})'
+ )
+
+ active_count = 0
+ membership_count = 0
+ for workspace in workspaces:
+ if workspace.status == WorkspaceStatus.ACTIVE.value:
+ active_count += 1
+ membership_count += len(workspace.members)
+ if membership_count > self.limits.max_snapshot_memberships:
+ raise DirectoryProjectionUnavailableError(
+ 'Directory membership capacity exceeded '
+ f'({membership_count} > {self.limits.max_snapshot_memberships})'
+ )
+ if active_count > self.limits.max_active_workspaces:
+ raise DirectoryProjectionUnavailableError(
+ f'Directory active Workspace capacity exceeded ({active_count} > {self.limits.max_active_workspaces})'
+ )
+ return workspace_count, membership_count
+
+ async def _enforce_active_workspace_capacity(self, session: Any) -> int:
+ """Count the committed candidate state while holding the projection lock.
+
+ Full snapshots can validate their own active count before doing any
+ database work. Incremental deltas cannot know the instance total, so
+ every projection path also checks the database after applying fences.
+ The caller holds the per-instance DirectoryProjectionState row lock;
+ concurrent replicas therefore cannot race two individually-admitted
+ deltas above the instance ceiling.
+ """
+
+ active_count = int(
+ (
+ await session.scalar(
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(Workspace)
+ .where(
+ Workspace.instance_uuid == self.instance_uuid,
+ Workspace.source == WorkspaceSource.CLOUD_PROJECTION.value,
+ Workspace.status == WorkspaceStatus.ACTIVE.value,
+ )
+ )
+ )
+ or 0
+ )
+ if active_count > self.limits.max_active_workspaces:
+ raise DirectoryProjectionUnavailableError(
+ f'Projected active Workspace capacity exceeded ({active_count} > {self.limits.max_active_workspaces})'
+ )
+ return active_count
+
+ def _record_batch_cardinality(
+ self,
+ *,
+ active_workspaces: int,
+ workspaces: int,
+ memberships: int,
+ ) -> None:
+ self._active_workspace_count = active_workspaces
+ self._last_batch_workspace_count = workspaces
+ self._last_batch_membership_count = memberships
+
+ async def apply_snapshot(
+ self,
+ snapshot: DirectorySnapshot,
+ *,
+ events: Iterable[DirectoryEvent] = (),
+ ) -> None:
+ """Atomically apply one monotonic full snapshot and its event receipts."""
+
+ if not isinstance(snapshot, DirectorySnapshot):
+ raise DirectoryProjectionUnavailableError('Directory provider returned an invalid snapshot')
+ workspace_count, membership_count = self._validate_batch_capacity(
+ snapshot.workspaces,
+ full_snapshot=True,
+ )
+ snapshot = DirectorySnapshot.model_validate(snapshot.model_dump())
+ if snapshot.instance_uuid != self.instance_uuid:
+ raise DirectoryProjectionUnavailableError('Directory snapshot targets another LangBot instance')
+ fingerprint = self._snapshot_fingerprint(snapshot)
+ now = self._utcnow()
+ lease_expires_at = now + datetime.timedelta(seconds=self.max_staleness_seconds)
+ event_list = tuple(DirectoryEvent.model_validate(event.model_dump()) for event in events)
+
+ directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
+ if not callable(directory_uow):
+ raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
+
+ async with directory_uow(self.instance_uuid) as uow:
+ session = uow.session
+ state_values = {
+ 'instance_uuid': self.instance_uuid,
+ 'cursor': snapshot.cursor,
+ 'snapshot_coverage_cursor': snapshot.cursor,
+ 'snapshot_fingerprint': fingerprint,
+ 'last_applied_at': now,
+ 'lease_expires_at': lease_expires_at,
+ }
+ dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
+ if dialect_name == 'postgresql':
+ insert_state = postgresql.insert(DirectoryProjectionState)
+ elif dialect_name == 'sqlite':
+ insert_state = sqlite.insert(DirectoryProjectionState)
+ else: # pragma: no cover - Cloud supports PostgreSQL; tests use SQLite.
+ raise DirectoryProjectionUnavailableError('Directory projection database is unsupported')
+ await session.execute(
+ insert_state.values(**state_values).on_conflict_do_nothing(
+ index_elements=[DirectoryProjectionState.instance_uuid]
+ )
+ )
+ state = await session.scalar(
+ sqlalchemy.select(DirectoryProjectionState)
+ .where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
+ .with_for_update()
+ )
+ if state is None: # pragma: no cover - insert/select are one transaction.
+ raise DirectoryProjectionUnavailableError('Directory projection state could not be locked')
+ if snapshot.cursor < state.cursor:
+ raise _DirectorySnapshotSuperseded('Directory snapshot cursor rolled back')
+ if snapshot.cursor == state.cursor and state.snapshot_fingerprint not in {
+ fingerprint,
+ _INCREMENTAL_PROJECTION_FINGERPRINT,
+ }:
+ raise DirectoryProjectionUnavailableError('Directory snapshot cursor has conflicting contents')
+
+ await self._record_events(session, event_list, now=now)
+ accounts_by_uuid = await self._apply_accounts(session, snapshot)
+ await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
+ await self._fence_absent_workspaces(session, snapshot)
+ active_workspace_count = await self._enforce_active_workspace_capacity(session)
+
+ state.cursor = snapshot.cursor
+ state.snapshot_coverage_cursor = snapshot.cursor
+ state.snapshot_fingerprint = fingerprint
+ state.last_applied_at = now
+ state.lease_expires_at = lease_expires_at
+
+ await self._mark_events_applied(session, event_list, now=now)
+
+ await session.flush()
+
+ await self._reconcile_entitlement_snapshot_set(snapshot)
+ self._publish_runtime_execution_projection(snapshot.workspaces)
+ self._record_batch_cardinality(
+ active_workspaces=active_workspace_count,
+ workspaces=workspace_count,
+ memberships=membership_count,
+ )
+ self._record_success()
+ self._consumer_cursor = snapshot.cursor
+
+ async def apply_delta(self, delta: DirectoryDelta, batch: DirectoryEventBatch) -> None:
+ """Apply only Workspaces named by directory events in one signed page."""
+
+ if not isinstance(delta, DirectoryDelta):
+ raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
+ if not isinstance(batch, DirectoryEventBatch):
+ raise DirectoryProjectionUnavailableError('Directory provider returned an invalid event batch')
+ workspace_count, membership_count = self._validate_batch_capacity(
+ delta.workspaces,
+ full_snapshot=False,
+ )
+ delta = DirectoryDelta.model_validate(delta.model_dump())
+ batch = DirectoryEventBatch.model_validate(batch.model_dump())
+ self._validate_batch(batch, expected_after_cursor=batch.after_cursor)
+ if delta.instance_uuid != self.instance_uuid:
+ raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
+
+ required_revisions = self._directory_event_revisions(batch.events)
+ requested = set(delta.requested_workspace_uuids)
+ if not required_revisions or requested != set(required_revisions):
+ raise DirectoryProjectionUnavailableError('Directory delta does not match its event batch')
+ returned = {workspace.uuid: workspace for workspace in delta.workspaces}
+ for workspace_uuid, workspace in returned.items():
+ if workspace.projection_revision < required_revisions[workspace_uuid]:
+ raise DirectoryProjectionUnavailableError(
+ 'Directory Workspace delta is older than its signed event notification'
+ )
+
+ now = self._utcnow()
+ lease_expires_at = now + datetime.timedelta(seconds=self.max_staleness_seconds)
+ directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
+ if not callable(directory_uow):
+ raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
+
+ projection_caught_up = False
+ async with directory_uow(self.instance_uuid) as uow:
+ session = uow.session
+ state = await session.scalar(
+ sqlalchemy.select(DirectoryProjectionState)
+ .where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
+ .with_for_update()
+ )
+ if state is None:
+ raise DirectoryProjectionUnavailableError('Directory projection state disappeared')
+ state_cursor = int(state.cursor)
+ if state_cursor < batch.after_cursor:
+ raise DirectoryProjectionUnavailableError('Directory projection state cursor rolled back')
+
+ # A different runtime replica may already have applied this page.
+ # In that case every receipt through the shared cursor must exist;
+ # this replica still fetched the delta and refreshed its own
+ # entitlement cache before advancing its process-local cursor.
+ await self._record_events(
+ session,
+ batch.events,
+ now=now,
+ allow_missing_through_cursor=int(state.snapshot_coverage_cursor),
+ reject_missing_through_cursor=state_cursor,
+ )
+ if state_cursor < batch.cursor:
+ projected_delta = DirectorySnapshot(
+ instance_uuid=self.instance_uuid,
+ cursor=batch.cursor,
+ generated_at=delta.generated_at,
+ workspaces=delta.workspaces,
+ )
+ accounts_by_uuid = await self._apply_accounts(session, projected_delta)
+ await self._apply_workspaces(
+ session,
+ projected_delta,
+ accounts_by_uuid=accounts_by_uuid,
+ )
+ await self._fence_workspaces(
+ session,
+ {
+ workspace_uuid: required_revisions[workspace_uuid]
+ for workspace_uuid in requested - set(returned)
+ },
+ )
+ state.cursor = batch.cursor
+ # A per-Workspace delta cannot prove a full-directory
+ # fingerprint. Event receipts and entity revisions protect the
+ # incremental path; a later full snapshot replaces this marker.
+ state.snapshot_fingerprint = _INCREMENTAL_PROJECTION_FINGERPRINT
+
+ active_workspace_count = await self._enforce_active_workspace_capacity(session)
+ state.last_applied_at = now
+ state.lease_expires_at = lease_expires_at
+ await self._mark_events_applied(session, batch.events, now=now)
+ await session.flush()
+ projection_caught_up = batch.cursor == batch.high_water_cursor and int(state.cursor) == batch.cursor
+
+ await self._update_entitlement_workspace_activity(
+ returned.values(),
+ requested_workspace_uuids=requested,
+ )
+ self._publish_runtime_execution_projection(
+ returned.values(),
+ affected_workspace_uuids=requested,
+ )
+ self._record_batch_cardinality(
+ active_workspaces=active_workspace_count,
+ workspaces=workspace_count,
+ memberships=membership_count,
+ )
+ if projection_caught_up:
+ self._record_success()
+ self._consumer_cursor = batch.cursor
+
+ def _publish_runtime_execution_projection(
+ self,
+ workspaces: Iterable[DirectoryWorkspace],
+ *,
+ affected_workspace_uuids: set[str] | None = None,
+ ) -> None:
+ """Retire stale runtime scopes without per-session database polling.
+
+ The signed directory transaction is already committed when this hook
+ runs. Runtime calls still validate the database fence before and after
+ side effects; this notification only releases idle resources promptly.
+ """
+
+ tool_manager = getattr(self.ap, 'tool_mgr', None)
+ mcp_loader = getattr(tool_manager, 'mcp_tool_loader', None)
+ reconcile = getattr(mcp_loader, 'reconcile_execution_projection', None)
+ if not callable(reconcile):
+ return
+ active_generations = {
+ workspace.uuid: workspace.execution_generation
+ for workspace in workspaces
+ if workspace.status == WorkspaceStatus.ACTIVE.value
+ }
+ try:
+ reconcile(
+ self.instance_uuid,
+ active_generations,
+ affected_workspace_uuids=affected_workspace_uuids,
+ )
+ except Exception:
+ # Runtime retirement is a resource cleanup path, not an execution
+ # admission boundary. Database-backed call-time fences remain
+ # authoritative if a local runtime hook fails.
+ self.ap.logger.exception('Failed to publish the Cloud execution projection to MCP runtimes')
+
+ async def _reconcile_entitlement_snapshot_set(
+ self,
+ snapshot: DirectorySnapshot,
+ ) -> None:
+ resolver = getattr(self.ap, 'entitlement_resolver', None)
+ if not isinstance(resolver, EntitlementResolver):
+ return
+ await resolver.reconcile_active_workspaces(
+ {workspace.uuid for workspace in snapshot.workspaces if workspace.status == WorkspaceStatus.ACTIVE.value}
+ )
+
+ async def _update_entitlement_workspace_activity(
+ self,
+ workspaces: Iterable[DirectoryWorkspace],
+ *,
+ requested_workspace_uuids: set[str],
+ ) -> None:
+ resolver = getattr(self.ap, 'entitlement_resolver', None)
+ if not isinstance(resolver, EntitlementResolver):
+ return
+ returned = {workspace.uuid: workspace for workspace in workspaces}
+ active = {
+ workspace_uuid
+ for workspace_uuid, workspace in returned.items()
+ if workspace.status == WorkspaceStatus.ACTIVE.value
+ }
+ await resolver.update_workspace_activity(
+ active_workspace_uuids=active,
+ inactive_workspace_uuids=requested_workspace_uuids - active,
+ )
+
+ async def apply_event_batch(self, batch: DirectoryEventBatch) -> None:
+ """Advance non-directory events after the adapter refreshes local caches."""
+
+ if not isinstance(batch, DirectoryEventBatch):
+ raise DirectoryProjectionUnavailableError('Directory provider returned an invalid event batch')
+ batch = DirectoryEventBatch.model_validate(batch.model_dump())
+ self._validate_batch(batch, expected_after_cursor=batch.after_cursor)
+ if any(event.event_type == 'directory.changed' for event in batch.events):
+ raise DirectoryProjectionUnavailableError('Directory changes require an authoritative full snapshot')
+
+ now = self._utcnow()
+ lease_expires_at = now + datetime.timedelta(seconds=self.max_staleness_seconds)
+ directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
+ if not callable(directory_uow):
+ raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
+ projection_caught_up = False
+ async with directory_uow(self.instance_uuid) as uow:
+ state = await uow.session.scalar(
+ sqlalchemy.select(DirectoryProjectionState)
+ .where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
+ .with_for_update()
+ )
+ if state is None:
+ raise DirectoryProjectionUnavailableError('Directory projection state disappeared')
+ if state.cursor < batch.after_cursor:
+ raise DirectoryProjectionUnavailableError('Directory projection state cursor rolled back')
+ await self._record_events(
+ uow.session,
+ batch.events,
+ now=now,
+ allow_missing_through_cursor=int(state.snapshot_coverage_cursor),
+ reject_missing_through_cursor=int(state.cursor),
+ )
+ state.cursor = max(int(state.cursor), batch.cursor)
+ state.last_applied_at = now
+ state.lease_expires_at = lease_expires_at
+ await self._mark_events_applied(uow.session, batch.events, now=now)
+ await uow.session.flush()
+ projection_caught_up = batch.cursor == batch.high_water_cursor and int(state.cursor) == batch.cursor
+ if projection_caught_up:
+ self._record_success()
+ self._consumer_cursor = batch.cursor
+
+ async def _touch_freshness(self, requested_cursor: int) -> None:
+ now = self._utcnow()
+ lease_expires_at = now + datetime.timedelta(seconds=self.max_staleness_seconds)
+ directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
+ if not callable(directory_uow):
+ raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
+ async with directory_uow(self.instance_uuid) as uow:
+ state = await uow.session.scalar(
+ sqlalchemy.select(DirectoryProjectionState)
+ .where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
+ .with_for_update()
+ )
+ if state is None:
+ raise DirectoryProjectionUnavailableError('Directory projection state disappeared')
+ if state.cursor < requested_cursor:
+ raise DirectoryProjectionUnavailableError('Directory projection state cursor rolled back')
+ if state.cursor > requested_cursor:
+ raise DirectoryProjectionUnavailableError(
+ 'This runtime replica has not consumed the shared directory high-water mark'
+ )
+ state.last_applied_at = now
+ state.lease_expires_at = lease_expires_at
+ await uow.session.flush()
+ self._record_success()
+
+ def _validate_batch(self, batch: DirectoryEventBatch, *, expected_after_cursor: int) -> None:
+ if batch.instance_uuid != self.instance_uuid:
+ raise DirectoryProjectionUnavailableError('Directory event batch targets another LangBot instance')
+ if batch.after_cursor != expected_after_cursor:
+ raise DirectoryProjectionUnavailableError('Directory event batch does not match the requested cursor')
+ supported_event_types = {'directory.changed', 'entitlement.changed'}
+ if any(event.event_type not in supported_event_types for event in batch.events):
+ raise DirectoryProjectionUnavailableError('Directory event batch contains an unsupported event type')
+ for event in batch.events:
+ if event.payload.get('workspace_uuid') != event.aggregate_uuid:
+ raise DirectoryProjectionUnavailableError('Directory event payload has a conflicting Workspace scope')
+ revision_key = 'directory_revision' if event.event_type == 'directory.changed' else 'entitlement_revision'
+ payload_revision = event.payload.get(revision_key)
+ if type(payload_revision) is not int or payload_revision != event.revision:
+ raise DirectoryProjectionUnavailableError('Directory event payload has a conflicting revision')
+
+ @staticmethod
+ def _directory_event_revisions(events: Iterable[DirectoryEvent]) -> dict[str, int]:
+ revisions: dict[str, int] = {}
+ for event in events:
+ if event.event_type == 'directory.changed':
+ revisions[event.aggregate_uuid] = max(revisions.get(event.aggregate_uuid, 0), event.revision)
+ return revisions
+
+ async def _record_events(
+ self,
+ session: Any,
+ events: tuple[DirectoryEvent, ...],
+ *,
+ now: datetime.datetime,
+ allow_missing_through_cursor: int = -1,
+ reject_missing_through_cursor: int | None = None,
+ ) -> None:
+ for event in events:
+ fingerprint = self._fingerprint(event.model_dump(mode='json'))
+ existing = await session.scalar(
+ sqlalchemy.select(DirectoryProjectionInbox).where(
+ DirectoryProjectionInbox.instance_uuid == self.instance_uuid,
+ DirectoryProjectionInbox.event_uuid == event.uuid,
+ )
+ )
+ if existing is not None:
+ if existing.cursor != event.cursor or existing.fingerprint != fingerprint:
+ raise DirectoryProjectionUnavailableError('Directory event UUID has conflicting contents')
+ continue
+ if (
+ reject_missing_through_cursor is not None
+ and allow_missing_through_cursor < event.cursor <= reject_missing_through_cursor
+ ):
+ raise DirectoryProjectionUnavailableError(
+ 'Directory projection cursor advanced without a matching event receipt'
+ )
+ session.add(
+ DirectoryProjectionInbox(
+ instance_uuid=self.instance_uuid,
+ event_uuid=event.uuid,
+ cursor=event.cursor,
+ event_type=event.event_type,
+ revision=event.revision,
+ fingerprint=fingerprint,
+ received_at=now,
+ applied_at=None,
+ )
+ )
+
+ async def _mark_events_applied(
+ self,
+ session: Any,
+ events: Iterable[DirectoryEvent],
+ *,
+ now: datetime.datetime,
+ ) -> None:
+ event_uuids = [event.uuid for event in events]
+ if not event_uuids:
+ return
+ inbox_rows = (
+ await session.scalars(
+ sqlalchemy.select(DirectoryProjectionInbox).where(
+ DirectoryProjectionInbox.instance_uuid == self.instance_uuid,
+ DirectoryProjectionInbox.event_uuid.in_(event_uuids),
+ )
+ )
+ ).all()
+ if len(inbox_rows) != len(event_uuids):
+ raise DirectoryProjectionUnavailableError('Directory event receipt could not be persisted')
+ for row in inbox_rows:
+ row.applied_at = now
+
+ async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
+ selected: dict[str, DirectoryMember] = {}
+ emails: dict[str, str] = {}
+ for workspace in snapshot.workspaces:
+ for member in workspace.members:
+ email_owner = emails.setdefault(member.normalized_email, member.account_uuid)
+ if email_owner != member.account_uuid:
+ raise DirectoryProjectionUnavailableError(
+ 'Directory snapshot maps one normalized email to multiple accounts'
+ )
+ previous = selected.get(member.account_uuid)
+ if previous is not None and self._account_projection(previous) != self._account_projection(member):
+ raise DirectoryProjectionUnavailableError('Directory snapshot has conflicting account projections')
+ if previous is None:
+ selected[member.account_uuid] = member
+
+ # Fetch existing UUID and email owners in bounded batches. The previous
+ # two SELECTs per unique account made a large but valid directory
+ # snapshot produce tens of thousands of serial round trips during
+ # startup. The configured membership ceiling bounds the materialized
+ # maps, while batching stays below PostgreSQL parameter limits.
+ accounts_by_uuid: dict[str, User] = {}
+ accounts_by_email: dict[str, User] = {}
+ selected_items = list(selected.items())
+ for start in range(0, len(selected_items), _ACCOUNT_QUERY_CHUNK_SIZE):
+ chunk = selected_items[start : start + _ACCOUNT_QUERY_CHUNK_SIZE]
+ account_uuids = [account_uuid for account_uuid, _member in chunk]
+ normalized_emails = [member.normalized_email for _account_uuid, member in chunk]
+ rows = (
+ await session.scalars(
+ sqlalchemy.select(User).where(
+ sqlalchemy.or_(
+ User.uuid.in_(account_uuids),
+ User.normalized_email.in_(normalized_emails),
+ )
+ )
+ )
+ ).all()
+ for account in rows:
+ accounts_by_uuid[account.uuid] = account
+ accounts_by_email[account.normalized_email] = account
+
+ for account_uuid, member in selected.items():
+ account = accounts_by_uuid.get(account_uuid)
+ email_account = accounts_by_email.get(member.normalized_email)
+ if email_account is not None and email_account.uuid != account_uuid:
+ raise DirectoryProjectionUnavailableError('Directory account email collides with another Core account')
+ if account is None:
+ account = User(
+ uuid=account_uuid,
+ user=member.display_name,
+ normalized_email=member.normalized_email,
+ password='',
+ status=_ACCOUNT_STATUS_MAP[member.account_status],
+ source=AccountSource.CLOUD_PROJECTION.value,
+ projection_revision=snapshot.cursor,
+ account_type='space',
+ space_account_uuid=account_uuid,
+ )
+ session.add(account)
+ accounts_by_uuid[account_uuid] = account
+ accounts_by_email[member.normalized_email] = account
+ continue
+ if account.source != AccountSource.CLOUD_PROJECTION.value:
+ raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
+ if account.projection_revision > snapshot.cursor:
+ raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
+ projected_account = self._account_projection(member)
+ persisted_account = self._persisted_account_projection(account)
+ if account.projection_revision == snapshot.cursor and persisted_account != projected_account:
+ raise DirectoryProjectionUnavailableError('Directory account revision has conflicting contents')
+ if persisted_account == projected_account:
+ # A Workspace rename, role update, or another member's change
+ # must not revoke this Account's JWT. Account revisions advance
+ # only when the Account projection itself changes.
+ continue
+ account.user = member.display_name
+ account.normalized_email = member.normalized_email
+ account.status = _ACCOUNT_STATUS_MAP[member.account_status]
+ account.projection_revision = snapshot.cursor
+ account.account_type = 'space'
+ account.space_account_uuid = account_uuid
+ await session.flush()
+ return accounts_by_uuid
+
+ async def _apply_workspaces(
+ self,
+ session: Any,
+ snapshot: DirectorySnapshot,
+ *,
+ accounts_by_uuid: dict[str, User],
+ ) -> None:
+ for candidate in snapshot.workspaces:
+ workspace = await session.get(Workspace, candidate.uuid)
+ if workspace is None:
+ workspace = Workspace(
+ uuid=candidate.uuid,
+ instance_uuid=self.instance_uuid,
+ name=candidate.name,
+ slug=candidate.slug,
+ type=candidate.type,
+ status=candidate.status,
+ created_by_account_uuid=self._projected_creator_uuid(candidate, accounts_by_uuid),
+ source=WorkspaceSource.CLOUD_PROJECTION.value,
+ projection_revision=candidate.projection_revision,
+ )
+ session.add(workspace)
+ await session.flush()
+ else:
+ self._validate_existing_workspace(workspace, candidate)
+ workspace.name = candidate.name
+ workspace.slug = candidate.slug
+ workspace.type = candidate.type
+ workspace.status = candidate.status
+ workspace.created_by_account_uuid = self._projected_creator_uuid(candidate, accounts_by_uuid)
+ workspace.projection_revision = candidate.projection_revision
+
+ await self._apply_memberships(session, workspace, candidate)
+ await self._apply_execution_state(session, workspace, candidate)
+
+ @staticmethod
+ def _projected_creator_uuid(
+ candidate: DirectoryWorkspace,
+ accounts_by_uuid: dict[str, User],
+ ) -> str | None:
+ creator = accounts_by_uuid.get(candidate.created_by_account_uuid)
+ if creator is None:
+ if candidate.status == WorkspaceStatus.ACTIVE.value:
+ raise DirectoryProjectionUnavailableError('Active Directory Workspace creator is not projected')
+ return None
+ return candidate.created_by_account_uuid
+
+ def _validate_existing_workspace(self, workspace: Workspace, candidate: DirectoryWorkspace) -> None:
+ if workspace.instance_uuid != self.instance_uuid:
+ raise DirectoryProjectionUnavailableError('Directory Workspace belongs to another LangBot instance')
+ if workspace.source != WorkspaceSource.CLOUD_PROJECTION.value:
+ raise DirectoryProjectionUnavailableError('Directory Workspace UUID collides with a local Workspace')
+ if workspace.projection_revision > candidate.projection_revision:
+ raise DirectoryProjectionUnavailableError('Directory Workspace revision rolled back')
+ if workspace.projection_revision == candidate.projection_revision and self._workspace_projection(
+ workspace
+ ) != self._candidate_workspace_projection(candidate):
+ raise DirectoryProjectionUnavailableError('Directory Workspace revision has conflicting contents')
+
+ async def _apply_memberships(
+ self,
+ session: Any,
+ workspace: Workspace,
+ candidate: DirectoryWorkspace,
+ ) -> None:
+ existing = {
+ membership.account_uuid: membership
+ for membership in (
+ await session.scalars(
+ sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.workspace_uuid == workspace.uuid)
+ )
+ ).all()
+ }
+ included_accounts: set[str] = set()
+ for member in candidate.members:
+ included_accounts.add(member.account_uuid)
+ membership = existing.get(member.account_uuid)
+ joined_at = self._naive_utc(member.joined_at)
+ role = _ROLE_MAP[member.role]
+ status = _MEMBERSHIP_STATUS_MAP[member.membership_status]
+ if candidate.status != WorkspaceStatus.ACTIVE.value:
+ status = (
+ MembershipStatus.REMOVED.value
+ if candidate.status in {WorkspaceStatus.ARCHIVED.value, WorkspaceStatus.DELETED.value}
+ else MembershipStatus.DISABLED.value
+ )
+ if membership is None:
+ session.add(
+ WorkspaceMembership(
+ uuid=member.membership_uuid,
+ workspace_uuid=workspace.uuid,
+ account_uuid=member.account_uuid,
+ role=role,
+ status=status,
+ joined_at=joined_at,
+ projection_revision=member.projection_revision,
+ )
+ )
+ continue
+ if membership.projection_revision == 0:
+ # Revision zero is Core-owned collaboration state. Directory
+ # projection seeds memberships, but must not overwrite later
+ # invitation, role, or removal decisions made by Core.
+ continue
+ if membership.uuid != member.membership_uuid:
+ raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account')
+ if membership.projection_revision > member.projection_revision:
+ raise DirectoryProjectionUnavailableError('Directory membership revision rolled back')
+ if membership.projection_revision == member.projection_revision and self._membership_projection(
+ membership
+ ) != (role, status, self._datetime_fingerprint(joined_at)):
+ raise DirectoryProjectionUnavailableError('Directory membership revision has conflicting contents')
+ membership.role = role
+ membership.status = status
+ membership.joined_at = joined_at
+ membership.projection_revision = member.projection_revision
+
+ for account_uuid, membership in existing.items():
+ if account_uuid not in included_accounts and membership.projection_revision != 0:
+ membership.status = MembershipStatus.REMOVED.value
+ membership.projection_revision = max(
+ int(membership.projection_revision),
+ candidate.projection_revision,
+ )
+ await session.flush()
+
+ async def _apply_execution_state(
+ self,
+ session: Any,
+ workspace: Workspace,
+ candidate: DirectoryWorkspace,
+ ) -> None:
+ active = candidate.status == WorkspaceStatus.ACTIVE.value
+ desired_state = WorkspaceExecutionStatus.ACTIVE.value if active else WorkspaceExecutionStatus.INACTIVE.value
+ execution = await session.get(WorkspaceExecutionState, workspace.uuid)
+ if execution is None:
+ session.add(
+ WorkspaceExecutionState(
+ workspace_uuid=workspace.uuid,
+ instance_uuid=self.instance_uuid,
+ active_generation=candidate.execution_generation,
+ state=desired_state,
+ write_fenced=not active,
+ source=WorkspaceExecutionSource.CLOUD.value,
+ desired_state_revision=candidate.projection_revision,
+ )
+ )
+ await session.flush()
+ return
+ if execution.instance_uuid != self.instance_uuid or execution.source != WorkspaceExecutionSource.CLOUD.value:
+ raise DirectoryProjectionUnavailableError('Directory execution state has an invalid owner')
+ if execution.active_generation > candidate.execution_generation:
+ raise DirectoryProjectionUnavailableError('Directory execution generation rolled back')
+ if execution.desired_state_revision > candidate.projection_revision:
+ raise DirectoryProjectionUnavailableError('Directory desired-state revision rolled back')
+ if execution.desired_state_revision == candidate.projection_revision and (
+ execution.active_generation != candidate.execution_generation
+ or execution.state != desired_state
+ or execution.write_fenced != (not active)
+ ):
+ raise DirectoryProjectionUnavailableError(
+ 'Directory execution state has conflicting contents at one revision'
+ )
+ execution.active_generation = candidate.execution_generation
+ execution.state = desired_state
+ execution.write_fenced = not active
+ execution.desired_state_revision = candidate.projection_revision
+ await session.flush()
+
+ async def _fence_absent_workspaces(self, session: Any, snapshot: DirectorySnapshot) -> None:
+ included = {workspace.uuid for workspace in snapshot.workspaces}
+ projected = (
+ await session.scalars(
+ sqlalchemy.select(Workspace)
+ .outerjoin(
+ WorkspaceExecutionState,
+ WorkspaceExecutionState.workspace_uuid == Workspace.uuid,
+ )
+ .where(
+ Workspace.instance_uuid == self.instance_uuid,
+ Workspace.source == WorkspaceSource.CLOUD_PROJECTION.value,
+ sqlalchemy.or_(
+ Workspace.status.not_in(
+ (
+ WorkspaceStatus.ARCHIVED.value,
+ WorkspaceStatus.DELETED.value,
+ )
+ ),
+ WorkspaceExecutionState.state == WorkspaceExecutionStatus.ACTIVE.value,
+ WorkspaceExecutionState.write_fenced == sqlalchemy.false(),
+ ),
+ )
+ )
+ ).all()
+ for workspace in projected:
+ if workspace.uuid in included:
+ continue
+ workspace.status = WorkspaceStatus.ARCHIVED.value
+ await self._remove_workspace_memberships(session, workspace.uuid)
+ execution = await session.get(WorkspaceExecutionState, workspace.uuid)
+ if execution is not None:
+ execution.state = WorkspaceExecutionStatus.INACTIVE.value
+ execution.write_fenced = True
+ await session.flush()
+
+ async def _fence_workspaces(self, session: Any, workspace_revisions: dict[str, int]) -> None:
+ """Fence requested Workspaces omitted from an authoritative delta."""
+
+ if not workspace_revisions:
+ return
+ projected = (
+ await session.scalars(
+ sqlalchemy.select(Workspace).where(
+ Workspace.instance_uuid == self.instance_uuid,
+ Workspace.source == WorkspaceSource.CLOUD_PROJECTION.value,
+ Workspace.uuid.in_(workspace_revisions),
+ )
+ )
+ ).all()
+ for workspace in projected:
+ tombstone_revision = workspace_revisions[workspace.uuid]
+ if int(workspace.projection_revision) > tombstone_revision:
+ raise DirectoryProjectionUnavailableError('Directory Workspace tombstone revision rolled back')
+ memberships = (
+ await session.scalars(
+ sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.workspace_uuid == workspace.uuid)
+ )
+ ).all()
+ if any(int(membership.projection_revision) > tombstone_revision for membership in memberships):
+ raise DirectoryProjectionUnavailableError('Directory membership tombstone revision rolled back')
+ execution = await session.get(WorkspaceExecutionState, workspace.uuid)
+ if execution is not None and int(execution.desired_state_revision) > tombstone_revision:
+ raise DirectoryProjectionUnavailableError('Directory execution tombstone revision rolled back')
+ workspace.status = WorkspaceStatus.ARCHIVED.value
+ workspace.projection_revision = max(int(workspace.projection_revision), tombstone_revision)
+ await self._remove_workspace_memberships(
+ session,
+ workspace.uuid,
+ projection_revision=tombstone_revision,
+ memberships=memberships,
+ )
+ if execution is not None:
+ execution.state = WorkspaceExecutionStatus.INACTIVE.value
+ execution.write_fenced = True
+ execution.desired_state_revision = max(
+ int(execution.desired_state_revision),
+ tombstone_revision,
+ )
+ await session.flush()
+
+ async def _remove_workspace_memberships(
+ self,
+ session: Any,
+ workspace_uuid: str,
+ *,
+ projection_revision: int | None = None,
+ memberships: Iterable[WorkspaceMembership] | None = None,
+ ) -> None:
+ if memberships is None:
+ memberships = (
+ await session.scalars(
+ sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.workspace_uuid == workspace_uuid)
+ )
+ ).all()
+ for membership in memberships:
+ membership.status = MembershipStatus.REMOVED.value
+ if projection_revision is not None:
+ membership.projection_revision = max(
+ int(membership.projection_revision),
+ projection_revision,
+ )
+
+ def _record_success(self) -> None:
+ self._last_success_monotonic = self._monotonic_time()
+ self._ready = True
+
+ @classmethod
+ def _snapshot_fingerprint(cls, snapshot: DirectorySnapshot) -> str:
+ workspaces = []
+ for workspace in sorted(snapshot.workspaces, key=lambda item: item.uuid):
+ data = workspace.model_dump(mode='json')
+ data['members'] = sorted(data['members'], key=lambda item: item['membership_uuid'])
+ workspaces.append(data)
+ return cls._fingerprint(
+ {
+ 'instance_uuid': snapshot.instance_uuid,
+ 'workspaces': workspaces,
+ }
+ )
+
+ @staticmethod
+ def _fingerprint(value: Any) -> str:
+ encoded = json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode()
+ return hashlib.sha256(encoded).hexdigest()
+
+ @staticmethod
+ def _account_projection(member: DirectoryMember) -> tuple[str, str, str]:
+ return member.normalized_email, member.display_name, _ACCOUNT_STATUS_MAP[member.account_status]
+
+ @staticmethod
+ def _persisted_account_projection(account: User) -> tuple[str, str, str]:
+ return account.normalized_email, account.user, account.status
+
+ @staticmethod
+ def _workspace_projection(workspace: Workspace) -> tuple[Any, ...]:
+ return (
+ workspace.name,
+ workspace.slug,
+ workspace.type,
+ workspace.status,
+ workspace.created_by_account_uuid,
+ )
+
+ @staticmethod
+ def _candidate_workspace_projection(candidate: DirectoryWorkspace) -> tuple[Any, ...]:
+ return (
+ candidate.name,
+ candidate.slug,
+ candidate.type,
+ candidate.status,
+ candidate.created_by_account_uuid,
+ )
+
+ @classmethod
+ def _membership_projection(cls, membership: WorkspaceMembership) -> tuple[Any, ...]:
+ return (
+ membership.role,
+ membership.status,
+ cls._datetime_fingerprint(membership.joined_at),
+ )
+
+ @staticmethod
+ def _datetime_fingerprint(value: datetime.datetime | None) -> str | None:
+ if value is None:
+ return None
+ return DirectoryProjectionService._naive_utc(value).isoformat(timespec='microseconds')
+
+ @staticmethod
+ def _naive_utc(value: datetime.datetime | None) -> datetime.datetime | None:
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ return value
+ return value.astimezone(datetime.UTC).replace(tzinfo=None)
+
+ @staticmethod
+ def _utcnow() -> datetime.datetime:
+ return datetime.datetime.now(datetime.UTC)
diff --git a/src/langbot/pkg/cloud/entitlements.py b/src/langbot/pkg/cloud/entitlements.py
new file mode 100644
index 000000000..d6d34afd5
--- /dev/null
+++ b/src/langbot/pkg/cloud/entitlements.py
@@ -0,0 +1,251 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+from collections.abc import Callable
+from typing import Protocol, runtime_checkable
+
+import pydantic
+
+
+class EntitlementUnavailableError(RuntimeError):
+ """Raised when a trusted, currently-active entitlement is unavailable."""
+
+ def __init__(self, message: str, *, entitlement_revision: int | None = None) -> None:
+ super().__init__(message)
+ self.entitlement_revision = entitlement_revision
+
+
+class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
+ """Raised only when an active entitlement does not grant one feature."""
+
+ def __init__(
+ self,
+ feature: str,
+ *,
+ entitlement_revision: int | None = None,
+ ) -> None:
+ self.feature = feature
+ super().__init__(
+ f'Workspace entitlement does not grant {feature}',
+ entitlement_revision=entitlement_revision,
+ )
+
+
+class EntitlementSnapshot(pydantic.BaseModel):
+ """Capability projection consumed by open-source Core.
+
+ Admission and quota decisions use normalized features/limits rather than
+ product plan names. ``plan_name`` is signed display metadata for Cloud UI
+ only and must never drive authorization or quota enforcement.
+ """
+
+ model_config = pydantic.ConfigDict(frozen=True, extra='forbid')
+
+ instance_uuid: str = pydantic.Field(min_length=1, max_length=256)
+ workspace_uuid: str = pydantic.Field(min_length=1, max_length=256)
+ entitlement_revision: int = pydantic.Field(ge=1)
+ status: str = pydantic.Field(pattern=r'^(active|suspended|cancelled)$')
+ not_before: int = pydantic.Field(ge=0)
+ expires_at: int = pydantic.Field(gt=0)
+ features: dict[str, bool] = pydantic.Field(default_factory=dict)
+ limits: dict[str, int] = pydantic.Field(default_factory=dict)
+ # Signed display metadata for Cloud UI only. Admission and quota decisions
+ # must continue to use generic ``features`` and ``limits`` exclusively.
+ plan_name: str | None = pydantic.Field(default=None, min_length=1, max_length=128)
+
+ @pydantic.field_validator('features')
+ @classmethod
+ def _validate_feature_names(cls, value: dict[str, bool]) -> dict[str, bool]:
+ if any(not str(name).strip() for name in value):
+ raise ValueError('Entitlement feature names must be non-empty')
+ return dict(value)
+
+ @pydantic.field_validator('limits')
+ @classmethod
+ def _validate_limits(cls, value: dict[str, int]) -> dict[str, int]:
+ normalized: dict[str, int] = {}
+ for name, limit in value.items():
+ if not str(name).strip():
+ raise ValueError('Entitlement limit names must be non-empty')
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
+ raise ValueError(f'Entitlement limit {name!r} must be a non-negative integer')
+ normalized[str(name)] = limit
+ return normalized
+
+ def require_active(
+ self,
+ *,
+ instance_uuid: str,
+ workspace_uuid: str,
+ now: int | None = None,
+ ) -> EntitlementSnapshot:
+ current_time = int(time.time()) if now is None else now
+ if self.instance_uuid != instance_uuid or self.workspace_uuid != workspace_uuid:
+ raise EntitlementUnavailableError('Entitlement scope does not match the Workspace execution context')
+ if self.status != 'active':
+ raise EntitlementUnavailableError(
+ 'Workspace entitlement is not active',
+ entitlement_revision=self.entitlement_revision,
+ )
+ if current_time < self.not_before or current_time >= self.expires_at:
+ raise EntitlementUnavailableError(
+ 'Workspace entitlement is not currently valid',
+ entitlement_revision=self.entitlement_revision,
+ )
+ return self
+
+ def require_feature(self, feature: str) -> None:
+ if self.features.get(feature) is not True:
+ raise EntitlementFeatureUnavailableError(
+ feature,
+ entitlement_revision=self.entitlement_revision,
+ )
+
+ def limit(self, name: str) -> int:
+ value = self.limits.get(name)
+ if value is None:
+ raise EntitlementUnavailableError(f'Workspace entitlement does not define limit {name}')
+ return value
+
+
+@runtime_checkable
+class EntitlementProvider(Protocol):
+ """Closed Control Plane adapter injected by a verified Cloud bootstrap."""
+
+ async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
+ """Return the newest verified snapshot for one Workspace."""
+
+
+class OpenSourceEntitlementProvider:
+ """Marker provider for OSS; Cloud admission grants never use this class."""
+
+ async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
+ del workspace_uuid
+ raise EntitlementUnavailableError('Signed Workspace entitlements are only available in Cloud mode')
+
+
+class EntitlementResolver:
+ """Validate scope/freshness and reject revision rollback or equivocation."""
+
+ def __init__(
+ self,
+ instance_uuid: str,
+ provider: EntitlementProvider,
+ *,
+ deployment_admission: Callable[[], object] | None = None,
+ ) -> None:
+ self.instance_uuid = instance_uuid
+ self.provider = provider
+ self._deployment_admission = deployment_admission
+ self._lock = asyncio.Lock()
+ self._snapshots: dict[str, tuple[int, str, EntitlementSnapshot]] = {}
+ self._active_workspace_uuids: frozenset[str] | None = None
+
+ @staticmethod
+ def _fingerprint(snapshot: EntitlementSnapshot) -> str:
+ return json.dumps(snapshot.model_dump(mode='json'), sort_keys=True, separators=(',', ':'))
+
+ async def resolve(
+ self,
+ workspace_uuid: str,
+ *,
+ minimum_revision: int = 0,
+ now: int | None = None,
+ ) -> EntitlementSnapshot:
+ if self._deployment_admission is not None:
+ self._deployment_admission()
+ async with self._lock:
+ self._require_projected_workspace_locked(workspace_uuid)
+ candidate = await self.provider.get_workspace_entitlement(workspace_uuid)
+ if self._deployment_admission is not None:
+ # A provider call may cross the Manifest expiry boundary.
+ self._deployment_admission()
+ if not isinstance(candidate, EntitlementSnapshot):
+ raise EntitlementUnavailableError('Entitlement provider returned an invalid snapshot')
+ # Deep-copy untrusted provider-owned containers before caching them.
+ candidate = EntitlementSnapshot.model_validate(candidate.model_dump())
+ candidate.require_active(
+ instance_uuid=self.instance_uuid,
+ workspace_uuid=workspace_uuid,
+ now=now,
+ )
+ if candidate.entitlement_revision < minimum_revision:
+ raise EntitlementUnavailableError('Workspace entitlement revision rolled back')
+
+ fingerprint = self._fingerprint(candidate)
+ async with self._lock:
+ # The directory may fence a Workspace while the provider call is
+ # in flight. Recheck before retaining or returning its snapshot.
+ self._require_projected_workspace_locked(workspace_uuid)
+ previous = self._snapshots.get(workspace_uuid)
+ if previous is not None:
+ previous_revision, previous_fingerprint, _ = previous
+ if candidate.entitlement_revision < previous_revision:
+ raise EntitlementUnavailableError('Workspace entitlement revision rolled back')
+ if candidate.entitlement_revision == previous_revision and fingerprint != previous_fingerprint:
+ raise EntitlementUnavailableError('Workspace entitlement revision has conflicting contents')
+ self._snapshots[workspace_uuid] = (
+ candidate.entitlement_revision,
+ fingerprint,
+ candidate,
+ )
+ return candidate.model_copy(deep=True)
+
+ def _require_projected_workspace_locked(self, workspace_uuid: str) -> None:
+ active_workspace_uuids = self._active_workspace_uuids
+ if active_workspace_uuids is not None and workspace_uuid not in active_workspace_uuids:
+ raise EntitlementUnavailableError('Workspace is not active in the Cloud directory projection')
+
+ async def reconcile_active_workspaces(
+ self,
+ workspace_uuids: set[str] | frozenset[str],
+ ) -> None:
+ """Drop entitlement history for Workspaces fenced by the directory."""
+
+ active = frozenset(workspace_uuids)
+ async with self._lock:
+ self._active_workspace_uuids = active
+ self._snapshots = {
+ workspace_uuid: cached for workspace_uuid, cached in self._snapshots.items() if workspace_uuid in active
+ }
+
+ async def set_workspace_active(
+ self,
+ workspace_uuid: str,
+ *,
+ active: bool,
+ ) -> None:
+ """Apply one incremental directory activity change."""
+
+ await self.update_workspace_activity(
+ active_workspace_uuids={workspace_uuid} if active else set(),
+ inactive_workspace_uuids=set() if active else {workspace_uuid},
+ )
+
+ async def update_workspace_activity(
+ self,
+ *,
+ active_workspace_uuids: set[str] | frozenset[str],
+ inactive_workspace_uuids: set[str] | frozenset[str],
+ ) -> None:
+ """Apply one directory delta without copying the active set per item."""
+
+ active_updates = set(active_workspace_uuids)
+ inactive_updates = set(inactive_workspace_uuids)
+ if active_updates & inactive_updates:
+ raise ValueError('Workspace activity update contains conflicting entries')
+ async with self._lock:
+ current = set(self._active_workspace_uuids or ())
+ current.update(active_updates)
+ current.difference_update(inactive_updates)
+ for workspace_uuid in inactive_updates:
+ self._snapshots.pop(workspace_uuid, None)
+ self._active_workspace_uuids = frozenset(current)
+
+ def snapshot_counts(self) -> dict[str, int]:
+ return {
+ 'active_workspaces': len(self._active_workspace_uuids or ()),
+ 'cached_snapshots': len(self._snapshots),
+ }
diff --git a/src/langbot/pkg/cloud/launch.py b/src/langbot/pkg/cloud/launch.py
new file mode 100644
index 000000000..ac5006870
--- /dev/null
+++ b/src/langbot/pkg/cloud/launch.py
@@ -0,0 +1,265 @@
+from __future__ import annotations
+
+import asyncio
+import base64
+import binascii
+import hashlib
+import heapq
+import json
+import os
+import time
+import typing
+from collections.abc import Callable, Iterable
+
+from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+
+if typing.TYPE_CHECKING:
+ from ..core.app import Application
+
+
+CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
+LAUNCH_KIND = 'workspace.launch'
+EXPECTED_ISSUER = 'langbot-space'
+EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
+_CONSUMED_JTI_MAX_ENTRIES = 4096
+_CONSUMED_JTI_HEAP_COMPACT_FLOOR = 64
+_CONSUMED_JTI_HEAP_MAX_MULTIPLIER = 4
+
+
+class SpaceLaunchError(ValueError):
+ """Raised when a Space-issued Cloud launch assertion is not admissible."""
+
+
+def _decode_base64url(value: str, *, label: str) -> bytes:
+ if not value or any(character.isspace() for character in value):
+ raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url')
+ try:
+ raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise SpaceLaunchError(f'Launch assertion {label} is not valid base64url') from exc
+ if base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') != value:
+ raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url')
+ return raw
+
+
+def _strict_json_object(value: bytes, *, label: str) -> dict[str, typing.Any]:
+ def reject_duplicate_keys(pairs: Iterable[tuple[str, typing.Any]]) -> dict[str, typing.Any]:
+ result: dict[str, typing.Any] = {}
+ for key, item in pairs:
+ if key in result:
+ raise SpaceLaunchError(f'Launch assertion {label} contains duplicate key {key!r}')
+ result[key] = item
+ return result
+
+ try:
+ decoded = json.loads(value, object_pairs_hook=reject_duplicate_keys)
+ except SpaceLaunchError:
+ raise
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise SpaceLaunchError(f'Launch assertion {label} is not valid JSON') from exc
+ if not isinstance(decoded, dict):
+ raise SpaceLaunchError(f'Launch assertion {label} must be a JSON object')
+ return decoded
+
+
+def _required_string(claims: dict[str, typing.Any], name: str) -> str:
+ value = claims.get(name)
+ if not isinstance(value, str) or not value or value != value.strip():
+ raise SpaceLaunchError(f'Launch assertion claim {name} must be a non-empty string')
+ return value
+
+
+def _required_int(claims: dict[str, typing.Any], name: str, *, minimum: int = 0) -> int:
+ value = claims.get(name)
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
+ raise SpaceLaunchError(f'Launch assertion claim {name} must be an integer >= {minimum}')
+ return value
+
+
+def _load_ed25519_public_key(encoded: str) -> Ed25519PublicKey:
+ value = encoded.strip()
+ if value.startswith('-----BEGIN'):
+ try:
+ key = serialization.load_pem_public_key(value.encode('ascii'))
+ except (ValueError, TypeError) as exc:
+ raise SpaceLaunchError('Space launch public key is not valid PEM') from exc
+ if not isinstance(key, Ed25519PublicKey):
+ raise SpaceLaunchError('Space launch public key must be Ed25519')
+ return key
+
+ try:
+ raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True)
+ except (binascii.Error, ValueError) as exc:
+ raise SpaceLaunchError('Space launch public key must be base64 encoded') from exc
+ if len(raw) != 32:
+ raise SpaceLaunchError('Space launch Ed25519 public key must contain 32 bytes')
+ return Ed25519PublicKey.from_public_bytes(raw)
+
+
+class SpaceLaunchService:
+ """Verify and single-use consume Space Cloud direct-launch assertions."""
+
+ def __init__(
+ self,
+ ap: Application,
+ *,
+ wall_time: Callable[[], float] = time.time,
+ ) -> None:
+ self.ap = ap
+ self._wall_time = wall_time
+ self._replay_lock = asyncio.Lock()
+ self._consumed_jtis: dict[str, int] = {}
+ self._consumed_jti_expiry_heap: list[tuple[int, str]] = []
+
+ async def consume_assertion(
+ self,
+ assertion: str,
+ *,
+ expected_workspace_uuid: str | None = None,
+ ) -> dict[str, str]:
+ claims = self._verify_assertion(assertion)
+ payload = claims.get('payload')
+ if not isinstance(payload, dict):
+ raise SpaceLaunchError('Launch assertion payload must be a JSON object')
+ account_uuid = _required_string(payload, 'account_uuid')
+ workspace_uuid = _required_string(payload, 'workspace_uuid')
+ if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid:
+ raise SpaceLaunchError('Launch assertion targets another Workspace')
+ await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
+ return {
+ 'account_uuid': account_uuid,
+ 'workspace_uuid': workspace_uuid,
+ }
+
+ def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
+ if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
+ raise SpaceLaunchError('Space direct launch requires verified Cloud mode')
+ public_key, key_id, clock_skew_seconds = self._trust_config()
+ segments = token.split('.')
+ if len(segments) != 3:
+ raise SpaceLaunchError('Launch assertion must be a compact JWS')
+ encoded_header, encoded_claims, encoded_signature = segments
+ header = _strict_json_object(_decode_base64url(encoded_header, label='header'), label='header')
+ if set(header) != {'alg', 'kid', 'typ'}:
+ raise SpaceLaunchError('Launch assertion header contains unsupported fields')
+ if header.get('alg') != 'EdDSA':
+ raise SpaceLaunchError('Launch assertion algorithm must be EdDSA')
+ if header.get('kid') != key_id:
+ raise SpaceLaunchError('Launch assertion key ID does not match Cloud trust')
+ if header.get('typ') != CONTROL_PLANE_TYP:
+ raise SpaceLaunchError('Launch assertion type is not a control-plane payload')
+
+ signature = _decode_base64url(encoded_signature, label='signature')
+ if len(signature) != 64:
+ raise SpaceLaunchError('Launch assertion signature must contain 64 bytes')
+ try:
+ public_key.verify(signature, f'{encoded_header}.{encoded_claims}'.encode('ascii'))
+ except InvalidSignature as exc:
+ raise SpaceLaunchError('Launch assertion signature is invalid') from exc
+
+ claims = _strict_json_object(_decode_base64url(encoded_claims, label='claims'), label='claims')
+ instance_uuid = self.ap.workspace_service.instance_uuid
+ if _required_string(claims, 'iss') != EXPECTED_ISSUER:
+ raise SpaceLaunchError('Launch assertion issuer is not LangBot Space')
+ if _required_string(claims, 'aud') != EXPECTED_AUDIENCE:
+ raise SpaceLaunchError('Launch assertion audience does not target Cloud runtime')
+ if _required_string(claims, 'sub') != f'langbot-instance:{instance_uuid}':
+ raise SpaceLaunchError('Launch assertion subject targets another instance')
+ if _required_string(claims, 'instance_uuid') != instance_uuid:
+ raise SpaceLaunchError('Launch assertion instance UUID does not match this Core')
+ if _required_string(claims, 'kind') != LAUNCH_KIND:
+ raise SpaceLaunchError('Launch assertion kind is not workspace.launch')
+
+ issued_at = _required_int(claims, 'iat')
+ not_before = _required_int(claims, 'nbf')
+ expires_at = _required_int(claims, 'exp', minimum=1)
+ now = self._wall_time()
+ if issued_at > now + clock_skew_seconds:
+ raise SpaceLaunchError('Launch assertion was issued in the future')
+ if not_before > now + clock_skew_seconds:
+ raise SpaceLaunchError('Launch assertion is not active yet')
+ if expires_at <= now - clock_skew_seconds:
+ raise SpaceLaunchError('Launch assertion is expired')
+ if expires_at <= max(issued_at, not_before):
+ raise SpaceLaunchError('Launch assertion expiry must follow issue time')
+ return claims
+
+ def _trust_config(self) -> tuple[Ed25519PublicKey, str, float]:
+ data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
+ space_config = data.get('space', {})
+ launch_config = space_config.get('launch', {}) if isinstance(space_config, dict) else {}
+ if not isinstance(launch_config, dict):
+ launch_config = {}
+ public_key_value = (
+ os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY', '').strip()
+ or str(launch_config.get('control_plane_public_key', '') or '').strip()
+ )
+ key_id = (
+ os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_KEY_ID', '').strip()
+ or str(launch_config.get('control_plane_key_id', '') or '').strip()
+ or str(getattr(getattr(self.ap, 'deployment', None), 'verification_key_id', '') or '').strip()
+ )
+ if not public_key_value or not key_id:
+ raise SpaceLaunchError('Space launch control-plane trust is not configured')
+ clock_skew = self._bounded_float(
+ os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_CLOCK_SKEW_SECONDS') or launch_config.get('clock_skew_seconds'),
+ default=30.0,
+ minimum=0.0,
+ maximum=300.0,
+ )
+ return _load_ed25519_public_key(public_key_value), key_id, clock_skew
+
+ async def _consume_jti(self, jti: str, expires_at: int) -> None:
+ digest = hashlib.sha256(jti.encode('utf-8')).hexdigest()
+ now = int(self._wall_time())
+ async with self._replay_lock:
+ self._prune_consumed_jtis(now)
+ if digest in self._consumed_jtis:
+ raise SpaceLaunchError('Launch assertion has already been consumed')
+ if len(self._consumed_jtis) >= _CONSUMED_JTI_MAX_ENTRIES:
+ # Evicting a still-valid digest would make a signed launch
+ # assertion replayable. Bound memory by failing closed instead.
+ raise SpaceLaunchError('Launch assertion replay cache capacity reached')
+ self._consumed_jtis[digest] = expires_at
+ heapq.heappush(
+ self._consumed_jti_expiry_heap,
+ (expires_at, digest),
+ )
+
+ def _prune_consumed_jtis(self, now: int) -> None:
+ while self._consumed_jti_expiry_heap:
+ expires_at, digest = self._consumed_jti_expiry_heap[0]
+ current_expiry = self._consumed_jtis.get(digest)
+ if current_expiry != expires_at:
+ heapq.heappop(self._consumed_jti_expiry_heap)
+ continue
+ if expires_at > now:
+ break
+ heapq.heappop(self._consumed_jti_expiry_heap)
+ self._consumed_jtis.pop(digest, None)
+
+ max_heap_entries = max(
+ _CONSUMED_JTI_HEAP_COMPACT_FLOOR,
+ len(self._consumed_jtis) * _CONSUMED_JTI_HEAP_MAX_MULTIPLIER,
+ )
+ if len(self._consumed_jti_expiry_heap) > max_heap_entries:
+ self._consumed_jti_expiry_heap[:] = [(expiry, digest) for digest, expiry in self._consumed_jtis.items()]
+ heapq.heapify(self._consumed_jti_expiry_heap)
+
+ @staticmethod
+ def _bounded_float(
+ value: typing.Any,
+ *,
+ default: float,
+ minimum: float,
+ maximum: float,
+ ) -> float:
+ try:
+ result = float(value)
+ except (TypeError, ValueError):
+ return default
+ if not minimum <= result <= maximum:
+ return default
+ return result
diff --git a/src/langbot/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py
index ee064c219..7560f7c43 100644
--- a/src/langbot/pkg/command/cmdmgr.py
+++ b/src/langbot/pkg/command/cmdmgr.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import typing
+import inspect
from ..core import app
from . import operator
@@ -63,6 +64,12 @@ class CommandManager:
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
"""执行命令"""
+ require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
+ if require_context is not None:
+ result = require_context(context)
+ if inspect.isawaitable(result):
+ await result
+
command_list = await self.ap.plugin_connector.list_commands(bound_plugins)
for command in command_list:
@@ -89,6 +96,7 @@ class CommandManager:
_admins = await self.ap.persistence_mgr.execute_async(
_sa.select(_BotAdmin).where(
+ _BotAdmin.workspace_uuid == query.workspace_uuid,
_BotAdmin.bot_uuid == (query.bot_uuid or ''),
_BotAdmin.launcher_type == query.launcher_type.value,
_BotAdmin.launcher_id == str(query.launcher_id),
@@ -98,7 +106,11 @@ class CommandManager:
privilege = 2
ctx = command_context.ExecuteContext(
+ instance_uuid=query.instance_uuid,
+ workspace_uuid=query.workspace_uuid,
+ placement_generation=query.placement_generation,
query_id=query.query_id,
+ query_uuid=query.query_uuid,
session=session,
command_text=command_text,
full_command_text=full_command_text,
diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py
index e5bc1abf0..bb8d76a2f 100644
--- a/src/langbot/pkg/core/app.py
+++ b/src/langbot/pkg/core/app.py
@@ -2,9 +2,9 @@ from __future__ import annotations
import logging
import asyncio
+import contextlib
import traceback
import os
-import contextlib
from ..platform import botmgr as im_mgr
from ..platform.webhook_pusher import WebhookPusher
@@ -19,7 +19,7 @@ from ..plugin import connector as plugin_connector
from ..pipeline import pool
from ..pipeline import controller, pipelinemgr
from ..pipeline import aggregator as message_aggregator
-from ..utils import version as version_mgr, proxy as proxy_mgr
+from ..utils import version as version_mgr, proxy as proxy_mgr, httpclient
from ..persistence import mgr as persistencemgr
from ..api.http.controller import main as http_controller
from ..api.http.service import user as user_service
@@ -37,7 +37,7 @@ from ..api.http.service import skill as skill_service
from ..api.http.service import maintenance as maintenance_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
-from ..utils import logcache
+from ..utils import bounded_executor, event_loop_monitor, logcache
from . import taskmgr
from . import entities as core_entities
from ..rag.knowledge import kbmgr as rag_mgr
@@ -46,6 +46,14 @@ from ..vector import mgr as vectordb_mgr
from ..telemetry import telemetry as telemetry_module
from ..survey import manager as survey_module
from ..skill import manager as skill_mgr
+from ..workspace import service as workspace_service_module
+from ..workspace import collaboration as workspace_collaboration_module
+from ..workspace import invitation_delivery as invitation_delivery_module
+from ..cloud import bootstrap as cloud_bootstrap_module
+from ..cloud import launch as cloud_launch_module
+from ..cloud import directory_projection as cloud_directory_projection_module
+from ..cloud import entitlements as cloud_entitlements_module
+from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
class Application:
@@ -120,6 +128,24 @@ class Application:
persistence_mgr: persistencemgr.PersistenceManager = None
+ workspace_service: workspace_service_module.WorkspaceService = None
+
+ workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None
+
+ invitation_delivery_service: invitation_delivery_module.InvitationDeliveryService = None
+
+ space_launch_service: cloud_launch_module.SpaceLaunchService = None
+
+ deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
+
+ deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
+
+ manifest_refresh_service: cloud_bootstrap_module.CloudManifestRefreshService | None = None
+
+ entitlement_resolver: cloud_entitlements_module.EntitlementResolver | None = None
+
+ directory_projection_service: cloud_directory_projection_module.DirectoryProjectionService | None = None
+
vector_db_mgr: vectordb_mgr.VectorDBManager = None
http_ctrl: http_controller.HTTPController = None
@@ -166,15 +192,123 @@ class Application:
maintenance_service: maintenance_service.MaintenanceService = None
+ blocking_executor: bounded_executor.BoundedThreadPoolExecutor | None = None
+ event_loop_monitor: event_loop_monitor.EventLoopLagMonitor
+
def __init__(self):
self._shutdown_lock = asyncio.Lock()
self._shutdown_complete = False
+ self._shutdown_task: asyncio.Task | None = None
+ self.event_loop_monitor = event_loop_monitor.EventLoopLagMonitor()
+
+ def get_runtime_resource_stats(self) -> dict[str, object]:
+ """Return aggregate O(1) counters for liveness and soak validation."""
+
+ try:
+ asyncio_tasks = len(asyncio.all_tasks(self.event_loop))
+ except (RuntimeError, TypeError):
+ asyncio_tasks = 0
+
+ task_stats = self.task_mgr.get_stats() if self.task_mgr is not None else {}
+ query_pool_stats = {}
+ if self.query_pool is not None:
+ query_pool_stats = {
+ 'queued': len(self.query_pool.queries),
+ 'cached': len(self.query_pool.cached_queries),
+ 'active_workspaces': len(self.query_pool.active_query_count_by_workspace),
+ }
+
+ model_stats = {}
+ if self.model_mgr is not None:
+ model_stats = {
+ 'providers': len(self.model_mgr.provider_dict),
+ 'llms': len(self.model_mgr.llm_model_dict),
+ 'embeddings': len(self.model_mgr.embedding_model_dict),
+ 'rerankers': len(self.model_mgr.rerank_model_dict),
+ }
+
+ runtime_stats = {
+ 'bots': len(getattr(self.platform_mgr, '_bots_by_key', {})),
+ 'pipelines': len(getattr(self.pipeline_mgr, '_pipelines_by_key', {})),
+ 'knowledge_bases': len(getattr(self.rag_mgr, 'knowledge_bases', {})),
+ 'message_aggregation_buffers': len(getattr(self.msg_aggregator, 'buffers', {})),
+ 'message_aggregation_scopes': len(
+ getattr(
+ self.msg_aggregator,
+ '_buffer_counts_by_scope',
+ {},
+ )
+ ),
+ 'plugin_installations': len(
+ getattr(
+ self.plugin_connector,
+ '_known_desired_states',
+ {},
+ )
+ ),
+ }
+ mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None)
+ runtime_stats.update(
+ {
+ 'mcp_sessions': len(getattr(mcp_loader, '_sessions', {})),
+ 'mcp_host_tasks': len(getattr(mcp_loader, '_hosted_mcp_tasks', ())),
+ 'mcp_dispatch_tasks': len(getattr(mcp_loader, '_host_dispatch_tasks', ())),
+ 'mcp_projection_retirements': len(getattr(mcp_loader, '_pending_projection_retirements', ())),
+ 'mcp_projection_reconcile_active': int(
+ (
+ projection_task := getattr(
+ mcp_loader,
+ '_projection_reconcile_task',
+ None,
+ )
+ )
+ is not None
+ and not projection_task.done()
+ ),
+ }
+ )
+
+ directory_stats = {}
+ directory_snapshot = getattr(self.directory_projection_service, 'resource_snapshot', None)
+ if callable(directory_snapshot):
+ directory_stats = directory_snapshot()
+
+ database_stats = {}
+ database_snapshot = getattr(self.persistence_mgr, 'get_resource_stats', None)
+ if callable(database_snapshot):
+ database_stats = database_snapshot()
+
+ return {
+ 'asyncio_tasks': asyncio_tasks,
+ 'event_loop': self.event_loop_monitor.snapshot(),
+ 'blocking_executor': (self.blocking_executor.snapshot() if self.blocking_executor is not None else {}),
+ 'application_tasks': task_stats,
+ 'database_pool': database_stats,
+ 'directory': directory_stats,
+ 'query_pool': query_pool_stats,
+ 'models': model_stats,
+ 'runtimes': runtime_stats,
+ 'telemetry_tasks': len(getattr(self.telemetry, 'send_tasks', ())),
+ }
async def initialize(self):
pass
async def run(self):
+ self.event_loop_monitor.start()
try:
+ if self.directory_projection_service is not None:
+ self.task_mgr.create_task(
+ self.directory_projection_service.run(),
+ name='cloud-directory-projection',
+ scopes=[core_entities.LifecycleControlScope.APPLICATION],
+ )
+ if self.manifest_refresh_service is not None:
+ self.task_mgr.create_task(
+ self.manifest_refresh_service.run(),
+ name='cloud-manifest-refresh',
+ scopes=[core_entities.LifecycleControlScope.APPLICATION],
+ )
await self.plugin_connector.initialize_plugins()
# 后续可能会允许动态重启其他任务
@@ -213,74 +347,128 @@ class Application:
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
- # Start monitoring data cleanup task if enabled
monitoring_cfg = self.instance_config.data.get('monitoring', {})
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
- if auto_cleanup_cfg.get('enabled', True):
- retention_days = self._get_positive_int_config(
- auto_cleanup_cfg.get('retention_days', 30),
- default=30,
- name='monitoring.auto_cleanup.retention_days',
- )
- delete_batch_size = self._get_positive_int_config(
- auto_cleanup_cfg.get('delete_batch_size', 1000),
- default=1000,
- name='monitoring.auto_cleanup.delete_batch_size',
- )
- check_interval_hours = self._get_positive_float_config(
+ monitoring_enabled = auto_cleanup_cfg.get('enabled', True)
+ retention_days = self._get_positive_int_config(
+ auto_cleanup_cfg.get('retention_days', 30),
+ default=30,
+ name='monitoring.auto_cleanup.retention_days',
+ )
+ delete_batch_size = self._get_positive_int_config(
+ auto_cleanup_cfg.get('delete_batch_size', 1000),
+ default=1000,
+ name='monitoring.auto_cleanup.delete_batch_size',
+ )
+ monitoring_interval_seconds = (
+ self._get_positive_float_config(
auto_cleanup_cfg.get('check_interval_hours', 1),
default=1,
name='monitoring.auto_cleanup.check_interval_hours',
)
+ * 3600
+ )
- async def monitoring_cleanup_loop():
- check_interval_seconds = check_interval_hours * 3600
- while True:
- try:
- deleted = await self.monitoring_service.cleanup_expired_records(
- retention_days,
- batch_size=delete_batch_size,
- )
- total_deleted = sum(deleted.values())
- if total_deleted > 0:
- self.logger.info(
- f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
- f'(retention={retention_days}d): {deleted}'
- )
- except Exception as e:
- self.logger.warning(f'Monitoring auto-cleanup error: {e}')
- await asyncio.sleep(check_interval_seconds)
-
- self.task_mgr.create_task(
- monitoring_cleanup_loop(),
- name='monitoring-cleanup',
- scopes=[core_entities.LifecycleControlScope.APPLICATION],
- )
-
- # Start storage/log maintenance task if enabled
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
- if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
- check_interval_hours = self._get_positive_float_config(
+ storage_enabled = storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None
+ storage_interval_seconds = (
+ self._get_positive_float_config(
storage_cleanup_cfg.get('check_interval_hours', 1),
default=1,
name='storage.cleanup.check_interval_hours',
)
+ * 3600
+ )
- async def storage_cleanup_loop():
- check_interval_seconds = check_interval_hours * 3600
+ maintenance_intervals: dict[str, float] = {}
+ if monitoring_enabled:
+ maintenance_intervals['monitoring'] = monitoring_interval_seconds
+ if storage_enabled:
+ maintenance_intervals['storage'] = storage_interval_seconds
+ if self.workspace_collaboration_service is not None:
+ maintenance_intervals['invitations'] = 3600.0
+
+ if maintenance_intervals:
+
+ async def resource_maintenance_loop():
+ """Share tenant discovery and serialize periodic maintenance."""
+
+ loop = asyncio.get_running_loop()
+ started_at = loop.time()
+ next_due = {name: started_at + interval for name, interval in maintenance_intervals.items()}
while True:
+ await asyncio.sleep(max(min(next_due.values()) - loop.time(), 0.0))
+ observed_at = loop.time()
+ due = {name for name, due_at in next_due.items() if due_at <= observed_at}
+ if not due:
+ continue
try:
- deleted = await self.maintenance_service.cleanup_expired_files()
- total_deleted = sum(deleted.values())
- if total_deleted > 0:
- self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
- except Exception as e:
- self.logger.warning(f'Storage maintenance error: {e}')
- await asyncio.sleep(check_interval_seconds)
+ bindings = await self.workspace_service.list_active_execution_bindings()
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ self.logger.warning(f'Resource maintenance Workspace discovery failed: {exc}')
+ else:
+ for binding in bindings:
+ context = ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ )
+ if 'monitoring' in due:
+ try:
+ deleted = await self.monitoring_service.cleanup_expired_records(
+ context,
+ retention_days,
+ batch_size=delete_batch_size,
+ )
+ total_deleted = sum(deleted.values())
+ if total_deleted > 0:
+ self.logger.info(
+ f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
+ f'for Workspace {context.workspace_uuid} '
+ f'(retention={retention_days}d): {deleted}'
+ )
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ self.logger.warning(
+ f'Monitoring auto-cleanup failed for '
+ f'Workspace {context.workspace_uuid}: {exc}'
+ )
+ if 'storage' in due:
+ try:
+ deleted = await self.maintenance_service.cleanup_expired_files(context)
+ total_deleted = sum(deleted.values())
+ if total_deleted > 0:
+ self.logger.info(
+ f'Storage maintenance for Workspace {context.workspace_uuid}: '
+ f'deleted expired files: {deleted}'
+ )
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ self.logger.warning(
+ f'Storage maintenance failed for Workspace {context.workspace_uuid}: {exc}'
+ )
+ if 'invitations' in due:
+ try:
+ await self.workspace_collaboration_service.cleanup_expired_invitations(
+ active_bindings=bindings,
+ )
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ self.logger.warning(f'Expired Workspace invitation cleanup failed: {exc}')
+
+ completed_at = loop.time()
+ for name in due:
+ next_due[name] = completed_at + maintenance_intervals[name]
self.task_mgr.create_task(
- storage_cleanup_loop(),
- name='storage-maintenance',
+ resource_maintenance_loop(),
+ name='resource-maintenance',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
@@ -328,30 +516,68 @@ class Application:
if self.task_mgr is not None:
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
+ with contextlib.suppress(Exception):
+ await self.event_loop_monitor.stop()
+ mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
+ if mcp_mount is not None:
+ with contextlib.suppress(Exception):
+ await mcp_mount.stop_session_manager()
if self.platform_mgr is not None:
with contextlib.suppress(Exception):
await self.platform_mgr.shutdown()
if self.tool_mgr is not None:
with contextlib.suppress(Exception):
await self.tool_mgr.shutdown()
+ if self.model_mgr is not None:
+ with contextlib.suppress(Exception):
+ await self.model_mgr.shutdown()
if self.box_service is not None:
with contextlib.suppress(Exception):
await self.box_service.shutdown()
if self.plugin_connector is not None:
with contextlib.suppress(Exception):
await self.plugin_connector.aclose()
+ if self.telemetry is not None:
+ with contextlib.suppress(Exception):
+ await self.telemetry.shutdown()
+ if self.vector_db_mgr is not None:
+ with contextlib.suppress(Exception):
+ await self.vector_db_mgr.shutdown()
+ if self.storage_mgr is not None:
+ with contextlib.suppress(Exception):
+ await self.storage_mgr.shutdown()
+ manifest_provider = getattr(self.deployment, 'manifest_provider', None)
+ if manifest_provider is not None:
+ with contextlib.suppress(Exception):
+ await manifest_provider.aclose()
if self.task_mgr is not None:
tasks = [wrapper.task for wrapper in self.task_mgr.tasks if not wrapper.task.done()]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
+ with contextlib.suppress(Exception):
+ await httpclient.close_all()
+ persistence_shutdown = getattr(self.persistence_mgr, 'shutdown', None)
+ if callable(persistence_shutdown):
+ with contextlib.suppress(Exception):
+ await persistence_shutdown()
+ else:
+ # Compatibility for lightweight test/application doubles.
+ persistence_db = getattr(self.persistence_mgr, 'db', None)
+ persistence_engine = getattr(persistence_db, 'engine', None)
+ if persistence_engine is not None:
+ with contextlib.suppress(Exception):
+ await persistence_engine.dispose()
self._shutdown_complete = True
def dispose(self):
"""Compatibility wrapper for callers that cannot await shutdown."""
+ if self._shutdown_complete:
+ return
loop = self.event_loop
if loop is not None and not loop.is_closed():
- loop.create_task(self.shutdown())
+ if self._shutdown_task is None or self._shutdown_task.done():
+ self._shutdown_task = loop.create_task(self.shutdown())
return
if self.plugin_connector is not None:
self.plugin_connector.dispose()
diff --git a/src/langbot/pkg/core/boot.py b/src/langbot/pkg/core/boot.py
index fb6919630..1b15e9922 100644
--- a/src/langbot/pkg/core/boot.py
+++ b/src/langbot/pkg/core/boot.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import traceback
import asyncio
+import contextlib
import os
from . import app
@@ -32,14 +33,22 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
ap.event_loop = loop
- # Execute startup stage
- for stage_name in stage_order:
- stage_cls = stage.preregistered_stages[stage_name]
- stage_inst = stage_cls()
+ try:
+ # Execute startup stage
+ for stage_name in stage_order:
+ stage_cls = stage.preregistered_stages[stage_name]
+ stage_inst = stage_cls()
- await stage_inst.run(ap)
+ await stage_inst.run(ap)
- await ap.initialize()
+ await ap.initialize()
+ except BaseException:
+ # ``main()`` cannot clean up a partially built application because
+ # ``make_app()`` has not returned it yet. Release managers, pools and
+ # child processes that earlier startup stages already attached.
+ with contextlib.suppress(BaseException):
+ await ap.shutdown()
+ raise
return ap
diff --git a/src/langbot/pkg/core/errors.py b/src/langbot/pkg/core/errors.py
new file mode 100644
index 000000000..24bf39bf7
--- /dev/null
+++ b/src/langbot/pkg/core/errors.py
@@ -0,0 +1,2 @@
+class TaskCapacityError(RuntimeError):
+ """Raised when the configured user-task admission limit is exhausted."""
diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py
index 2af7fa579..813a6c4e6 100644
--- a/src/langbot/pkg/core/stages/build_app.py
+++ b/src/langbot/pkg/core/stages/build_app.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from .. import stage, app
-from ...utils import version, proxy
+from ...utils import version, proxy, constants
from ...pipeline import pool, controller, pipelinemgr
from ...pipeline import aggregator as message_aggregator
from ...box import service as box_service
@@ -37,6 +37,16 @@ from ...vector import mgr as vectordb_mgr
from .. import taskmgr
from ...telemetry import telemetry as telemetry_module
from ...survey import manager as survey_module
+from ...workspace import service as workspace_service_module
+from ...workspace import collaboration as workspace_collaboration_module
+from ...workspace import invitation_delivery as invitation_delivery_module
+from ...cloud import bootstrap as cloud_bootstrap
+from ...cloud import launch as cloud_launch_module
+from ...cloud.directory import directory_projection_limits_from_config
+from ...cloud.directory_projection import DirectoryProjectionService
+from ...cloud.entitlements import EntitlementResolver
+from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType
+from ...api.http.authz import WorkspaceRequiredError
@stage.stage_class('BuildAppStage')
@@ -45,15 +55,43 @@ class BuildAppStage(stage.BootingStage):
async def run(self, ap: app.Application):
"""Build LangBot application"""
+ # Multi-Workspace mode is selected only by an installed closed
+ # bootstrap that returns a verified Manifest receipt. Mutable values
+ # such as system.edition are intentionally absent from this boundary.
+ deployment = await cloud_bootstrap.resolve_deployment(
+ instance_uuid=constants.instance_id,
+ instance_config=ap.instance_config.data,
+ )
+ ap.deployment = deployment
+ ap.deployment_admission = cloud_bootstrap.DeploymentAdmissionGuard(
+ constants.instance_id,
+ deployment,
+ )
+ ap.manifest_refresh_service = (
+ cloud_bootstrap.CloudManifestRefreshService(
+ ap.deployment_admission,
+ deployment.manifest_provider,
+ ap.logger,
+ )
+ if deployment.multi_workspace_enabled
+ else None
+ )
+ ap.entitlement_resolver = (
+ EntitlementResolver(
+ constants.instance_id,
+ deployment.entitlement_provider,
+ deployment_admission=ap.deployment_admission.require_active,
+ )
+ if deployment.multi_workspace_enabled
+ else None
+ )
+
ap.task_mgr = taskmgr.AsyncTaskManager(ap)
discover = discover_engine.ComponentDiscoveryEngine(ap)
discover.discover_blueprint('templates/components.yaml')
ap.discover = discover
- user_service_inst = user_service.UserService(ap)
- ap.user_service = user_service_inst
-
space_service_inst = space_service.SpaceService(ap)
ap.space_service = space_service_inst
@@ -98,23 +136,77 @@ class BuildAppStage(stage.BootingStage):
await ver_mgr.initialize()
ap.ver_mgr = ver_mgr
- ap.query_pool = pool.QueryPool()
-
log_cache = logcache.LogCache()
ap.log_cache = log_cache
storage_mgr_inst = storagemgr.StorageMgr(ap)
- await storage_mgr_inst.initialize()
ap.storage_mgr = storage_mgr_inst
+ await storage_mgr_inst.initialize()
- persistence_mgr_inst = persistencemgr.PersistenceManager(ap)
+ persistence_mgr_inst = persistencemgr.PersistenceManager(
+ ap,
+ mode=persistencemgr.PersistenceMode(deployment.persistence_mode),
+ )
ap.persistence_mgr = persistence_mgr_inst
await persistence_mgr_inst.initialize()
+ if deployment.multi_workspace_enabled:
+ directory_projection_service = DirectoryProjectionService(
+ ap,
+ deployment.directory_provider,
+ constants.instance_id,
+ limits=directory_projection_limits_from_config(ap.instance_config.data),
+ )
+ await directory_projection_service.initialize()
+ ap.directory_projection_service = directory_projection_service
+
+ workspace_policy = deployment.workspace_policy
+ workspace_service_inst = workspace_service_module.WorkspaceService(
+ ap,
+ policy=workspace_policy,
+ )
+ if not workspace_policy.multi_workspace_enabled:
+ await workspace_service_inst.ensure_singleton_workspace()
+ ap.workspace_service = workspace_service_inst
+ if workspace_policy.multi_workspace_enabled:
+ # Directory refresh starts in Application.run(), after this serial
+ # build graph. Share one validated immutable binding snapshot
+ # across model/platform/pipeline/RAG/plugin initialization instead
+ # of repeating tenant validation for every manager.
+ await workspace_service_inst.prime_startup_execution_bindings()
+
+ ap.workspace_collaboration_service = workspace_collaboration_module.WorkspaceCollaborationService(
+ ap,
+ workspace_service_inst,
+ )
+ ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap)
+ ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap)
+
+ user_service_inst = user_service.UserService(ap)
+ ap.user_service = user_service_inst
+
+ async def resolve_singleton_execution_context() -> ExecutionContext:
+ if workspace_policy.multi_workspace_enabled:
+ raise WorkspaceRequiredError('Cloud runtime work requires an explicit Workspace context')
+ binding = await workspace_service_inst.get_local_execution_binding()
+ return ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ )
+
+ concurrency_config = ap.instance_config.data.get('concurrency', {})
+ ap.query_pool = pool.QueryPool(
+ singleton_context_resolver=resolve_singleton_execution_context,
+ max_queries=int(concurrency_config.get('pending_queries', 1000)),
+ max_queries_per_workspace=int(concurrency_config.get('pending_queries_per_workspace', 100)),
+ )
+
# Telemetry manager: attach to app so other components can call via self.ap.telemetry
telemetry_inst = telemetry_module.TelemetryManager(ap)
- await telemetry_inst.initialize()
ap.telemetry = telemetry_inst
+ await telemetry_inst.initialize()
# Survey manager
survey_inst = survey_module.SurveyManager(ap)
@@ -134,16 +226,16 @@ class BuildAppStage(stage.BootingStage):
ap.sess_mgr = llm_session_mgr_inst
box_service_inst = box_service.BoxService(ap)
- await box_service_inst.initialize()
ap.box_service = box_service_inst
+ await box_service_inst.initialize()
llm_tool_mgr_inst = llm_tool_mgr.ToolManager(ap)
- await llm_tool_mgr_inst.initialize()
ap.tool_mgr = llm_tool_mgr_inst
+ await llm_tool_mgr_inst.initialize()
im_mgr_inst = im_mgr.PlatformManager(ap=ap)
- await im_mgr_inst.initialize()
ap.platform_mgr = im_mgr_inst
+ await im_mgr_inst.initialize()
# Initialize webhook pusher
webhook_pusher_inst = WebhookPusher(ap)
@@ -171,12 +263,12 @@ class BuildAppStage(stage.BootingStage):
# 初始化向量数据库管理器
vectordb_mgr_inst = vectordb_mgr.VectorDBManager(ap)
- await vectordb_mgr_inst.initialize()
ap.vector_db_mgr = vectordb_mgr_inst
+ await vectordb_mgr_inst.initialize()
http_ctrl = http_controller.HTTPController(ap)
- await http_ctrl.initialize()
ap.http_ctrl = http_ctrl
+ await http_ctrl.initialize()
monitoring_service_inst = monitoring_service.MonitoringService(ap)
ap.monitoring_service = monitoring_service_inst
@@ -196,6 +288,7 @@ class BuildAppStage(stage.BootingStage):
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
plugin_connector_inst.schedule_reconnect()
ap.plugin_connector = plugin_connector_inst
+ workspace_service_inst.release_startup_execution_bindings()
ctrl = controller.Controller(ap)
ap.ctrl = ctrl
diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py
index 6fc890f10..a89cb0b48 100644
--- a/src/langbot/pkg/core/stages/load_config.py
+++ b/src/langbot/pkg/core/stages/load_config.py
@@ -1,8 +1,9 @@
from __future__ import annotations
import os
+import copy
from typing import Any
-from langbot.pkg.utils import constants
+from langbot.pkg.utils import bounded_executor, constants
import yaml
import importlib.resources as resources
import uuid
@@ -12,6 +13,102 @@ from .. import stage, app
from ..bootutils import config
+_RUNTIME_POLICY_DEFAULTS = {
+ 'cloud': {
+ 'directory': {
+ 'max_active_workspaces': 1000,
+ 'max_snapshot_workspaces': 1000,
+ 'max_snapshot_memberships': 20000,
+ 'max_response_bytes': 33554432,
+ }
+ },
+ 'database': {
+ 'postgresql': {
+ 'pool_size': 10,
+ 'max_overflow': 10,
+ 'pool_timeout_seconds': 30,
+ 'pool_recycle_seconds': 1800,
+ 'statement_timeout_ms': 60000,
+ 'lock_timeout_ms': 5000,
+ 'idle_in_transaction_session_timeout_ms': 60000,
+ }
+ },
+ 'system': {
+ 'blocking_executor': {
+ 'max_workers': bounded_executor.DEFAULT_MAX_WORKERS,
+ 'max_pending': bounded_executor.DEFAULT_MAX_PENDING,
+ 'max_inflight_per_scope': (bounded_executor.DEFAULT_MAX_INFLIGHT_PER_SCOPE),
+ }
+ },
+ 'plugin': {
+ 'worker': {
+ 'max_cpus': 1.0,
+ 'max_memory_mb': 512,
+ 'max_pids': 128,
+ 'max_open_files': 256,
+ 'max_file_size_mb': 512,
+ 'max_workers': 16,
+ 'max_total_cpus': 8.0,
+ 'max_total_memory_mb': 8192,
+ 'max_installations': 10000,
+ 'max_concurrent_restarts': 1,
+ 'restart_failure_threshold': 8,
+ 'restart_failure_window_seconds': 30.0,
+ 'restart_circuit_open_seconds': 60.0,
+ 'require_hard_limits': False,
+ }
+ },
+ 'mcp': {'stdio': {'enabled': True}},
+ 'monitoring': {
+ 'query_limits': {
+ 'page_rows': 1000,
+ 'export_rows': 10000,
+ 'detail_rows': 2000,
+ 'timeseries_buckets': 1000,
+ 'max_offset': 1000000,
+ },
+ 'auto_cleanup': {'max_batches_per_table_per_run': 4},
+ },
+ 'storage': {
+ 'max_object_read_bytes': 10485760,
+ 'cleanup': {'max_files_per_run': 1000},
+ },
+ 'webhooks': {
+ 'max_per_workspace': 16,
+ 'max_inflight_requests': 16,
+ },
+ 'box': {
+ 'limits': {
+ 'max_workspace_entries': 100000,
+ }
+ },
+}
+
+
+def _complete_runtime_policy_defaults(cfg: dict) -> dict:
+ """Backfill typed security-policy leaves before applying env overrides.
+
+ The historic config loader intentionally does not deep-complete the whole
+ template. These fields are different: their native env overrides must
+ retain boolean/numeric types on upgraded instances, so their defaults must
+ exist before ``CLOUD__...``, ``PLUGIN__...`` and ``MCP__...`` are parsed.
+ """
+
+ def merge(target: dict, defaults: dict, path: tuple[str, ...] = ()) -> None:
+ for key, default in defaults.items():
+ if key not in target:
+ target[key] = copy.deepcopy(default)
+ continue
+ if isinstance(default, dict):
+ if not isinstance(target[key], dict):
+ dotted_path = '.'.join((*path, key))
+ raise ValueError(f'{dotted_path} must be a mapping')
+ merge(target[key], default, (*path, key))
+
+ merge(cfg, _RUNTIME_POLICY_DEFAULTS)
+ return cfg
+
+
def _apply_env_overrides_to_config(cfg: dict) -> dict:
"""Apply environment variable overrides to data/config.yaml
@@ -64,11 +161,19 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
if '__' not in env_key:
continue
- print(f'apply env overrides to config: env_key: {env_key}, env_value: {env_value}')
-
# Convert environment variable name to config path
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
keys = [key.lower() for key in env_key.split('__')]
+ # macOS and some launchers expose variables such as
+ # ``__CF_USER_TEXT_ENCODING``. They are not LangBot config paths and
+ # must not create an empty top-level YAML key when config is dumped.
+ if any(not key for key in keys):
+ continue
+
+ # Values may contain database passwords, runtime control tokens, or
+ # provider credentials. Keep the useful audit breadcrumb without ever
+ # copying the secret into startup logs.
+ print(f'apply env override to config: env_key: {env_key}')
# Navigate to the target value and validate the path
current = cfg
@@ -150,9 +255,21 @@ class LoadConfigStage(stage.BootingStage):
ap.instance_config = await config.load_yaml_config('data/config.yaml', 'config.yaml', completion=False)
+ # Deep-complete only typed execution-policy fields. This keeps native
+ # env coercion reliable for existing data/config.yaml files.
+ ap.instance_config.data = _complete_runtime_policy_defaults(ap.instance_config.data)
+
# Apply environment variable overrides to data/config.yaml
ap.instance_config.data = _apply_env_overrides_to_config(ap.instance_config.data)
+ blocking_config = ap.instance_config.data['system']['blocking_executor']
+ ap.blocking_executor = bounded_executor.configure_bounded_default_executor(
+ ap.event_loop,
+ max_workers=blocking_config['max_workers'],
+ max_pending=blocking_config['max_pending'],
+ max_inflight_per_scope=blocking_config['max_inflight_per_scope'],
+ )
+
await ap.instance_config.dump_config()
# load or generate instance id
diff --git a/src/langbot/pkg/core/stages/show_notes.py b/src/langbot/pkg/core/stages/show_notes.py
index d0f861ba3..912c0fb4b 100644
--- a/src/langbot/pkg/core/stages/show_notes.py
+++ b/src/langbot/pkg/core/stages/show_notes.py
@@ -1,8 +1,7 @@
from __future__ import annotations
-import asyncio
-
from .. import stage, app, note
+from .. import entities as core_entities
from ...utils import importutil
from .. import notes
@@ -31,6 +30,12 @@ class ShowNotesStage(stage.BootingStage):
if msg:
ap.logger.log(level, msg)
- asyncio.create_task(ayield_note(note_inst))
+ ap.task_mgr.create_task(
+ ayield_note(note_inst),
+ kind='launch-note',
+ name=f'launch-note-{note_cls.__name__}',
+ scopes=[core_entities.LifecycleControlScope.APPLICATION],
+ instance_uuid=ap.workspace_service.instance_uuid,
+ )
except Exception:
continue
diff --git a/src/langbot/pkg/core/task_boundary.py b/src/langbot/pkg/core/task_boundary.py
new file mode 100644
index 000000000..c9e652be2
--- /dev/null
+++ b/src/langbot/pkg/core/task_boundary.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+import asyncio
+import contextvars
+import typing
+
+from ..utils import bounded_executor
+
+
+T = typing.TypeVar('T')
+
+
+def create_detached_task(
+ coro: typing.Coroutine[typing.Any, typing.Any, T],
+ *,
+ loop: asyncio.AbstractEventLoop | None = None,
+ name: str | None = None,
+ after_commit_manager: typing.Any | None = None,
+ workspace_uuid: str | None = None,
+) -> asyncio.Task[T]:
+ """Create a task that inherits no request-local ContextVars.
+
+ A normal ``asyncio.create_task`` copies the caller's context. That is
+ unsafe for work which outlives an HTTP request because it can copy the
+ request's active database transaction or trusted tenant scope into a
+ different asyncio task. Detached work must receive durable identity such
+ as ``ExecutionContext`` through explicit arguments and establish its own
+ tenant scope or unit of work whenever it accesses persistence.
+ """
+
+ task_loop = loop or asyncio.get_running_loop()
+ gate: asyncio.Future[None] | None = None
+ # Inspect the type so dynamic Mock/AsyncMock attributes do not turn into a
+ # fake gate in lightweight tests or embedders.
+ gate_factory = getattr(type(after_commit_manager), 'create_after_commit_gate', None)
+ if callable(gate_factory):
+ gate = gate_factory(after_commit_manager)
+ task_coro = _wait_for_commit(coro, gate) if gate is not None else coro
+ if workspace_uuid is not None:
+ task_coro = bounded_executor.run_in_blocking_work_scope(
+ task_coro,
+ workspace_uuid,
+ )
+ return task_loop.create_task(task_coro, name=name, context=contextvars.Context())
+
+
+async def _wait_for_commit(
+ coro: typing.Coroutine[typing.Any, typing.Any, T],
+ gate: asyncio.Future[None],
+) -> T:
+ try:
+ await gate
+ except BaseException:
+ coro.close()
+ raise
+ return await coro
+
+
+async def run_in_workspace_uow(
+ ap: typing.Any,
+ workspace_uuid: str,
+ operation: typing.Callable[[], typing.Awaitable[T]],
+) -> T:
+ """Run one short persistence section in a detached Cloud task scope.
+
+ This helper deliberately scopes only the supplied operation. Callers
+ should not wrap long-running network or runtime work in a database
+ transaction.
+ """
+
+ persistence_mgr = getattr(ap, 'persistence_mgr', None)
+ if persistence_mgr is None:
+ return await operation()
+ cloud_runtime = getattr(getattr(persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if not cloud_runtime:
+ return await operation()
+
+ tenant_uow = getattr(persistence_mgr, 'tenant_uow', None)
+ if not callable(tenant_uow):
+ raise RuntimeError('Detached Cloud tasks require an explicit tenant unit of work')
+ async with tenant_uow(workspace_uuid):
+ return await operation()
diff --git a/src/langbot/pkg/core/taskmgr.py b/src/langbot/pkg/core/taskmgr.py
index 8bf8784a1..25cc38e0d 100644
--- a/src/langbot/pkg/core/taskmgr.py
+++ b/src/langbot/pkg/core/taskmgr.py
@@ -7,6 +7,8 @@ import time
from . import app
from . import entities as core_entities
+from .errors import TaskCapacityError
+from .task_boundary import create_detached_task
class TaskContext:
@@ -21,13 +23,18 @@ class TaskContext:
metadata: dict
"""Structured metadata for progress reporting"""
- def __init__(self):
+ def __init__(self, max_log_chars: int = 200000):
self.current_action = 'default'
self.log = ''
self.metadata = {}
+ self.max_log_chars = max(int(max_log_chars), 1)
def _log(self, msg: str):
self.log += msg + '\n'
+ if len(self.log) > self.max_log_chars:
+ marker = '[older task output truncated]\n'
+ keep = max(self.max_log_chars - len(marker), 0)
+ self.log = marker + (self.log[-keep:] if keep else '')
def set_current_action(self, action: str):
self.current_action = action
@@ -98,6 +105,15 @@ class TaskWrapper:
scopes: list[core_entities.LifecycleControlScope]
"""Task scope"""
+ instance_uuid: str | None
+ """Owning LangBot instance for a tenant user task."""
+
+ workspace_uuid: str | None
+ """Owning Workspace for a tenant user task."""
+
+ placement_generation: int | None
+ """Workspace execution fence captured when the task was created."""
+
def __init__(
self,
ap: app.Application,
@@ -108,18 +124,30 @@ class TaskWrapper:
label: str = '',
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
+ instance_uuid: str | None = None,
+ workspace_uuid: str | None = None,
+ placement_generation: int | None = None,
):
self.id = TaskWrapper._id_index
TaskWrapper._id_index += 1
self.ap = ap
self.task_context = context or TaskContext()
- self.task = self.ap.event_loop.create_task(coro)
+ self.task = create_detached_task(
+ coro,
+ loop=self.ap.event_loop,
+ name=name or None,
+ after_commit_manager=getattr(self.ap, 'persistence_mgr', None),
+ workspace_uuid=workspace_uuid,
+ )
self.task_type = task_type
self.kind = kind
self.name = name
self.label = label if label != '' else name
self.task.set_name(name)
self.scopes = scopes
+ self.instance_uuid = instance_uuid
+ self.workspace_uuid = workspace_uuid
+ self.placement_generation = placement_generation
self.created_at = time.time()
def assume_exception(self):
@@ -155,6 +183,8 @@ class TaskWrapper:
'kind': self.kind,
'name': self.name,
'label': self.label,
+ 'workspace_uuid': self.workspace_uuid,
+ 'placement_generation': self.placement_generation,
'scopes': [scope.value for scope in self.scopes],
'created_at': self.created_at,
'task_context': self.task_context.to_dict(),
@@ -184,6 +214,39 @@ class AsyncTaskManager:
self.ap = ap
self.tasks = []
+ def _task_log_limit(self) -> int:
+ value = self.ap.instance_config.data.get('system', {}).get('task_retention', {}).get('max_log_chars', 200000)
+ try:
+ value = int(value)
+ except (TypeError, ValueError):
+ value = 200000
+ return max(value, 1)
+
+ def _user_task_limit(self, name: str, default: int) -> int:
+ value = self.ap.instance_config.data.get('system', {}).get('task_retention', {}).get(name, default)
+ try:
+ value = int(value)
+ except (TypeError, ValueError):
+ value = default
+ return max(value, 1)
+
+ def _admit_user_task(self, coro: typing.Coroutine, workspace_uuid: str | None) -> None:
+ active_user_tasks = [
+ wrapper for wrapper in self.tasks if wrapper.task_type == 'user' and not wrapper.task.done()
+ ]
+ global_limit = self._user_task_limit('max_active_user_tasks', 256)
+ if len(active_user_tasks) >= global_limit:
+ coro.close()
+ raise TaskCapacityError('The instance has too many active user operations')
+
+ if workspace_uuid is None:
+ return
+ workspace_limit = self._user_task_limit('max_active_user_tasks_per_workspace', 8)
+ active_workspace_tasks = sum(1 for wrapper in active_user_tasks if wrapper.workspace_uuid == workspace_uuid)
+ if active_workspace_tasks >= workspace_limit:
+ coro.close()
+ raise TaskCapacityError('The Workspace has too many active user operations')
+
def create_task(
self,
coro: typing.Coroutine,
@@ -193,8 +256,30 @@ class AsyncTaskManager:
label: str = '',
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
+ instance_uuid: str | None = None,
+ workspace_uuid: str | None = None,
+ placement_generation: int | None = None,
) -> TaskWrapper:
- wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
+ if context is None:
+ context = TaskContext(max_log_chars=self._task_log_limit())
+ else:
+ context.max_log_chars = self._task_log_limit()
+ if len(context.log) > context.max_log_chars:
+ context.log = context.log[-context.max_log_chars :]
+
+ wrapper = TaskWrapper(
+ self.ap,
+ coro,
+ task_type,
+ kind,
+ name,
+ label,
+ context,
+ scopes,
+ instance_uuid,
+ workspace_uuid,
+ placement_generation,
+ )
self.tasks.append(wrapper)
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
self._prune_completed_tasks()
@@ -208,8 +293,23 @@ class AsyncTaskManager:
label: str = '',
context: TaskContext = None,
scopes: list[core_entities.LifecycleControlScope] = [core_entities.LifecycleControlScope.APPLICATION],
+ instance_uuid: str | None = None,
+ workspace_uuid: str | None = None,
+ placement_generation: int | None = None,
) -> TaskWrapper:
- return self.create_task(coro, 'user', kind, name, label, context, scopes)
+ self._admit_user_task(coro, workspace_uuid)
+ return self.create_task(
+ coro,
+ 'user',
+ kind,
+ name,
+ label,
+ context,
+ scopes,
+ instance_uuid,
+ workspace_uuid,
+ placement_generation,
+ )
async def wait_all(self):
await asyncio.gather(*[t.task for t in self.tasks], return_exceptions=True)
@@ -221,12 +321,20 @@ class AsyncTaskManager:
self,
type: str = None,
kind: str = None,
+ *,
+ instance_uuid: str | None = None,
+ workspace_uuid: str | None = None,
+ placement_generation: int | None = None,
) -> dict:
return {
'tasks': [
t.to_dict()
for t in self.tasks
- if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
+ if (type is None or t.task_type == type)
+ and (kind is None or t.kind == kind)
+ and (instance_uuid is None or t.instance_uuid == instance_uuid)
+ and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
+ and (placement_generation is None or t.placement_generation == placement_generation)
],
'id_index': TaskWrapper._id_index,
}
@@ -240,9 +348,21 @@ class AsyncTaskManager:
'id_index': TaskWrapper._id_index,
}
- def get_task_by_id(self, id: int) -> TaskWrapper | None:
+ def get_task_by_id(
+ self,
+ id: int,
+ *,
+ instance_uuid: str | None = None,
+ workspace_uuid: str | None = None,
+ placement_generation: int | None = None,
+ ) -> TaskWrapper | None:
for t in self.tasks:
- if t.id == id:
+ if (
+ t.id == id
+ and (instance_uuid is None or t.instance_uuid == instance_uuid)
+ and (workspace_uuid is None or t.workspace_uuid == workspace_uuid)
+ and (placement_generation is None or t.placement_generation == placement_generation)
+ ):
return t
return None
diff --git a/src/langbot/pkg/discover/engine.py b/src/langbot/pkg/discover/engine.py
index 713420d14..273dcbf34 100644
--- a/src/langbot/pkg/discover/engine.py
+++ b/src/langbot/pkg/discover/engine.py
@@ -211,6 +211,7 @@ class ComponentDiscoveryEngine:
def __init__(self, ap: app.Application):
self.ap = ap
+ self.components = {}
def load_component_manifest(self, path: str, owner: str = 'builtin', no_save: bool = False) -> Component | None:
"""加载组件清单"""
diff --git a/src/langbot/pkg/entity/errors/account.py b/src/langbot/pkg/entity/errors/account.py
index edd5b41fa..a2d0f1e85 100644
--- a/src/langbot/pkg/entity/errors/account.py
+++ b/src/langbot/pkg/entity/errors/account.py
@@ -2,5 +2,19 @@ from __future__ import annotations
class AccountEmailMismatchError(Exception):
- def __str__(self):
+ def __str__(self) -> str:
return 'Account email mismatch'
+
+
+class SpaceAccountNotRegisteredError(AccountEmailMismatchError):
+ code = 'space_account_not_registered'
+
+ def __str__(self) -> str:
+ return 'No Account is registered for this Space email'
+
+
+class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
+ code = 'space_account_binding_required'
+
+ def __str__(self) -> str:
+ return 'This local Account must bind Space from Account settings before Space login'
diff --git a/src/langbot/pkg/entity/persistence/apikey.py b/src/langbot/pkg/entity/persistence/apikey.py
index 488c03241..f15bb9a41 100644
--- a/src/langbot/pkg/entity/persistence/apikey.py
+++ b/src/langbot/pkg/entity/persistence/apikey.py
@@ -1,16 +1,47 @@
+import enum
+import uuid as uuid_lib
+
import sqlalchemy
from .base import Base
+class ApiKeyStatus(enum.StrEnum):
+ ACTIVE = 'active'
+ REVOKED = 'revoked'
+
+
+def _new_uuid() -> str:
+ return str(uuid_lib.uuid4())
+
+
class ApiKey(Base):
"""API Key for external service authentication"""
__tablename__ = 'api_keys'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
+ uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False, default=_new_uuid)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
+ created_by_account_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
+ nullable=True,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
- key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True)
+ key_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
+ scopes = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=list, server_default='[]')
+ status = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=ApiKeyStatus.ACTIVE.value,
+ )
+ expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
@@ -19,3 +50,16 @@ class ApiKey(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
+
+ __table_args__ = (
+ sqlalchemy.Index('uq_api_keys_uuid', 'uuid', unique=True),
+ # Authentication begins with the presented secret, before a Workspace
+ # can be trusted, so hashes remain globally unique.
+ sqlalchemy.Index('uq_api_keys_key_hash', 'key_hash', unique=True),
+ sqlalchemy.Index('ix_api_keys_workspace_name', 'workspace_uuid', 'name'),
+ sqlalchemy.Index('ix_api_keys_workspace_status', 'workspace_uuid', 'status'),
+ sqlalchemy.CheckConstraint(
+ "status IN ('active', 'revoked')",
+ name='ck_api_keys_status',
+ ),
+ )
diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py
index 9043b7560..d95169f33 100644
--- a/src/langbot/pkg/entity/persistence/bot.py
+++ b/src/langbot/pkg/entity/persistence/bot.py
@@ -9,12 +9,31 @@ class BotAdmin(Base):
__tablename__ = 'bot_admins'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
- __table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),)
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint(
+ 'workspace_uuid',
+ 'bot_uuid',
+ 'launcher_type',
+ 'launcher_id',
+ name='uq_bot_admin',
+ ),
+ sqlalchemy.ForeignKeyConstraint(
+ ['workspace_uuid', 'bot_uuid'],
+ ['bots.workspace_uuid', 'bots.uuid'],
+ name='fk_bot_admins_workspace_bot',
+ ondelete='CASCADE',
+ ),
+ )
class Bot(Base):
@@ -23,6 +42,11 @@ class Bot(Base):
__tablename__ = 'bots'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
adapter = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -38,3 +62,8 @@ class Bot(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
+
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_bots_workspace_uuid'),
+ sqlalchemy.Index('ix_bots_workspace_name', 'workspace_uuid', 'name'),
+ )
diff --git a/src/langbot/pkg/entity/persistence/bstorage.py b/src/langbot/pkg/entity/persistence/bstorage.py
index 674dee29b..eb24b5cb3 100644
--- a/src/langbot/pkg/entity/persistence/bstorage.py
+++ b/src/langbot/pkg/entity/persistence/bstorage.py
@@ -8,6 +8,11 @@ class BinaryStorage(Base):
__tablename__ = 'binary_storages'
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ primary_key=True,
+ )
unique_key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
key = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
owner_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -20,3 +25,12 @@ class BinaryStorage(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
+
+ __table_args__ = (
+ sqlalchemy.Index(
+ 'ix_binary_storages_workspace_owner',
+ 'workspace_uuid',
+ 'owner_type',
+ 'owner',
+ ),
+ )
diff --git a/src/langbot/pkg/entity/persistence/cloud_directory.py b/src/langbot/pkg/entity/persistence/cloud_directory.py
new file mode 100644
index 000000000..11e41cdfb
--- /dev/null
+++ b/src/langbot/pkg/entity/persistence/cloud_directory.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import sqlalchemy
+
+from .base import Base
+
+
+class DirectoryProjectionState(Base):
+ """Durable cursor and lease for one verified Cloud directory."""
+
+ __tablename__ = 'directory_projection_states'
+
+ instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ cursor = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
+ snapshot_coverage_cursor = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
+ snapshot_fingerprint = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
+ last_applied_at = sqlalchemy.Column(sqlalchemy.DateTime(timezone=True), nullable=False)
+ lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ sqlalchemy.CheckConstraint('cursor >= 0', name='ck_directory_projection_state_cursor'),
+ sqlalchemy.CheckConstraint(
+ 'snapshot_coverage_cursor >= 0 AND snapshot_coverage_cursor <= cursor',
+ name='ck_directory_projection_state_snapshot_coverage',
+ ),
+ sqlalchemy.CheckConstraint(
+ 'length(snapshot_fingerprint) = 64',
+ name='ck_directory_projection_state_fingerprint',
+ ),
+ )
+
+
+class DirectoryProjectionInbox(Base):
+ """Idempotency ledger for signed control-plane directory events."""
+
+ __tablename__ = 'directory_projection_inbox'
+
+ instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ event_uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True)
+ cursor = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False)
+ event_type = sqlalchemy.Column(sqlalchemy.String(128), nullable=False)
+ revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False)
+ fingerprint = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
+ received_at = sqlalchemy.Column(
+ sqlalchemy.DateTime(timezone=True),
+ nullable=False,
+ server_default=sqlalchemy.func.now(),
+ )
+ applied_at = sqlalchemy.Column(sqlalchemy.DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint(
+ 'instance_uuid',
+ 'cursor',
+ name='uq_directory_projection_inbox_cursor',
+ ),
+ sqlalchemy.Index(
+ 'ix_directory_projection_inbox_pending',
+ 'instance_uuid',
+ 'applied_at',
+ 'cursor',
+ ),
+ sqlalchemy.CheckConstraint('cursor > 0', name='ck_directory_projection_inbox_cursor'),
+ sqlalchemy.CheckConstraint('revision > 0', name='ck_directory_projection_inbox_revision'),
+ sqlalchemy.CheckConstraint(
+ 'length(fingerprint) = 64',
+ name='ck_directory_projection_inbox_fingerprint',
+ ),
+ )
diff --git a/src/langbot/pkg/entity/persistence/mcp.py b/src/langbot/pkg/entity/persistence/mcp.py
index 983fdce53..1e7f93a4a 100644
--- a/src/langbot/pkg/entity/persistence/mcp.py
+++ b/src/langbot/pkg/entity/persistence/mcp.py
@@ -7,6 +7,11 @@ class MCPServer(Base):
__tablename__ = 'mcp_servers'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, remote (legacy: sse, http)
@@ -22,3 +27,8 @@ class MCPServer(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
+
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint('workspace_uuid', 'name', name='uq_mcp_servers_workspace_name'),
+ sqlalchemy.Index('ix_mcp_servers_workspace_enable', 'workspace_uuid', 'enable'),
+ )
diff --git a/src/langbot/pkg/entity/persistence/metadata.py b/src/langbot/pkg/entity/persistence/metadata.py
index ac3b4602f..70b1790d7 100644
--- a/src/langbot/pkg/entity/persistence/metadata.py
+++ b/src/langbot/pkg/entity/persistence/metadata.py
@@ -19,3 +19,17 @@ class Metadata(Base):
key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
value = sqlalchemy.Column(sqlalchemy.String(255))
+
+
+class WorkspaceMetadata(Base):
+ """Metadata owned by one workspace rather than by the LangBot instance."""
+
+ __tablename__ = 'workspace_metadata'
+
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ primary_key=True,
+ )
+ key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ value = sqlalchemy.Column(sqlalchemy.String(255))
diff --git a/src/langbot/pkg/entity/persistence/model.py b/src/langbot/pkg/entity/persistence/model.py
index 5b5f1fe2f..c04a532b5 100644
--- a/src/langbot/pkg/entity/persistence/model.py
+++ b/src/langbot/pkg/entity/persistence/model.py
@@ -9,6 +9,11 @@ class ModelProvider(Base):
__tablename__ = 'model_providers'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
requester = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
base_url = sqlalchemy.Column(sqlalchemy.String(512), nullable=False)
@@ -21,6 +26,12 @@ class ModelProvider(Base):
onupdate=sqlalchemy.func.now(),
)
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_model_providers_workspace_uuid'),
+ sqlalchemy.Index('ix_model_providers_workspace_name', 'workspace_uuid', 'name'),
+ sqlalchemy.Index('ix_model_providers_workspace_requester', 'workspace_uuid', 'requester'),
+ )
+
class LLMModel(Base):
"""LLM model"""
@@ -28,6 +39,11 @@ class LLMModel(Base):
__tablename__ = 'llm_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
abilities = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=[])
@@ -42,6 +58,16 @@ class LLMModel(Base):
onupdate=sqlalchemy.func.now(),
)
+ __table_args__ = (
+ sqlalchemy.ForeignKeyConstraint(
+ ['workspace_uuid', 'provider_uuid'],
+ ['model_providers.workspace_uuid', 'model_providers.uuid'],
+ name='fk_llm_models_workspace_provider',
+ ),
+ sqlalchemy.Index('ix_llm_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
+ sqlalchemy.Index('ix_llm_models_workspace_name', 'workspace_uuid', 'name'),
+ )
+
class EmbeddingModel(Base):
"""Embedding model"""
@@ -49,6 +75,11 @@ class EmbeddingModel(Base):
__tablename__ = 'embedding_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
@@ -61,6 +92,16 @@ class EmbeddingModel(Base):
onupdate=sqlalchemy.func.now(),
)
+ __table_args__ = (
+ sqlalchemy.ForeignKeyConstraint(
+ ['workspace_uuid', 'provider_uuid'],
+ ['model_providers.workspace_uuid', 'model_providers.uuid'],
+ name='fk_embedding_models_workspace_provider',
+ ),
+ sqlalchemy.Index('ix_embedding_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
+ sqlalchemy.Index('ix_embedding_models_workspace_name', 'workspace_uuid', 'name'),
+ )
+
class RerankModel(Base):
"""Rerank model"""
@@ -68,6 +109,11 @@ class RerankModel(Base):
__tablename__ = 'rerank_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
@@ -79,3 +125,13 @@ class RerankModel(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
+
+ __table_args__ = (
+ sqlalchemy.ForeignKeyConstraint(
+ ['workspace_uuid', 'provider_uuid'],
+ ['model_providers.workspace_uuid', 'model_providers.uuid'],
+ name='fk_rerank_models_workspace_provider',
+ ),
+ sqlalchemy.Index('ix_rerank_models_workspace_provider', 'workspace_uuid', 'provider_uuid'),
+ sqlalchemy.Index('ix_rerank_models_workspace_name', 'workspace_uuid', 'name'),
+ )
diff --git a/src/langbot/pkg/entity/persistence/monitoring.py b/src/langbot/pkg/entity/persistence/monitoring.py
index f594b8187..35ebe161a 100644
--- a/src/langbot/pkg/entity/persistence/monitoring.py
+++ b/src/langbot/pkg/entity/persistence/monitoring.py
@@ -9,6 +9,11 @@ class MonitoringMessage(Base):
__tablename__ = 'monitoring_messages'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -25,6 +30,11 @@ class MonitoringMessage(Base):
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
+ __table_args__ = (
+ sqlalchemy.Index('ix_monitoring_messages_workspace_timestamp', 'workspace_uuid', 'timestamp'),
+ sqlalchemy.Index('ix_monitoring_messages_workspace_session', 'workspace_uuid', 'session_id'),
+ )
+
class MonitoringLLMCall(Base):
"""LLM call records"""
@@ -32,6 +42,11 @@ class MonitoringLLMCall(Base):
__tablename__ = 'monitoring_llm_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
input_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
@@ -48,6 +63,11 @@ class MonitoringLLMCall(Base):
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
+ __table_args__ = (
+ sqlalchemy.Index('ix_monitoring_llm_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'),
+ sqlalchemy.Index('ix_monitoring_llm_calls_workspace_session', 'workspace_uuid', 'session_id'),
+ )
+
class MonitoringToolCall(Base):
"""Tool call records"""
@@ -55,6 +75,11 @@ class MonitoringToolCall(Base):
__tablename__ = 'monitoring_tool_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill
@@ -70,12 +95,22 @@ class MonitoringToolCall(Base):
result = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
+ __table_args__ = (
+ sqlalchemy.Index('ix_monitoring_tool_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'),
+ sqlalchemy.Index('ix_monitoring_tool_calls_workspace_session', 'workspace_uuid', 'session_id'),
+ )
+
class MonitoringSession(Base):
"""Session tracking records"""
__tablename__ = 'monitoring_sessions'
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ primary_key=True,
+ )
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
@@ -89,6 +124,11 @@ class MonitoringSession(Base):
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
+ __table_args__ = (
+ sqlalchemy.Index('ix_monitoring_sessions_workspace_activity', 'workspace_uuid', 'last_activity'),
+ sqlalchemy.Index('ix_monitoring_sessions_workspace_active', 'workspace_uuid', 'is_active'),
+ )
+
class MonitoringError(Base):
"""Error log records"""
@@ -96,6 +136,11 @@ class MonitoringError(Base):
__tablename__ = 'monitoring_errors'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
error_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
@@ -107,6 +152,11 @@ class MonitoringError(Base):
stack_trace = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
+ __table_args__ = (
+ sqlalchemy.Index('ix_monitoring_errors_workspace_timestamp', 'workspace_uuid', 'timestamp'),
+ sqlalchemy.Index('ix_monitoring_errors_workspace_session', 'workspace_uuid', 'session_id'),
+ )
+
class MonitoringEmbeddingCall(Base):
"""Embedding call records"""
@@ -114,6 +164,11 @@ class MonitoringEmbeddingCall(Base):
__tablename__ = 'monitoring_embedding_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
prompt_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
@@ -129,6 +184,19 @@ class MonitoringEmbeddingCall(Base):
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
+ __table_args__ = (
+ sqlalchemy.Index(
+ 'ix_monitoring_embedding_calls_workspace_timestamp',
+ 'workspace_uuid',
+ 'timestamp',
+ ),
+ sqlalchemy.Index(
+ 'ix_monitoring_embedding_calls_workspace_kb',
+ 'workspace_uuid',
+ 'knowledge_base_id',
+ ),
+ )
+
class MonitoringFeedback(Base):
"""User feedback records (like/dislike) from AI Bot conversations"""
@@ -136,8 +204,13 @@ class MonitoringFeedback(Base):
__tablename__ = 'monitoring_feedback'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
- feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
+ feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
@@ -151,3 +224,13 @@ class MonitoringFeedback(Base):
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom
+
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint(
+ 'workspace_uuid',
+ 'feedback_id',
+ name='uq_monitoring_feedback_workspace_feedback_id',
+ ),
+ sqlalchemy.Index('ix_monitoring_feedback_workspace_timestamp', 'workspace_uuid', 'timestamp'),
+ sqlalchemy.Index('ix_monitoring_feedback_workspace_session', 'workspace_uuid', 'session_id'),
+ )
diff --git a/src/langbot/pkg/entity/persistence/pipeline.py b/src/langbot/pkg/entity/persistence/pipeline.py
index d74cf78ee..6451ed6b7 100644
--- a/src/langbot/pkg/entity/persistence/pipeline.py
+++ b/src/langbot/pkg/entity/persistence/pipeline.py
@@ -9,6 +9,11 @@ class LegacyPipeline(Base):
__tablename__ = 'legacy_pipelines'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='⚙️')
@@ -36,6 +41,16 @@ class LegacyPipeline(Base):
},
)
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint(
+ 'workspace_uuid',
+ 'uuid',
+ name='uq_legacy_pipelines_workspace_uuid',
+ ),
+ sqlalchemy.Index('ix_legacy_pipelines_workspace_name', 'workspace_uuid', 'name'),
+ sqlalchemy.Index('ix_legacy_pipelines_workspace_default', 'workspace_uuid', 'is_default'),
+ )
+
class PipelineRunRecord(Base):
"""Pipeline run record"""
@@ -43,6 +58,11 @@ class PipelineRunRecord(Base):
__tablename__ = 'pipeline_run_records'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
status = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
@@ -56,3 +76,22 @@ class PipelineRunRecord(Base):
finished_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
result = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
knowledge_base_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
+
+ __table_args__ = (
+ sqlalchemy.ForeignKeyConstraint(
+ ['workspace_uuid', 'pipeline_uuid'],
+ ['legacy_pipelines.workspace_uuid', 'legacy_pipelines.uuid'],
+ name='fk_pipeline_run_records_workspace_pipeline',
+ ondelete='CASCADE',
+ ),
+ sqlalchemy.Index(
+ 'ix_pipeline_run_records_workspace_pipeline',
+ 'workspace_uuid',
+ 'pipeline_uuid',
+ ),
+ sqlalchemy.Index(
+ 'ix_pipeline_run_records_workspace_created',
+ 'workspace_uuid',
+ 'created_at',
+ ),
+ )
diff --git a/src/langbot/pkg/entity/persistence/plugin.py b/src/langbot/pkg/entity/persistence/plugin.py
index 61629586d..4a0123938 100644
--- a/src/langbot/pkg/entity/persistence/plugin.py
+++ b/src/langbot/pkg/entity/persistence/plugin.py
@@ -1,3 +1,6 @@
+import hashlib
+import uuid
+
import sqlalchemy
from .base import Base
@@ -8,8 +11,24 @@ class PluginSetting(Base):
__tablename__ = 'plugin_settings'
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ primary_key=True,
+ )
plugin_author = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
plugin_name = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ installation_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ nullable=False,
+ default=lambda: str(uuid.uuid4()),
+ )
+ artifact_digest = sqlalchemy.Column(
+ sqlalchemy.String(64),
+ nullable=False,
+ default=lambda: hashlib.sha256(f'pending:{uuid.uuid4()}'.encode()).hexdigest(),
+ )
+ runtime_revision = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=1)
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True)
priority = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default=dict)
@@ -22,3 +41,16 @@ class PluginSetting(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
+
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint('installation_uuid', name='uq_plugin_settings_installation_uuid'),
+ sqlalchemy.CheckConstraint('runtime_revision >= 1', name='ck_plugin_settings_runtime_revision_positive'),
+ sqlalchemy.CheckConstraint('length(artifact_digest) = 64', name='ck_plugin_settings_artifact_digest_length'),
+ sqlalchemy.Index('ix_plugin_settings_workspace_enabled', 'workspace_uuid', 'enabled'),
+ sqlalchemy.Index(
+ 'ix_plugin_settings_workspace_installation',
+ 'workspace_uuid',
+ 'installation_uuid',
+ unique=True,
+ ),
+ )
diff --git a/src/langbot/pkg/entity/persistence/rag.py b/src/langbot/pkg/entity/persistence/rag.py
index cfb1f0a5c..8cd1592eb 100644
--- a/src/langbot/pkg/entity/persistence/rag.py
+++ b/src/langbot/pkg/entity/persistence/rag.py
@@ -5,6 +5,11 @@ from .base import Base
class KnowledgeBase(Base):
__tablename__ = 'knowledge_bases'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String, index=True)
description = sqlalchemy.Column(sqlalchemy.Text)
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚')
@@ -13,8 +18,20 @@ class KnowledgeBase(Base):
# New fields for plugin-based RAG
knowledge_engine_plugin_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)
collection_id = sqlalchemy.Column(sqlalchemy.String, nullable=True)
+ # Server-managed compatibility marker. Pre-tenancy installations stored
+ # vectors directly under ``collection_id``; new knowledge bases use a
+ # tenant-derived opaque physical collection instead.
+ legacy_vector_collection = sqlalchemy.Column(
+ sqlalchemy.Boolean,
+ nullable=False,
+ default=False,
+ server_default=sqlalchemy.false(),
+ )
creation_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)
retrieval_settings = sqlalchemy.Column(sqlalchemy.JSON, nullable=True, default=None)
+ # Server-selected pgvector dimension. ``None`` means no embedding has been
+ # written yet; the first pgvector upsert binds it atomically.
+ embedding_dimension = sqlalchemy.Column(sqlalchemy.Integer, nullable=True)
# Field sets for different operations
MUTABLE_FIELDS = {'name', 'description', 'retrieval_settings'}
@@ -23,22 +40,77 @@ class KnowledgeBase(Base):
CREATE_FIELDS = MUTABLE_FIELDS | {'uuid', 'knowledge_engine_plugin_id', 'collection_id', 'creation_settings'}
"""Fields used when creating a new knowledge base."""
- ALL_DB_FIELDS = CREATE_FIELDS | {'emoji', 'created_at', 'updated_at'}
+ ALL_DB_FIELDS = CREATE_FIELDS | {
+ 'workspace_uuid',
+ 'legacy_vector_collection',
+ 'embedding_dimension',
+ 'emoji',
+ 'created_at',
+ 'updated_at',
+ }
"""All fields stored in database (for loading from DB row)."""
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_knowledge_bases_workspace_uuid'),
+ sqlalchemy.Index('ix_knowledge_bases_workspace_name', 'workspace_uuid', 'name'),
+ sqlalchemy.Index(
+ 'uq_knowledge_bases_workspace_collection',
+ 'workspace_uuid',
+ 'collection_id',
+ unique=True,
+ sqlite_where=sqlalchemy.text('collection_id IS NOT NULL'),
+ postgresql_where=sqlalchemy.text('collection_id IS NOT NULL'),
+ ),
+ sqlalchemy.CheckConstraint(
+ 'embedding_dimension IS NULL OR embedding_dimension > 0',
+ name='ck_knowledge_bases_embedding_dimension_positive',
+ ),
+ )
+
class File(Base):
__tablename__ = 'knowledge_base_files'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
kb_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
file_name = sqlalchemy.Column(sqlalchemy.String)
extension = sqlalchemy.Column(sqlalchemy.String)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
status = sqlalchemy.Column(sqlalchemy.String, default='pending') # pending, processing, completed, failed
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint('workspace_uuid', 'uuid', name='uq_knowledge_base_files_workspace_uuid'),
+ sqlalchemy.ForeignKeyConstraint(
+ ['workspace_uuid', 'kb_id'],
+ ['knowledge_bases.workspace_uuid', 'knowledge_bases.uuid'],
+ name='fk_knowledge_base_files_workspace_kb',
+ ondelete='CASCADE',
+ ),
+ sqlalchemy.Index('ix_knowledge_base_files_workspace_kb', 'workspace_uuid', 'kb_id'),
+ )
+
class Chunk(Base):
__tablename__ = 'knowledge_base_chunks'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
file_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
text = sqlalchemy.Column(sqlalchemy.Text)
+
+ __table_args__ = (
+ sqlalchemy.ForeignKeyConstraint(
+ ['workspace_uuid', 'file_id'],
+ ['knowledge_base_files.workspace_uuid', 'knowledge_base_files.uuid'],
+ name='fk_knowledge_base_chunks_workspace_file',
+ ondelete='CASCADE',
+ ),
+ sqlalchemy.Index('ix_knowledge_base_chunks_workspace_file', 'workspace_uuid', 'file_id'),
+ )
diff --git a/src/langbot/pkg/entity/persistence/user.py b/src/langbot/pkg/entity/persistence/user.py
index 00ea73803..de3a97143 100644
--- a/src/langbot/pkg/entity/persistence/user.py
+++ b/src/langbot/pkg/entity/persistence/user.py
@@ -1,15 +1,47 @@
+import enum
+import uuid as uuid_lib
+
import sqlalchemy
from .base import Base
+class AccountStatus(enum.StrEnum):
+ ACTIVE = 'active'
+ DISABLED = 'disabled'
+ DELETED = 'deleted'
+
+
+class AccountSource(enum.StrEnum):
+ LOCAL = 'local'
+ CLOUD_PROJECTION = 'cloud_projection'
+
+
class User(Base):
__tablename__ = 'users'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
+ uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ nullable=False,
+ default=lambda: str(uuid_lib.uuid4()),
+ )
user = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
+ normalized_email = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
password = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
+ status = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=AccountStatus.ACTIVE.value,
+ )
+ source = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=AccountSource.LOCAL.value,
+ )
+ projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
+
# Account type: 'local' (default) or 'space'
account_type = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='local')
@@ -27,3 +59,22 @@ class User(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
+
+ __table_args__ = (
+ sqlalchemy.Index('uq_users_uuid', 'uuid', unique=True),
+ sqlalchemy.Index('uq_users_normalized_email', 'normalized_email', unique=True),
+ sqlalchemy.CheckConstraint(
+ 'normalized_email = trim(normalized_email) '
+ 'AND length(normalized_email) > 0 '
+ 'AND length(normalized_email) <= 320',
+ name='ck_users_normalized_email',
+ ),
+ sqlalchemy.CheckConstraint(
+ "status IN ('active', 'disabled', 'deleted')",
+ name='ck_users_status',
+ ),
+ sqlalchemy.CheckConstraint(
+ "source IN ('local', 'cloud_projection')",
+ name='ck_users_source',
+ ),
+ )
diff --git a/src/langbot/pkg/entity/persistence/webhook.py b/src/langbot/pkg/entity/persistence/webhook.py
index 326ab6c47..e5d120132 100644
--- a/src/langbot/pkg/entity/persistence/webhook.py
+++ b/src/langbot/pkg/entity/persistence/webhook.py
@@ -9,6 +9,11 @@ class Webhook(Base):
__tablename__ = 'webhooks'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
url = sqlalchemy.Column(sqlalchemy.String(1024), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(512), nullable=True, default='')
@@ -20,3 +25,5 @@ class Webhook(Base):
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)
+
+ __table_args__ = (sqlalchemy.Index('ix_webhooks_workspace_name', 'workspace_uuid', 'name'),)
diff --git a/src/langbot/pkg/entity/persistence/workspace.py b/src/langbot/pkg/entity/persistence/workspace.py
new file mode 100644
index 000000000..ca3743d4b
--- /dev/null
+++ b/src/langbot/pkg/entity/persistence/workspace.py
@@ -0,0 +1,271 @@
+from __future__ import annotations
+
+import enum
+import uuid as uuid_lib
+
+import sqlalchemy
+
+from .base import Base
+
+
+class WorkspaceType(enum.StrEnum):
+ PERSONAL = 'personal'
+ TEAM = 'team'
+
+
+class WorkspaceStatus(enum.StrEnum):
+ PROVISIONING = 'provisioning'
+ ACTIVE = 'active'
+ SUSPENDED = 'suspended'
+ ARCHIVED = 'archived'
+ DELETED = 'deleted'
+
+
+class WorkspaceSource(enum.StrEnum):
+ LOCAL = 'local'
+ CLOUD_PROJECTION = 'cloud_projection'
+
+
+class MembershipRole(enum.StrEnum):
+ OWNER = 'owner'
+ ADMIN = 'admin'
+ DEVELOPER = 'developer'
+ OPERATOR = 'operator'
+ VIEWER = 'viewer'
+
+
+class MembershipStatus(enum.StrEnum):
+ ACTIVE = 'active'
+ DISABLED = 'disabled'
+ REMOVED = 'removed'
+
+
+class InvitationStatus(enum.StrEnum):
+ PENDING = 'pending'
+ ACCEPTED = 'accepted'
+ REVOKED = 'revoked'
+ EXPIRED = 'expired'
+
+
+class WorkspaceExecutionStatus(enum.StrEnum):
+ PROVISIONING = 'provisioning'
+ ACTIVE = 'active'
+ MIGRATING = 'migrating'
+ DRAINING = 'draining'
+ INACTIVE = 'inactive'
+
+
+class WorkspaceExecutionSource(enum.StrEnum):
+ LOCAL = 'local'
+ CLOUD = 'cloud'
+
+
+def _new_uuid() -> str:
+ return str(uuid_lib.uuid4())
+
+
+class Workspace(Base):
+ __tablename__ = 'workspaces'
+
+ uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
+ instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
+ name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
+ slug = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
+ type = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=WorkspaceType.TEAM.value,
+ )
+ status = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=WorkspaceStatus.ACTIVE.value,
+ )
+ created_by_account_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
+ nullable=True,
+ )
+ source = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=WorkspaceSource.LOCAL.value,
+ )
+ projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
+ created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
+ updated_at = sqlalchemy.Column(
+ sqlalchemy.DateTime,
+ nullable=False,
+ server_default=sqlalchemy.func.now(),
+ onupdate=sqlalchemy.func.now(),
+ )
+
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint('instance_uuid', 'slug', name='uq_workspaces_instance_slug'),
+ sqlalchemy.Index('ix_workspaces_instance_status', 'instance_uuid', 'status'),
+ sqlalchemy.Index(
+ 'uq_workspaces_local_instance',
+ 'instance_uuid',
+ unique=True,
+ sqlite_where=sqlalchemy.text("source = 'local'"),
+ postgresql_where=sqlalchemy.text("source = 'local'"),
+ ),
+ sqlalchemy.CheckConstraint(
+ "type IN ('personal', 'team')",
+ name='ck_workspaces_type',
+ ),
+ sqlalchemy.CheckConstraint(
+ "status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')",
+ name='ck_workspaces_status',
+ ),
+ sqlalchemy.CheckConstraint(
+ "source IN ('local', 'cloud_projection')",
+ name='ck_workspaces_source',
+ ),
+ )
+
+
+class WorkspaceMembership(Base):
+ __tablename__ = 'workspace_memberships'
+
+ uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
+ account_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
+ role = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
+ status = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=MembershipStatus.ACTIVE.value,
+ )
+ invited_by_account_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('users.uuid', ondelete='SET NULL'),
+ nullable=True,
+ )
+ joined_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ projection_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
+ created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
+ updated_at = sqlalchemy.Column(
+ sqlalchemy.DateTime,
+ nullable=False,
+ server_default=sqlalchemy.func.now(),
+ onupdate=sqlalchemy.func.now(),
+ )
+
+ __table_args__ = (
+ sqlalchemy.UniqueConstraint('workspace_uuid', 'account_uuid', name='uq_workspace_membership_account'),
+ sqlalchemy.Index('ix_workspace_memberships_account_status', 'account_uuid', 'status'),
+ sqlalchemy.CheckConstraint(
+ "role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
+ name='ck_workspace_memberships_role',
+ ),
+ sqlalchemy.CheckConstraint(
+ "status IN ('active', 'disabled', 'removed')",
+ name='ck_workspace_memberships_status',
+ ),
+ )
+
+
+class WorkspaceInvitation(Base):
+ __tablename__ = 'workspace_invitations'
+
+ uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True, default=_new_uuid)
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
+ normalized_email = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
+ role = sqlalchemy.Column(sqlalchemy.String(32), nullable=False)
+ token_hash = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
+ status = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=InvitationStatus.PENDING.value,
+ )
+ expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
+ accepted_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ revoked_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
+ created_by_account_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
+ nullable=False,
+ )
+ created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
+ updated_at = sqlalchemy.Column(
+ sqlalchemy.DateTime,
+ nullable=False,
+ server_default=sqlalchemy.func.now(),
+ onupdate=sqlalchemy.func.now(),
+ )
+
+ __table_args__ = (
+ sqlalchemy.Index('uq_workspace_invitations_token_hash', 'token_hash', unique=True),
+ sqlalchemy.Index(
+ 'uq_workspace_invitations_pending_email',
+ 'workspace_uuid',
+ 'normalized_email',
+ unique=True,
+ sqlite_where=sqlalchemy.text("status = 'pending'"),
+ postgresql_where=sqlalchemy.text("status = 'pending'"),
+ ),
+ sqlalchemy.CheckConstraint(
+ "role IN ('admin', 'developer', 'operator', 'viewer')",
+ name='ck_workspace_invitations_role',
+ ),
+ sqlalchemy.CheckConstraint(
+ "status IN ('pending', 'accepted', 'revoked', 'expired')",
+ name='ck_workspace_invitations_status',
+ ),
+ )
+
+
+class WorkspaceExecutionState(Base):
+ __tablename__ = 'workspace_execution_states'
+
+ workspace_uuid = sqlalchemy.Column(
+ sqlalchemy.String(36),
+ sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
+ primary_key=True,
+ )
+ instance_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
+ active_generation = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='1')
+ state = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=WorkspaceExecutionStatus.ACTIVE.value,
+ )
+ write_fenced = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, server_default=sqlalchemy.false())
+ source = sqlalchemy.Column(
+ sqlalchemy.String(32),
+ nullable=False,
+ server_default=WorkspaceExecutionSource.LOCAL.value,
+ )
+ desired_state_revision = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=False, server_default='0')
+ updated_at = sqlalchemy.Column(
+ sqlalchemy.DateTime,
+ nullable=False,
+ server_default=sqlalchemy.func.now(),
+ onupdate=sqlalchemy.func.now(),
+ )
+
+ __table_args__ = (
+ sqlalchemy.Index('ix_workspace_execution_states_instance_state', 'instance_uuid', 'state'),
+ sqlalchemy.CheckConstraint('active_generation > 0', name='ck_workspace_execution_generation'),
+ sqlalchemy.CheckConstraint(
+ "state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')",
+ name='ck_workspace_execution_state',
+ ),
+ sqlalchemy.CheckConstraint(
+ "source IN ('local', 'cloud')",
+ name='ck_workspace_execution_source',
+ ),
+ )
diff --git a/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py b/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py
new file mode 100644
index 000000000..d0a6c233e
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0009_workspace_tenancy_kernel.py
@@ -0,0 +1,542 @@
+"""add the workspace tenancy persistence kernel
+
+Revision ID: 0009_workspace_tenancy
+Revises: 0008_mcp_resource_prefs
+Create Date: 2026-07-18
+"""
+
+from __future__ import annotations
+
+import datetime
+import uuid
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = '0009_workspace_tenancy'
+down_revision = '0008_mcp_resource_prefs'
+branch_labels = None
+depends_on = None
+
+
+def _table_names(conn: sa.Connection) -> set[str]:
+ return set(sa.inspect(conn).get_table_names())
+
+
+def _column_map(conn: sa.Connection, table_name: str) -> dict[str, dict]:
+ return {column['name']: column for column in sa.inspect(conn).get_columns(table_name)}
+
+
+def _constraint_names(conn: sa.Connection, table_name: str) -> set[str]:
+ inspector = sa.inspect(conn)
+ names = {
+ constraint['name']
+ for constraint in inspector.get_check_constraints(table_name)
+ if constraint.get('name') is not None
+ }
+ names.update(
+ constraint['name']
+ for constraint in inspector.get_unique_constraints(table_name)
+ if constraint.get('name') is not None
+ )
+ return names
+
+
+def _index_names(conn: sa.Connection, table_name: str) -> set[str]:
+ return {index['name'] for index in sa.inspect(conn).get_indexes(table_name)}
+
+
+def _upgrade_users(conn: sa.Connection) -> None:
+ if 'users' not in _table_names(conn):
+ return
+
+ columns = _column_map(conn, 'users')
+ if 'uuid' not in columns:
+ op.add_column('users', sa.Column('uuid', sa.String(36), nullable=True))
+ if 'status' not in columns:
+ op.add_column('users', sa.Column('status', sa.String(32), nullable=True, server_default='active'))
+ if 'source' not in columns:
+ op.add_column('users', sa.Column('source', sa.String(32), nullable=True, server_default='local'))
+ if 'projection_revision' not in columns:
+ op.add_column(
+ 'users',
+ sa.Column('projection_revision', sa.BigInteger(), nullable=True, server_default='0'),
+ )
+
+ users = sa.table(
+ 'users',
+ sa.column('id', sa.Integer()),
+ sa.column('uuid', sa.String(36)),
+ sa.column('status', sa.String(32)),
+ sa.column('source', sa.String(32)),
+ sa.column('projection_revision', sa.BigInteger()),
+ )
+
+ seen_uuids: set[str] = set()
+ for user_id, account_uuid in conn.execute(sa.select(users.c.id, users.c.uuid).order_by(users.c.id)).all():
+ normalized_uuid = account_uuid.strip() if isinstance(account_uuid, str) else ''
+ try:
+ normalized_uuid = str(uuid.UUID(normalized_uuid))
+ except (ValueError, AttributeError):
+ normalized_uuid = ''
+ if not normalized_uuid or normalized_uuid in seen_uuids:
+ normalized_uuid = str(uuid.uuid4())
+ if normalized_uuid != account_uuid:
+ conn.execute(users.update().where(users.c.id == user_id).values(uuid=normalized_uuid))
+ seen_uuids.add(normalized_uuid)
+
+ conn.execute(users.update().where(users.c.status.is_(None)).values(status='active'))
+ conn.execute(users.update().where(users.c.source.is_(None)).values(source='local'))
+ conn.execute(users.update().where(users.c.projection_revision.is_(None)).values(projection_revision=0))
+
+ columns = _column_map(conn, 'users')
+ constraint_names = _constraint_names(conn, 'users')
+ needs_batch_alter = any(
+ columns[column_name]['nullable'] for column_name in ('uuid', 'status', 'source', 'projection_revision')
+ ) or not {'ck_users_status', 'ck_users_source'}.issubset(constraint_names)
+
+ if needs_batch_alter:
+ with op.batch_alter_table('users') as batch_op:
+ if columns['uuid']['nullable']:
+ batch_op.alter_column('uuid', existing_type=sa.String(36), nullable=False)
+ if columns['status']['nullable']:
+ batch_op.alter_column(
+ 'status',
+ existing_type=sa.String(32),
+ nullable=False,
+ server_default='active',
+ )
+ if columns['source']['nullable']:
+ batch_op.alter_column(
+ 'source',
+ existing_type=sa.String(32),
+ nullable=False,
+ server_default='local',
+ )
+ if columns['projection_revision']['nullable']:
+ batch_op.alter_column(
+ 'projection_revision',
+ existing_type=sa.BigInteger(),
+ nullable=False,
+ server_default='0',
+ )
+ if 'ck_users_status' not in constraint_names:
+ batch_op.create_check_constraint(
+ 'ck_users_status',
+ "status IN ('active', 'disabled', 'deleted')",
+ )
+ if 'ck_users_source' not in constraint_names:
+ batch_op.create_check_constraint(
+ 'ck_users_source',
+ "source IN ('local', 'cloud_projection')",
+ )
+
+ if 'uq_users_uuid' not in _index_names(conn, 'users'):
+ op.create_index('uq_users_uuid', 'users', ['uuid'], unique=True)
+
+
+def _create_workspace_tables(conn: sa.Connection) -> None:
+ tables = _table_names(conn)
+ if 'users' not in tables:
+ # LangBot's supported startup path creates the baseline schema before
+ # Alembic runs. Keep direct Alembic probes on an empty database safe.
+ return
+
+ if 'workspaces' not in tables:
+ op.create_table(
+ 'workspaces',
+ sa.Column('uuid', sa.String(36), primary_key=True),
+ sa.Column('instance_uuid', sa.String(255), nullable=False),
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('slug', sa.String(255), nullable=False),
+ sa.Column('type', sa.String(32), nullable=False, server_default='team'),
+ sa.Column('status', sa.String(32), nullable=False, server_default='active'),
+ sa.Column('created_by_account_uuid', sa.String(36), nullable=True),
+ sa.Column('source', sa.String(32), nullable=False, server_default='local'),
+ sa.Column('projection_revision', sa.BigInteger(), nullable=False, server_default='0'),
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.ForeignKeyConstraint(
+ ['created_by_account_uuid'],
+ ['users.uuid'],
+ name='fk_workspaces_created_by_account',
+ ondelete='SET NULL',
+ ),
+ sa.UniqueConstraint('instance_uuid', 'slug', name='uq_workspaces_instance_slug'),
+ sa.CheckConstraint("type IN ('personal', 'team')", name='ck_workspaces_type'),
+ sa.CheckConstraint(
+ "status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')",
+ name='ck_workspaces_status',
+ ),
+ sa.CheckConstraint(
+ "source IN ('local', 'cloud_projection')",
+ name='ck_workspaces_source',
+ ),
+ )
+ workspace_indexes = _index_names(conn, 'workspaces')
+ if 'ix_workspaces_instance_status' not in workspace_indexes:
+ op.create_index(
+ 'ix_workspaces_instance_status',
+ 'workspaces',
+ ['instance_uuid', 'status'],
+ )
+ if 'uq_workspaces_local_instance' not in workspace_indexes:
+ op.create_index(
+ 'uq_workspaces_local_instance',
+ 'workspaces',
+ ['instance_uuid'],
+ unique=True,
+ sqlite_where=sa.text("source = 'local'"),
+ postgresql_where=sa.text("source = 'local'"),
+ )
+
+ tables = _table_names(conn)
+ if 'workspace_memberships' not in tables:
+ op.create_table(
+ 'workspace_memberships',
+ sa.Column('uuid', sa.String(36), primary_key=True),
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ sa.Column('account_uuid', sa.String(36), nullable=False),
+ sa.Column('role', sa.String(32), nullable=False),
+ sa.Column('status', sa.String(32), nullable=False, server_default='active'),
+ sa.Column('invited_by_account_uuid', sa.String(36), nullable=True),
+ sa.Column('joined_at', sa.DateTime(), nullable=True),
+ sa.Column('projection_revision', sa.BigInteger(), nullable=False, server_default='0'),
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.ForeignKeyConstraint(
+ ['workspace_uuid'],
+ ['workspaces.uuid'],
+ name='fk_workspace_memberships_workspace',
+ ondelete='CASCADE',
+ ),
+ sa.ForeignKeyConstraint(
+ ['account_uuid'],
+ ['users.uuid'],
+ name='fk_workspace_memberships_account',
+ ondelete='CASCADE',
+ ),
+ sa.ForeignKeyConstraint(
+ ['invited_by_account_uuid'],
+ ['users.uuid'],
+ name='fk_workspace_memberships_invited_by_account',
+ ondelete='SET NULL',
+ ),
+ sa.UniqueConstraint(
+ 'workspace_uuid',
+ 'account_uuid',
+ name='uq_workspace_membership_account',
+ ),
+ sa.CheckConstraint(
+ "role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
+ name='ck_workspace_memberships_role',
+ ),
+ sa.CheckConstraint(
+ "status IN ('active', 'disabled', 'removed')",
+ name='ck_workspace_memberships_status',
+ ),
+ )
+ membership_indexes = _index_names(conn, 'workspace_memberships')
+ if 'ix_workspace_memberships_account_status' not in membership_indexes:
+ op.create_index(
+ 'ix_workspace_memberships_account_status',
+ 'workspace_memberships',
+ ['account_uuid', 'status'],
+ )
+
+ tables = _table_names(conn)
+ if 'workspace_invitations' not in tables:
+ op.create_table(
+ 'workspace_invitations',
+ sa.Column('uuid', sa.String(36), primary_key=True),
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ sa.Column('normalized_email', sa.String(320), nullable=False),
+ sa.Column('role', sa.String(32), nullable=False),
+ sa.Column('token_hash', sa.String(255), nullable=False),
+ sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
+ sa.Column('expires_at', sa.DateTime(), nullable=False),
+ sa.Column('accepted_at', sa.DateTime(), nullable=True),
+ sa.Column('revoked_at', sa.DateTime(), nullable=True),
+ sa.Column('created_by_account_uuid', sa.String(36), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.ForeignKeyConstraint(
+ ['workspace_uuid'],
+ ['workspaces.uuid'],
+ name='fk_workspace_invitations_workspace',
+ ondelete='CASCADE',
+ ),
+ sa.ForeignKeyConstraint(
+ ['created_by_account_uuid'],
+ ['users.uuid'],
+ name='fk_workspace_invitations_created_by_account',
+ ondelete='CASCADE',
+ ),
+ sa.CheckConstraint(
+ "role IN ('admin', 'developer', 'operator', 'viewer')",
+ name='ck_workspace_invitations_role',
+ ),
+ sa.CheckConstraint(
+ "status IN ('pending', 'accepted', 'revoked', 'expired')",
+ name='ck_workspace_invitations_status',
+ ),
+ )
+ invitation_indexes = _index_names(conn, 'workspace_invitations')
+ if 'uq_workspace_invitations_token_hash' not in invitation_indexes:
+ op.create_index(
+ 'uq_workspace_invitations_token_hash',
+ 'workspace_invitations',
+ ['token_hash'],
+ unique=True,
+ )
+ if 'uq_workspace_invitations_pending_email' not in invitation_indexes:
+ op.create_index(
+ 'uq_workspace_invitations_pending_email',
+ 'workspace_invitations',
+ ['workspace_uuid', 'normalized_email'],
+ unique=True,
+ sqlite_where=sa.text("status = 'pending'"),
+ postgresql_where=sa.text("status = 'pending'"),
+ )
+
+ tables = _table_names(conn)
+ if 'workspace_execution_states' not in tables:
+ op.create_table(
+ 'workspace_execution_states',
+ sa.Column('workspace_uuid', sa.String(36), primary_key=True),
+ sa.Column('instance_uuid', sa.String(255), nullable=False),
+ sa.Column('active_generation', sa.BigInteger(), nullable=False, server_default='1'),
+ sa.Column('state', sa.String(32), nullable=False, server_default='active'),
+ sa.Column('write_fenced', sa.Boolean(), nullable=False, server_default=sa.false()),
+ sa.Column('source', sa.String(32), nullable=False, server_default='local'),
+ sa.Column('desired_state_revision', sa.BigInteger(), nullable=False, server_default='0'),
+ sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
+ sa.ForeignKeyConstraint(
+ ['workspace_uuid'],
+ ['workspaces.uuid'],
+ name='fk_workspace_execution_states_workspace',
+ ondelete='CASCADE',
+ ),
+ sa.CheckConstraint('active_generation > 0', name='ck_workspace_execution_generation'),
+ sa.CheckConstraint(
+ "state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')",
+ name='ck_workspace_execution_state',
+ ),
+ sa.CheckConstraint(
+ "source IN ('local', 'cloud')",
+ name='ck_workspace_execution_source',
+ ),
+ )
+ execution_indexes = _index_names(conn, 'workspace_execution_states')
+ if 'ix_workspace_execution_states_instance_state' not in execution_indexes:
+ op.create_index(
+ 'ix_workspace_execution_states_instance_state',
+ 'workspace_execution_states',
+ ['instance_uuid', 'state'],
+ )
+
+
+def _load_instance_uuid(conn: sa.Connection) -> str | None:
+ if 'metadata' not in _table_names(conn):
+ return None
+
+ metadata = sa.table(
+ 'metadata',
+ sa.column('key', sa.String(255)),
+ sa.column('value', sa.String(255)),
+ )
+ value = conn.execute(sa.select(metadata.c.value).where(metadata.c.key == 'instance_uuid')).scalar_one_or_none()
+ if not isinstance(value, str) or not value.strip():
+ return None
+ return value.strip()
+
+
+def _bootstrap_default_workspace(conn: sa.Connection) -> None:
+ required_tables = {'users', 'workspaces', 'workspace_memberships', 'workspace_execution_states'}
+ if not required_tables.issubset(_table_names(conn)):
+ return
+
+ instance_uuid = _load_instance_uuid(conn)
+ users_exist = 'users' in _table_names(conn) and bool(conn.execute(sa.text('SELECT 1 FROM users LIMIT 1')).first())
+ if instance_uuid is None:
+ if users_exist:
+ raise RuntimeError("Cannot bootstrap the default workspace without metadata['instance_uuid']")
+ return
+
+ workspaces = sa.table(
+ 'workspaces',
+ sa.column('uuid', sa.String(36)),
+ sa.column('instance_uuid', sa.String(255)),
+ sa.column('name', sa.String(255)),
+ sa.column('slug', sa.String(255)),
+ sa.column('type', sa.String(32)),
+ sa.column('status', sa.String(32)),
+ sa.column('created_by_account_uuid', sa.String(36)),
+ sa.column('source', sa.String(32)),
+ sa.column('projection_revision', sa.BigInteger()),
+ )
+ local_rows = conn.execute(
+ sa.select(workspaces.c.uuid).where(
+ workspaces.c.instance_uuid == instance_uuid,
+ workspaces.c.source == 'local',
+ )
+ ).all()
+ if len(local_rows) > 1:
+ raise RuntimeError(f'Multiple local workspaces already exist for instance {instance_uuid!r}')
+
+ users = sa.table(
+ 'users',
+ sa.column('id', sa.Integer()),
+ sa.column('uuid', sa.String(36)),
+ )
+ owner_account_uuid = None
+ if 'users' in _table_names(conn):
+ owner_account_uuid = conn.execute(sa.select(users.c.uuid).order_by(users.c.id).limit(1)).scalar_one_or_none()
+
+ if local_rows:
+ workspace_uuid = local_rows[0][0]
+ if owner_account_uuid is not None:
+ conn.execute(
+ workspaces.update()
+ .where(workspaces.c.uuid == workspace_uuid)
+ .where(workspaces.c.created_by_account_uuid.is_(None))
+ .values(created_by_account_uuid=owner_account_uuid)
+ )
+ else:
+ workspace_uuid = str(uuid.uuid4())
+ conn.execute(
+ workspaces.insert().values(
+ uuid=workspace_uuid,
+ instance_uuid=instance_uuid,
+ name='Default Workspace',
+ slug='default',
+ type='team',
+ status='active',
+ created_by_account_uuid=owner_account_uuid,
+ source='local',
+ projection_revision=0,
+ )
+ )
+
+ execution_states = sa.table(
+ 'workspace_execution_states',
+ sa.column('workspace_uuid', sa.String(36)),
+ sa.column('instance_uuid', sa.String(255)),
+ sa.column('active_generation', sa.BigInteger()),
+ sa.column('state', sa.String(32)),
+ sa.column('write_fenced', sa.Boolean()),
+ sa.column('source', sa.String(32)),
+ sa.column('desired_state_revision', sa.BigInteger()),
+ )
+ execution_state = conn.execute(
+ sa.select(
+ execution_states.c.instance_uuid,
+ execution_states.c.active_generation,
+ execution_states.c.state,
+ execution_states.c.write_fenced,
+ execution_states.c.source,
+ ).where(execution_states.c.workspace_uuid == workspace_uuid)
+ ).first()
+ if execution_state is None:
+ conn.execute(
+ execution_states.insert().values(
+ workspace_uuid=workspace_uuid,
+ instance_uuid=instance_uuid,
+ active_generation=1,
+ state='active',
+ write_fenced=False,
+ source='local',
+ desired_state_revision=0,
+ )
+ )
+ elif (
+ execution_state.instance_uuid != instance_uuid
+ or execution_state.active_generation != 1
+ or execution_state.state != 'active'
+ or execution_state.write_fenced
+ or execution_state.source != 'local'
+ ):
+ raise RuntimeError(f'Default workspace {workspace_uuid!r} has an invalid local execution state')
+
+ if owner_account_uuid is None:
+ return
+
+ memberships = sa.table(
+ 'workspace_memberships',
+ sa.column('uuid', sa.String(36)),
+ sa.column('workspace_uuid', sa.String(36)),
+ sa.column('account_uuid', sa.String(36)),
+ sa.column('role', sa.String(32)),
+ sa.column('status', sa.String(32)),
+ sa.column('joined_at', sa.DateTime()),
+ sa.column('projection_revision', sa.BigInteger()),
+ )
+ membership = conn.execute(
+ sa.select(memberships.c.uuid, memberships.c.joined_at).where(
+ memberships.c.workspace_uuid == workspace_uuid,
+ memberships.c.account_uuid == owner_account_uuid,
+ )
+ ).first()
+ now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+ if membership is None:
+ conn.execute(
+ memberships.insert().values(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_uuid,
+ account_uuid=owner_account_uuid,
+ role='owner',
+ status='active',
+ joined_at=now,
+ projection_revision=0,
+ )
+ )
+ else:
+ conn.execute(
+ memberships.update()
+ .where(memberships.c.uuid == membership.uuid)
+ .values(
+ role='owner',
+ status='active',
+ joined_at=membership.joined_at or now,
+ )
+ )
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ _upgrade_users(conn)
+ _create_workspace_tables(conn)
+ _bootstrap_default_workspace(conn)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ tables = _table_names(conn)
+ for table_name in (
+ 'workspace_execution_states',
+ 'workspace_invitations',
+ 'workspace_memberships',
+ 'workspaces',
+ ):
+ if table_name in tables:
+ op.drop_table(table_name)
+
+ if 'users' not in _table_names(conn):
+ return
+
+ indexes = _index_names(conn, 'users')
+ if 'uq_users_uuid' in indexes:
+ op.drop_index('uq_users_uuid', table_name='users')
+
+ columns = _column_map(conn, 'users')
+ constraint_names = _constraint_names(conn, 'users')
+ with op.batch_alter_table('users') as batch_op:
+ # SQLite batch recreation otherwise preserves the named checks while
+ # dropping their referenced columns, producing ``no such column`` only
+ # after the Workspace directory tables have already been removed.
+ for constraint_name in ('ck_users_source', 'ck_users_status'):
+ if constraint_name in constraint_names:
+ batch_op.drop_constraint(constraint_name, type_='check')
+ for column_name in ('projection_revision', 'source', 'status', 'uuid'):
+ if column_name in columns:
+ batch_op.drop_column(column_name)
diff --git a/src/langbot/pkg/persistence/alembic/versions/0010_scope_tenant_resources.py b/src/langbot/pkg/persistence/alembic/versions/0010_scope_tenant_resources.py
new file mode 100644
index 000000000..9bcc1aac9
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0010_scope_tenant_resources.py
@@ -0,0 +1,884 @@
+"""scope every tenant-owned resource to a workspace
+
+Revision ID: 0010_scope_resources
+Revises: 0009_workspace_tenancy
+Create Date: 2026-07-19
+
+This migration is intentionally expand/backfill/contract. Existing rows are
+bound to the single local Workspace created by revision 0009 before any
+non-null or scoped-key constraint is installed.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import uuid
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = '0010_scope_resources'
+down_revision = '0009_workspace_tenancy'
+branch_labels = None
+depends_on = None
+
+
+_TENANT_TABLES = (
+ 'api_keys',
+ 'bots',
+ 'bot_admins',
+ 'binary_storages',
+ 'mcp_servers',
+ 'model_providers',
+ 'llm_models',
+ 'embedding_models',
+ 'rerank_models',
+ 'legacy_pipelines',
+ 'pipeline_run_records',
+ 'plugin_settings',
+ 'knowledge_bases',
+ 'knowledge_base_files',
+ 'knowledge_base_chunks',
+ 'webhooks',
+ 'monitoring_messages',
+ 'monitoring_llm_calls',
+ 'monitoring_tool_calls',
+ 'monitoring_sessions',
+ 'monitoring_errors',
+ 'monitoring_embedding_calls',
+ 'monitoring_feedback',
+)
+
+_COMPOSITE_PRIMARY_KEYS = {
+ 'binary_storages': ('workspace_uuid', 'unique_key'),
+ 'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
+ 'monitoring_sessions': ('workspace_uuid', 'session_id'),
+}
+
+_COMPOSITE_FOREIGN_KEYS = {
+ 'bot_admins': (
+ (
+ 'fk_bot_admins_workspace_bot',
+ ('workspace_uuid', 'bot_uuid'),
+ 'bots',
+ ('workspace_uuid', 'uuid'),
+ 'CASCADE',
+ ),
+ ),
+ 'llm_models': (
+ (
+ 'fk_llm_models_workspace_provider',
+ ('workspace_uuid', 'provider_uuid'),
+ 'model_providers',
+ ('workspace_uuid', 'uuid'),
+ None,
+ ),
+ ),
+ 'embedding_models': (
+ (
+ 'fk_embedding_models_workspace_provider',
+ ('workspace_uuid', 'provider_uuid'),
+ 'model_providers',
+ ('workspace_uuid', 'uuid'),
+ None,
+ ),
+ ),
+ 'rerank_models': (
+ (
+ 'fk_rerank_models_workspace_provider',
+ ('workspace_uuid', 'provider_uuid'),
+ 'model_providers',
+ ('workspace_uuid', 'uuid'),
+ None,
+ ),
+ ),
+ 'pipeline_run_records': (
+ (
+ 'fk_pipeline_run_records_workspace_pipeline',
+ ('workspace_uuid', 'pipeline_uuid'),
+ 'legacy_pipelines',
+ ('workspace_uuid', 'uuid'),
+ 'CASCADE',
+ ),
+ ),
+ 'knowledge_base_files': (
+ (
+ 'fk_knowledge_base_files_workspace_kb',
+ ('workspace_uuid', 'kb_id'),
+ 'knowledge_bases',
+ ('workspace_uuid', 'uuid'),
+ 'CASCADE',
+ ),
+ ),
+ 'knowledge_base_chunks': (
+ (
+ 'fk_knowledge_base_chunks_workspace_file',
+ ('workspace_uuid', 'file_id'),
+ 'knowledge_base_files',
+ ('workspace_uuid', 'uuid'),
+ 'CASCADE',
+ ),
+ ),
+}
+
+_SCOPED_INDEXES: dict[str, tuple[tuple[str, tuple[str, ...], bool, sa.TextClause | None], ...]] = {
+ 'api_keys': (
+ ('uq_api_keys_uuid', ('uuid',), True, None),
+ ('uq_api_keys_key_hash', ('key_hash',), True, None),
+ ('ix_api_keys_workspace_name', ('workspace_uuid', 'name'), False, None),
+ ('ix_api_keys_workspace_status', ('workspace_uuid', 'status'), False, None),
+ ),
+ 'bots': (
+ ('uq_bots_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
+ ('ix_bots_workspace_name', ('workspace_uuid', 'name'), False, None),
+ ('ix_bots_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
+ ),
+ 'bot_admins': (
+ (
+ 'uq_bot_admin',
+ ('workspace_uuid', 'bot_uuid', 'launcher_type', 'launcher_id'),
+ True,
+ None,
+ ),
+ ('ix_bot_admins_workspace_bot', ('workspace_uuid', 'bot_uuid'), False, None),
+ ),
+ 'binary_storages': (
+ (
+ 'ix_binary_storages_workspace_owner',
+ ('workspace_uuid', 'owner_type', 'owner'),
+ False,
+ None,
+ ),
+ ),
+ 'mcp_servers': (
+ ('uq_mcp_servers_workspace_name', ('workspace_uuid', 'name'), True, None),
+ ('ix_mcp_servers_workspace_enable', ('workspace_uuid', 'enable'), False, None),
+ ('ix_mcp_servers_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
+ ),
+ 'model_providers': (
+ ('uq_model_providers_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
+ ('ix_model_providers_workspace_name', ('workspace_uuid', 'name'), False, None),
+ ('ix_model_providers_workspace_requester', ('workspace_uuid', 'requester'), False, None),
+ ),
+ 'llm_models': (
+ ('ix_llm_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
+ ('ix_llm_models_workspace_name', ('workspace_uuid', 'name'), False, None),
+ ),
+ 'embedding_models': (
+ ('ix_embedding_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
+ ('ix_embedding_models_workspace_name', ('workspace_uuid', 'name'), False, None),
+ ),
+ 'rerank_models': (
+ ('ix_rerank_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
+ ('ix_rerank_models_workspace_name', ('workspace_uuid', 'name'), False, None),
+ ),
+ 'legacy_pipelines': (
+ ('uq_legacy_pipelines_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
+ ('ix_legacy_pipelines_workspace_name', ('workspace_uuid', 'name'), False, None),
+ ('ix_legacy_pipelines_workspace_default', ('workspace_uuid', 'is_default'), False, None),
+ ('ix_legacy_pipelines_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
+ ),
+ 'pipeline_run_records': (
+ (
+ 'ix_pipeline_run_records_workspace_pipeline',
+ ('workspace_uuid', 'pipeline_uuid'),
+ False,
+ None,
+ ),
+ (
+ 'ix_pipeline_run_records_workspace_created',
+ ('workspace_uuid', 'created_at'),
+ False,
+ None,
+ ),
+ ),
+ 'plugin_settings': (('ix_plugin_settings_workspace_enabled', ('workspace_uuid', 'enabled'), False, None),),
+ 'knowledge_bases': (
+ ('uq_knowledge_bases_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
+ ('ix_knowledge_bases_workspace_name', ('workspace_uuid', 'name'), False, None),
+ (
+ 'uq_knowledge_bases_workspace_collection',
+ ('workspace_uuid', 'collection_id'),
+ True,
+ sa.text('collection_id IS NOT NULL'),
+ ),
+ ),
+ 'knowledge_base_files': (
+ ('uq_knowledge_base_files_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
+ ('ix_knowledge_base_files_workspace_kb', ('workspace_uuid', 'kb_id'), False, None),
+ ),
+ 'knowledge_base_chunks': (('ix_knowledge_base_chunks_workspace_file', ('workspace_uuid', 'file_id'), False, None),),
+ 'webhooks': (
+ ('ix_webhooks_workspace_name', ('workspace_uuid', 'name'), False, None),
+ ('ix_webhooks_workspace_enabled', ('workspace_uuid', 'enabled'), False, None),
+ ('ix_webhooks_workspace_created', ('workspace_uuid', 'created_at'), False, None),
+ ),
+ 'monitoring_messages': (
+ ('ix_monitoring_messages_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
+ ('ix_monitoring_messages_workspace_bot', ('workspace_uuid', 'bot_id', 'timestamp'), False, None),
+ (
+ 'ix_monitoring_messages_workspace_pipeline',
+ ('workspace_uuid', 'pipeline_id', 'timestamp'),
+ False,
+ None,
+ ),
+ ('ix_monitoring_messages_workspace_session', ('workspace_uuid', 'session_id'), False, None),
+ ),
+ 'monitoring_llm_calls': (
+ ('ix_monitoring_llm_calls_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
+ ('ix_monitoring_llm_calls_workspace_session', ('workspace_uuid', 'session_id'), False, None),
+ ('ix_monitoring_llm_calls_workspace_message', ('workspace_uuid', 'message_id'), False, None),
+ ),
+ 'monitoring_tool_calls': (
+ ('ix_monitoring_tool_calls_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
+ ('ix_monitoring_tool_calls_workspace_session', ('workspace_uuid', 'session_id'), False, None),
+ ('ix_monitoring_tool_calls_workspace_message', ('workspace_uuid', 'message_id'), False, None),
+ ),
+ 'monitoring_sessions': (
+ ('ix_monitoring_sessions_workspace_activity', ('workspace_uuid', 'last_activity'), False, None),
+ ('ix_monitoring_sessions_workspace_active', ('workspace_uuid', 'is_active'), False, None),
+ ('ix_monitoring_sessions_workspace_bot', ('workspace_uuid', 'bot_id', 'last_activity'), False, None),
+ ),
+ 'monitoring_errors': (
+ ('ix_monitoring_errors_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
+ ('ix_monitoring_errors_workspace_session', ('workspace_uuid', 'session_id'), False, None),
+ ('ix_monitoring_errors_workspace_message', ('workspace_uuid', 'message_id'), False, None),
+ ),
+ 'monitoring_embedding_calls': (
+ (
+ 'ix_monitoring_embedding_calls_workspace_timestamp',
+ ('workspace_uuid', 'timestamp'),
+ False,
+ None,
+ ),
+ (
+ 'ix_monitoring_embedding_calls_workspace_kb',
+ ('workspace_uuid', 'knowledge_base_id'),
+ False,
+ None,
+ ),
+ (
+ 'ix_monitoring_embedding_calls_workspace_session',
+ ('workspace_uuid', 'session_id'),
+ False,
+ None,
+ ),
+ ),
+ 'monitoring_feedback': (
+ (
+ 'uq_monitoring_feedback_workspace_feedback_id',
+ ('workspace_uuid', 'feedback_id'),
+ True,
+ None,
+ ),
+ ('ix_monitoring_feedback_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
+ ('ix_monitoring_feedback_workspace_session', ('workspace_uuid', 'session_id'), False, None),
+ ('ix_monitoring_feedback_workspace_message', ('workspace_uuid', 'message_id'), False, None),
+ ),
+}
+
+
+def _inspector(conn: sa.Connection) -> sa.Inspector:
+ return sa.inspect(conn)
+
+
+def _table_names(conn: sa.Connection) -> set[str]:
+ return set(_inspector(conn).get_table_names())
+
+
+def _columns(conn: sa.Connection, table_name: str) -> dict[str, dict]:
+ return {column['name']: column for column in _inspector(conn).get_columns(table_name)}
+
+
+def _index_names(conn: sa.Connection, table_name: str) -> set[str]:
+ return {index['name'] for index in _inspector(conn).get_indexes(table_name)}
+
+
+def _unique_column_sets(conn: sa.Connection, table_name: str) -> set[tuple[str, ...]]:
+ inspector = _inspector(conn)
+ result = {
+ tuple(constraint.get('column_names') or ()) for constraint in inspector.get_unique_constraints(table_name)
+ }
+ result.update(
+ tuple(index.get('column_names') or ()) for index in inspector.get_indexes(table_name) if index.get('unique')
+ )
+ return result
+
+
+def _foreign_key_exists(
+ conn: sa.Connection,
+ table_name: str,
+ local_columns: tuple[str, ...],
+ referred_table: str,
+ referred_columns: tuple[str, ...],
+) -> bool:
+ return any(
+ tuple(foreign_key.get('constrained_columns') or ()) == local_columns
+ and foreign_key.get('referred_table') == referred_table
+ and tuple(foreign_key.get('referred_columns') or ()) == referred_columns
+ for foreign_key in _inspector(conn).get_foreign_keys(table_name)
+ )
+
+
+def _metadata_value(conn: sa.Connection, key: str) -> str | None:
+ if 'metadata' not in _table_names(conn):
+ return None
+ metadata = sa.table(
+ 'metadata',
+ sa.column('key', sa.String(255)),
+ sa.column('value', sa.String(255)),
+ )
+ value = conn.execute(sa.select(metadata.c.value).where(metadata.c.key == key)).scalar_one_or_none()
+ return value.strip() if isinstance(value, str) and value.strip() else None
+
+
+def _default_workspace_uuid(conn: sa.Connection) -> str | None:
+ if 'workspaces' not in _table_names(conn):
+ return None
+ workspaces = sa.table(
+ 'workspaces',
+ sa.column('uuid', sa.String(36)),
+ sa.column('instance_uuid', sa.String(255)),
+ sa.column('source', sa.String(32)),
+ )
+ instance_uuid = _metadata_value(conn, 'instance_uuid')
+ query = sa.select(workspaces.c.uuid).where(workspaces.c.source == 'local')
+ if instance_uuid is not None:
+ query = query.where(workspaces.c.instance_uuid == instance_uuid)
+ rows = conn.execute(query).all()
+ if len(rows) > 1:
+ raise RuntimeError('Cannot backfill tenant resources: multiple local Workspaces exist')
+ return rows[0][0] if rows else None
+
+
+def _upgrade_normalized_email(conn: sa.Connection) -> None:
+ if 'users' not in _table_names(conn):
+ return
+ columns = _columns(conn, 'users')
+ if 'normalized_email' not in columns:
+ op.add_column('users', sa.Column('normalized_email', sa.String(320), nullable=True))
+
+ users = sa.table(
+ 'users',
+ sa.column('id', sa.Integer()),
+ sa.column('user', sa.String(255)),
+ sa.column('normalized_email', sa.String(320)),
+ )
+ # Use the exact same normalization algorithm as the runtime. Database
+ # ``lower()`` is ASCII-only on SQLite and is not equivalent to Python
+ # ``casefold()`` (for example, Straße -> strasse). Recompute every row so
+ # an interrupted expand/backfill attempt using an older migration body can
+ # be resumed safely.
+ seen_emails: dict[str, int] = {}
+ for user_id, email in conn.execute(sa.select(users.c.id, users.c.user).order_by(users.c.id)).all():
+ normalized_email = str(email or '').strip().casefold()
+ if not normalized_email:
+ raise RuntimeError(f'Cannot normalize empty account identity for user row {user_id}')
+ if len(normalized_email) > 320:
+ raise RuntimeError(
+ f'Cannot normalize account identity for user row {user_id}: canonical value exceeds 320 characters'
+ )
+ duplicate_user_id = seen_emails.get(normalized_email)
+ if duplicate_user_id is not None:
+ raise RuntimeError(
+ f'Cannot create normalized account identity: user rows '
+ f'{duplicate_user_id} and {user_id} both normalize to {normalized_email!r}'
+ )
+ seen_emails[normalized_email] = user_id
+ conn.execute(users.update().where(users.c.id == user_id).values(normalized_email=normalized_email))
+
+ columns = _columns(conn, 'users')
+ checks = {
+ constraint.get('name'): constraint
+ for constraint in _inspector(conn).get_check_constraints('users')
+ if constraint.get('name') is not None
+ }
+ identity_check = checks.get('ck_users_normalized_email')
+ identity_check_sql = str((identity_check or {}).get('sqltext') or '').casefold()
+ # Python casefold is the canonical identity algorithm. SQL ``lower`` is
+ # dialect/locale dependent (notably Cherokee folds to uppercase in Python
+ # but PostgreSQL lowercases it), so the database validates only portable
+ # structural invariants and uniqueness.
+ replace_legacy_identity_check = identity_check is not None and 'lower' in identity_check_sql
+ needs_contract = columns['normalized_email']['nullable'] or identity_check is None or replace_legacy_identity_check
+ if needs_contract:
+ with op.batch_alter_table('users') as batch_op:
+ if replace_legacy_identity_check:
+ batch_op.drop_constraint('ck_users_normalized_email', type_='check')
+ if columns['normalized_email']['nullable']:
+ batch_op.alter_column(
+ 'normalized_email',
+ existing_type=columns['normalized_email']['type'],
+ nullable=False,
+ )
+ if identity_check is None or replace_legacy_identity_check:
+ batch_op.create_check_constraint(
+ 'ck_users_normalized_email',
+ 'normalized_email = trim(normalized_email) '
+ 'AND length(normalized_email) > 0 '
+ 'AND length(normalized_email) <= 320',
+ )
+ if 'uq_users_normalized_email' not in _index_names(conn, 'users'):
+ op.create_index('uq_users_normalized_email', 'users', ['normalized_email'], unique=True)
+
+
+def _api_key_owner(conn: sa.Connection, workspace_uuid: str | None) -> str | None:
+ if workspace_uuid is None or 'workspace_memberships' not in _table_names(conn):
+ return None
+ memberships = sa.table(
+ 'workspace_memberships',
+ sa.column('workspace_uuid', sa.String(36)),
+ sa.column('account_uuid', sa.String(36)),
+ sa.column('role', sa.String(32)),
+ )
+ return conn.execute(
+ sa.select(memberships.c.account_uuid)
+ .where(
+ memberships.c.workspace_uuid == workspace_uuid,
+ memberships.c.role == 'owner',
+ )
+ .limit(1)
+ ).scalar_one_or_none()
+
+
+def _expand_and_hash_api_keys(conn: sa.Connection, workspace_uuid: str | None) -> None:
+ if 'api_keys' not in _table_names(conn):
+ return
+ columns = _columns(conn, 'api_keys')
+ additions = (
+ ('uuid', sa.Column('uuid', sa.String(36), nullable=True)),
+ ('created_by_account_uuid', sa.Column('created_by_account_uuid', sa.String(36), nullable=True)),
+ ('key_hash', sa.Column('key_hash', sa.String(64), nullable=True)),
+ ('scopes', sa.Column('scopes', sa.JSON(), nullable=True)),
+ ('status', sa.Column('status', sa.String(32), nullable=True, server_default='active')),
+ ('expires_at', sa.Column('expires_at', sa.DateTime(), nullable=True)),
+ ('last_used_at', sa.Column('last_used_at', sa.DateTime(), nullable=True)),
+ )
+ for name, column in additions:
+ if name not in columns:
+ op.add_column('api_keys', column)
+
+ columns = _columns(conn, 'api_keys')
+ api_key_columns = [sa.column('id', sa.Integer())]
+ for name in ('uuid', 'created_by_account_uuid', 'key_hash', 'scopes', 'status', 'key'):
+ if name in columns:
+ api_key_columns.append(sa.column(name, columns[name]['type']))
+ api_keys = sa.table('api_keys', *api_key_columns)
+ owner_uuid = _api_key_owner(conn, workspace_uuid)
+ selected_columns = [api_keys.c.id, api_keys.c.uuid, api_keys.c.key_hash]
+ if 'key' in api_keys.c:
+ selected_columns.append(api_keys.c.key)
+ rows = conn.execute(sa.select(*selected_columns).order_by(api_keys.c.id)).mappings().all()
+ for row in rows:
+ values: dict[str, object] = {}
+ if not row['uuid']:
+ values['uuid'] = str(uuid.uuid4())
+ if not row['key_hash']:
+ plaintext = row.get('key')
+ if not isinstance(plaintext, str) or not plaintext:
+ raise RuntimeError(f'API key row {row["id"]} has no secret to hash')
+ values['key_hash'] = hashlib.sha256(plaintext.encode()).hexdigest()
+ if values:
+ conn.execute(api_keys.update().where(api_keys.c.id == row['id']).values(**values))
+ # Pre-tenancy API keys historically had unrestricted instance access. A
+ # wildcard preserves that behavior while binding it to the backfilled
+ # Workspace; new keys must store their requested explicit scopes.
+ conn.execute(api_keys.update().where(api_keys.c.scopes.is_(None)).values(scopes=['*']))
+ conn.execute(api_keys.update().where(api_keys.c.status.is_(None)).values(status='active'))
+ if owner_uuid is not None:
+ conn.execute(
+ api_keys.update()
+ .where(api_keys.c.created_by_account_uuid.is_(None))
+ .values(created_by_account_uuid=owner_uuid)
+ )
+
+ columns = _columns(conn, 'api_keys')
+ check_names = {constraint.get('name') for constraint in _inspector(conn).get_check_constraints('api_keys')}
+ existing_fks = _inspector(conn).get_foreign_keys('api_keys')
+ creator_fk_exists = any(
+ tuple(foreign_key.get('constrained_columns') or ()) == ('created_by_account_uuid',)
+ and foreign_key.get('referred_table') == 'users'
+ and tuple(foreign_key.get('referred_columns') or ()) == ('uuid',)
+ for foreign_key in existing_fks
+ )
+ has_legacy_key = 'key' in columns
+ needs_contract = (
+ any(columns[name]['nullable'] for name in ('uuid', 'key_hash', 'scopes', 'status'))
+ or 'ck_api_keys_status' not in check_names
+ or not creator_fk_exists
+ or has_legacy_key
+ )
+ if needs_contract:
+ naming = {'uq': 'uq_%(table_name)s_%(column_0_name)s'}
+ with op.batch_alter_table('api_keys', naming_convention=naming) as batch_op:
+ for name in ('uuid', 'key_hash', 'scopes', 'status'):
+ if columns[name]['nullable']:
+ batch_op.alter_column(name, existing_type=columns[name]['type'], nullable=False)
+ if 'ck_api_keys_status' not in check_names:
+ batch_op.create_check_constraint('ck_api_keys_status', "status IN ('active', 'revoked')")
+ if not creator_fk_exists:
+ batch_op.create_foreign_key(
+ 'fk_api_keys_created_by_account',
+ 'users',
+ ['created_by_account_uuid'],
+ ['uuid'],
+ ondelete='SET NULL',
+ )
+ if has_legacy_key:
+ # Dropping the column also removes its old global plaintext
+ # unique constraint/index during SQLite's batch rebuild.
+ batch_op.drop_column('key')
+
+
+def _expand_workspace_columns(conn: sa.Connection, workspace_uuid: str | None) -> None:
+ tables = _table_names(conn)
+ for table_name in _TENANT_TABLES:
+ if table_name not in tables:
+ continue
+ columns = _columns(conn, table_name)
+ if 'workspace_uuid' not in columns:
+ op.add_column(table_name, sa.Column('workspace_uuid', sa.String(36), nullable=True))
+ tenant_table = sa.table(table_name, sa.column('workspace_uuid', sa.String(36)))
+ null_count = conn.scalar(
+ sa.select(sa.func.count()).select_from(tenant_table).where(tenant_table.c.workspace_uuid.is_(None))
+ )
+ if null_count:
+ if workspace_uuid is None:
+ raise RuntimeError(f'Cannot backfill {table_name}: the instance has no unique local Workspace')
+ conn.execute(
+ tenant_table.update()
+ .where(tenant_table.c.workspace_uuid.is_(None))
+ .values(workspace_uuid=workspace_uuid)
+ )
+
+
+def _mark_legacy_vector_collections(conn: sa.Connection, workspace_uuid: str | None) -> None:
+ """Persist which pre-tenancy KBs must keep using ``collection_id``.
+
+ The marker is backfilled only when this migration introduces the column,
+ or resumes while that newly added column is still nullable. A fresh
+ schema already contains the non-null column with ``false`` as its default,
+ so knowledge bases created under the scoped-vector contract can never be
+ mistaken for legacy data during a later migration retry.
+ """
+
+ if 'knowledge_bases' not in _table_names(conn):
+ return
+ columns = _columns(conn, 'knowledge_bases')
+ introduced = 'legacy_vector_collection' not in columns
+ if introduced:
+ op.add_column(
+ 'knowledge_bases',
+ sa.Column('legacy_vector_collection', sa.Boolean(), nullable=True),
+ )
+ columns = _columns(conn, 'knowledge_bases')
+ needs_legacy_backfill = introduced or columns['legacy_vector_collection']['nullable']
+
+ knowledge_bases = sa.table(
+ 'knowledge_bases',
+ sa.column('collection_id', columns['collection_id']['type']),
+ sa.column('legacy_vector_collection', sa.Boolean()),
+ *((sa.column('workspace_uuid', columns['workspace_uuid']['type']),) if 'workspace_uuid' in columns else ()),
+ )
+ if needs_legacy_backfill and workspace_uuid is not None:
+ legacy_filter = sa.and_(
+ knowledge_bases.c.collection_id.is_not(None),
+ sa.func.length(sa.func.trim(knowledge_bases.c.collection_id)) > 0,
+ )
+ if 'workspace_uuid' in knowledge_bases.c:
+ # A partially migrated database may already have Workspace
+ # columns. Never mark a projected cloud row as legacy.
+ legacy_filter = sa.and_(
+ legacy_filter,
+ sa.or_(
+ knowledge_bases.c.workspace_uuid.is_(None),
+ knowledge_bases.c.workspace_uuid == workspace_uuid,
+ ),
+ )
+ conn.execute(knowledge_bases.update().where(legacy_filter).values(legacy_vector_collection=True))
+
+ conn.execute(
+ knowledge_bases.update()
+ .where(knowledge_bases.c.legacy_vector_collection.is_(None))
+ .values(legacy_vector_collection=False)
+ )
+ columns = _columns(conn, 'knowledge_bases')
+ if columns['legacy_vector_collection']['nullable']:
+ with op.batch_alter_table('knowledge_bases') as batch_op:
+ batch_op.alter_column(
+ 'legacy_vector_collection',
+ existing_type=columns['legacy_vector_collection']['type'],
+ nullable=False,
+ server_default=sa.false(),
+ )
+
+
+def _drop_legacy_uniqueness(conn: sa.Connection) -> None:
+ if 'bot_admins' in _table_names(conn):
+ for constraint in _inspector(conn).get_unique_constraints('bot_admins'):
+ if tuple(constraint.get('column_names') or ()) == ('bot_uuid', 'launcher_type', 'launcher_id'):
+ with op.batch_alter_table('bot_admins') as batch_op:
+ batch_op.drop_constraint(constraint['name'], type_='unique')
+ break
+
+ if 'monitoring_feedback' in _table_names(conn):
+ dropped_constraint = False
+ for constraint in _inspector(conn).get_unique_constraints('monitoring_feedback'):
+ if tuple(constraint.get('column_names') or ()) == ('feedback_id',):
+ convention = {'uq': 'uq_%(table_name)s_%(column_0_name)s'}
+ constraint_name = constraint.get('name') or 'uq_monitoring_feedback_feedback_id'
+ with op.batch_alter_table(
+ 'monitoring_feedback',
+ naming_convention=convention,
+ ) as batch_op:
+ batch_op.drop_constraint(constraint_name, type_='unique')
+ dropped_constraint = True
+ break
+ if not dropped_constraint:
+ for index in _inspector(conn).get_indexes('monitoring_feedback'):
+ if index.get('unique') and tuple(index.get('column_names') or ()) == ('feedback_id',):
+ op.drop_index(index['name'], table_name='monitoring_feedback')
+
+
+def _create_index_if_missing(
+ conn: sa.Connection,
+ table_name: str,
+ name: str,
+ columns: tuple[str, ...],
+ unique: bool,
+ predicate: sa.TextClause | None,
+) -> None:
+ if name in _index_names(conn, table_name):
+ return
+ if unique and predicate is None and columns in _unique_column_sets(conn, table_name):
+ return
+ kwargs = {}
+ if predicate is not None:
+ kwargs = {'sqlite_where': predicate, 'postgresql_where': predicate}
+ op.create_index(name, table_name, list(columns), unique=unique, **kwargs)
+
+
+def _validate_scoped_unique_data(conn: sa.Connection) -> None:
+ checks = (
+ ('mcp_servers', ('workspace_uuid', 'name')),
+ ('knowledge_bases', ('workspace_uuid', 'collection_id')),
+ )
+ for table_name, column_names in checks:
+ if table_name not in _table_names(conn):
+ continue
+ columns = _columns(conn, table_name)
+ if not all(column_name in columns for column_name in column_names):
+ continue
+ table = sa.table(
+ table_name,
+ *(sa.column(column_name, columns[column_name]['type']) for column_name in column_names),
+ )
+ group_columns = [table.c[column_name] for column_name in column_names]
+ query = sa.select(*group_columns, sa.func.count()).group_by(*group_columns).having(sa.func.count() > 1)
+ if column_names[-1] == 'collection_id':
+ query = query.where(group_columns[-1].is_not(None))
+ duplicate = conn.execute(query.limit(1)).first()
+ if duplicate is not None:
+ raise RuntimeError(
+ f'Cannot create scoped unique key on {table_name}{column_names}: duplicate {duplicate!r}'
+ )
+
+
+def _create_parent_and_scoped_indexes(conn: sa.Connection) -> None:
+ _validate_scoped_unique_data(conn)
+ tables = _table_names(conn)
+ for table_name, indexes in _SCOPED_INDEXES.items():
+ if table_name not in tables:
+ continue
+ available_columns = _columns(conn, table_name)
+ for name, columns, unique, predicate in indexes:
+ if all(column in available_columns for column in columns):
+ _create_index_if_missing(conn, table_name, name, columns, unique, predicate)
+
+
+def _contract_table(conn: sa.Connection, table_name: str) -> None:
+ columns = _columns(conn, table_name)
+ if 'workspace_uuid' not in columns:
+ return
+ direct_workspace_fk = _foreign_key_exists(
+ conn,
+ table_name,
+ ('workspace_uuid',),
+ 'workspaces',
+ ('uuid',),
+ )
+ current_pk = tuple(_inspector(conn).get_pk_constraint(table_name).get('constrained_columns') or ())
+ desired_pk = _COMPOSITE_PRIMARY_KEYS.get(table_name)
+ missing_composite_fks = [
+ foreign_key
+ for foreign_key in _COMPOSITE_FOREIGN_KEYS.get(table_name, ())
+ if not _foreign_key_exists(
+ conn,
+ table_name,
+ foreign_key[1],
+ foreign_key[2],
+ foreign_key[3],
+ )
+ ]
+ needs_contract = (
+ columns['workspace_uuid']['nullable']
+ or not direct_workspace_fk
+ or (desired_pk is not None and current_pk != desired_pk)
+ or bool(missing_composite_fks)
+ )
+ if not needs_contract:
+ return
+
+ naming = {
+ 'pk': 'pk_%(table_name)s',
+ 'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
+ }
+ pk_name = _inspector(conn).get_pk_constraint(table_name).get('name') or f'pk_{table_name}'
+ with op.batch_alter_table(table_name, naming_convention=naming) as batch_op:
+ if columns['workspace_uuid']['nullable']:
+ batch_op.alter_column(
+ 'workspace_uuid',
+ existing_type=columns['workspace_uuid']['type'],
+ nullable=False,
+ )
+ if desired_pk is not None and current_pk != desired_pk:
+ batch_op.drop_constraint(pk_name, type_='primary')
+ batch_op.create_primary_key(f'pk_{table_name}', list(desired_pk))
+ if not direct_workspace_fk:
+ batch_op.create_foreign_key(
+ f'fk_{table_name}_workspace',
+ 'workspaces',
+ ['workspace_uuid'],
+ ['uuid'],
+ ondelete='CASCADE',
+ )
+ for name, local_columns, referred_table, referred_columns, ondelete in missing_composite_fks:
+ batch_op.create_foreign_key(
+ name,
+ referred_table,
+ list(local_columns),
+ list(referred_columns),
+ ondelete=ondelete,
+ )
+
+
+def _contract_workspace_columns(conn: sa.Connection) -> None:
+ tables = _table_names(conn)
+ # Parents must be contracted before their children so SQLite can validate
+ # the exact composite target key during a batch-table rebuild.
+ order = (
+ 'api_keys',
+ 'bots',
+ 'bot_admins',
+ 'binary_storages',
+ 'mcp_servers',
+ 'model_providers',
+ 'llm_models',
+ 'embedding_models',
+ 'rerank_models',
+ 'legacy_pipelines',
+ 'pipeline_run_records',
+ 'plugin_settings',
+ 'knowledge_bases',
+ 'knowledge_base_files',
+ 'knowledge_base_chunks',
+ 'webhooks',
+ 'monitoring_messages',
+ 'monitoring_llm_calls',
+ 'monitoring_tool_calls',
+ 'monitoring_sessions',
+ 'monitoring_errors',
+ 'monitoring_embedding_calls',
+ 'monitoring_feedback',
+ )
+ for table_name in order:
+ if table_name in tables:
+ _contract_table(conn, table_name)
+
+
+def _migrate_workspace_metadata(conn: sa.Connection, workspace_uuid: str | None) -> None:
+ tables = _table_names(conn)
+ if 'workspaces' not in tables:
+ return
+ if 'workspace_metadata' not in tables:
+ op.create_table(
+ 'workspace_metadata',
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ sa.Column('key', sa.String(255), nullable=False),
+ sa.Column('value', sa.String(255), nullable=True),
+ sa.ForeignKeyConstraint(
+ ['workspace_uuid'],
+ ['workspaces.uuid'],
+ name='fk_workspace_metadata_workspace',
+ ondelete='CASCADE',
+ ),
+ sa.PrimaryKeyConstraint('workspace_uuid', 'key', name='pk_workspace_metadata'),
+ )
+ if workspace_uuid is None or 'metadata' not in tables:
+ return
+ metadata = sa.table(
+ 'metadata',
+ sa.column('key', sa.String(255)),
+ sa.column('value', sa.String(255)),
+ )
+ workspace_metadata = sa.table(
+ 'workspace_metadata',
+ sa.column('workspace_uuid', sa.String(36)),
+ sa.column('key', sa.String(255)),
+ sa.column('value', sa.String(255)),
+ )
+ tenant_keys = ('wizard_status', 'wizard_progress', 'rag_plugin_migration_needed')
+ rows = conn.execute(sa.select(metadata.c.key, metadata.c.value).where(metadata.c.key.in_(tenant_keys))).all()
+ for key, value in rows:
+ exists = conn.execute(
+ sa.select(workspace_metadata.c.key).where(
+ workspace_metadata.c.workspace_uuid == workspace_uuid,
+ workspace_metadata.c.key == key,
+ )
+ ).first()
+ if exists is None:
+ conn.execute(
+ workspace_metadata.insert().values(
+ workspace_uuid=workspace_uuid,
+ key=key,
+ value=value,
+ )
+ )
+ if rows:
+ conn.execute(metadata.delete().where(metadata.c.key.in_(tenant_keys)))
+
+
+def _validate_contract(conn: sa.Connection) -> None:
+ for table_name in _TENANT_TABLES:
+ if table_name not in _table_names(conn):
+ continue
+ columns = _columns(conn, table_name)
+ if 'workspace_uuid' not in columns or columns['workspace_uuid']['nullable']:
+ raise RuntimeError(f'{table_name}.workspace_uuid was not contracted to NOT NULL')
+ table = sa.table(table_name, sa.column('workspace_uuid', sa.String(36)))
+ if conn.scalar(sa.select(sa.func.count()).select_from(table).where(table.c.workspace_uuid.is_(None))):
+ raise RuntimeError(f'{table_name} still contains unscoped rows')
+ if conn.dialect.name == 'sqlite':
+ violations = conn.execute(sa.text('PRAGMA foreign_key_check')).all()
+ if violations:
+ raise RuntimeError(f'SQLite foreign key validation failed: {violations[:5]!r}')
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ _upgrade_normalized_email(conn)
+ workspace_uuid = _default_workspace_uuid(conn)
+ _mark_legacy_vector_collections(conn, workspace_uuid)
+ _expand_workspace_columns(conn, workspace_uuid)
+ _expand_and_hash_api_keys(conn, workspace_uuid)
+ _drop_legacy_uniqueness(conn)
+ _create_parent_and_scoped_indexes(conn)
+ _contract_workspace_columns(conn)
+ _migrate_workspace_metadata(conn, workspace_uuid)
+ _validate_contract(conn)
+
+
+def downgrade() -> None:
+ raise RuntimeError(
+ '0010_scope_resources is intentionally irreversible because plaintext API key secrets were securely removed'
+ )
diff --git a/src/langbot/pkg/persistence/alembic/versions/0011_postgres_tenant_rls.py b/src/langbot/pkg/persistence/alembic/versions/0011_postgres_tenant_rls.py
new file mode 100644
index 000000000..3dda79ccd
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0011_postgres_tenant_rls.py
@@ -0,0 +1,201 @@
+"""enforce PostgreSQL tenant isolation with exact discovery contracts
+
+Revision ID: 0011_postgres_tenant_rls
+Revises: 0010_scope_resources
+Create Date: 2026-07-19
+
+The table and policy lists are deliberately duplicated from the runtime
+contract. Alembic revisions must remain self-contained after application code
+evolves. Discovery policies are SELECT-only and reveal the minimum rows needed
+to turn an authenticated credential into one Workspace transaction.
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = '0011_postgres_tenant_rls'
+down_revision = '0010_scope_resources'
+branch_labels = None
+depends_on = None
+
+
+_POLICY_NAME = 'langbot_workspace_isolation'
+_ACCOUNT_POLICY_NAME = 'langbot_account_discovery'
+_API_KEY_POLICY_NAME = 'langbot_api_key_discovery'
+_INVITATION_POLICY_NAME = 'langbot_invitation_discovery'
+_INSTANCE_POLICY_NAME = 'langbot_instance_discovery'
+
+_TENANT_SETTING = 'langbot.workspace_uuid'
+_ACCOUNT_SETTING = 'langbot.account_uuid'
+_API_KEY_HASH_SETTING = 'langbot.api_key_hash'
+_INVITATION_HASH_SETTING = 'langbot.invitation_hash'
+_INSTANCE_SETTING = 'langbot.instance_uuid'
+_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
+
+_TENANT_TABLE_COLUMNS: dict[str, str] = {
+ 'workspaces': 'uuid',
+ 'workspace_memberships': 'workspace_uuid',
+ 'workspace_invitations': 'workspace_uuid',
+ 'workspace_execution_states': 'workspace_uuid',
+ 'workspace_metadata': 'workspace_uuid',
+ 'api_keys': 'workspace_uuid',
+ 'bots': 'workspace_uuid',
+ 'bot_admins': 'workspace_uuid',
+ 'binary_storages': 'workspace_uuid',
+ 'mcp_servers': 'workspace_uuid',
+ 'model_providers': 'workspace_uuid',
+ 'llm_models': 'workspace_uuid',
+ 'embedding_models': 'workspace_uuid',
+ 'rerank_models': 'workspace_uuid',
+ 'legacy_pipelines': 'workspace_uuid',
+ 'pipeline_run_records': 'workspace_uuid',
+ 'plugin_settings': 'workspace_uuid',
+ 'knowledge_bases': 'workspace_uuid',
+ 'knowledge_base_files': 'workspace_uuid',
+ 'knowledge_base_chunks': 'workspace_uuid',
+ 'webhooks': 'workspace_uuid',
+ 'monitoring_messages': 'workspace_uuid',
+ 'monitoring_llm_calls': 'workspace_uuid',
+ 'monitoring_tool_calls': 'workspace_uuid',
+ 'monitoring_sessions': 'workspace_uuid',
+ 'monitoring_errors': 'workspace_uuid',
+ 'monitoring_embedding_calls': 'workspace_uuid',
+ 'monitoring_feedback': 'workspace_uuid',
+}
+
+
+def _setting(name: str) -> str:
+ return f"NULLIF(current_setting('{name}', true), '')"
+
+
+def _tenant_expression(column: str) -> str:
+ return f'{column}::text = {_setting(_TENANT_SETTING)}'
+
+
+_DISCOVERY_POLICIES: dict[str, dict[str, str]] = {
+ 'workspace_memberships': {
+ _ACCOUNT_POLICY_NAME: (f"account_uuid::text = {_setting(_ACCOUNT_SETTING)} AND status = 'active'"),
+ },
+ 'workspace_execution_states': {
+ _INSTANCE_POLICY_NAME: (
+ f"instance_uuid = {_setting(_INSTANCE_SETTING)} AND state = 'active' AND write_fenced = false"
+ ),
+ },
+ 'api_keys': {
+ _API_KEY_POLICY_NAME: (
+ f"key_hash = {_setting(_API_KEY_HASH_SETTING)} AND status = 'active' "
+ 'AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)'
+ ),
+ },
+ 'workspace_invitations': {
+ _INVITATION_POLICY_NAME: f'token_hash = {_setting(_INVITATION_HASH_SETTING)}',
+ },
+}
+
+
+def _quote_identifier(conn: sa.Connection, identifier: str) -> str:
+ return conn.dialect.identifier_preparer.quote(identifier)
+
+
+def _record_oss_workspace_scope(conn: sa.Connection) -> None:
+ """Keep PostgreSQL OSS usable after FORCE RLS is enabled."""
+
+ local_workspaces = (
+ conn.execute(sa.text("SELECT uuid FROM workspaces WHERE source = 'local' ORDER BY uuid")).scalars().all()
+ )
+ if len(local_workspaces) != 1:
+ return
+
+ existing = conn.execute(
+ sa.text('SELECT value FROM metadata WHERE key = :key'),
+ {'key': _OSS_WORKSPACE_METADATA_KEY},
+ ).scalar_one_or_none()
+ if existing is None:
+ conn.execute(
+ sa.text('INSERT INTO metadata (key, value) VALUES (:key, :value)'),
+ {'key': _OSS_WORKSPACE_METADATA_KEY, 'value': local_workspaces[0]},
+ )
+ elif existing != local_workspaces[0]:
+ raise RuntimeError('Stored OSS Workspace scope does not match the local Workspace')
+
+
+def _drop_all_policies(conn: sa.Connection, table_name: str) -> None:
+ policy_names = conn.execute(
+ sa.text(
+ """
+ SELECT p.polname
+ FROM pg_policy p
+ JOIN pg_class c ON c.oid = p.polrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = current_schema() AND c.relname = :table_name
+ """
+ ),
+ {'table_name': table_name},
+ ).scalars()
+ table = _quote_identifier(conn, table_name)
+ for policy in policy_names:
+ op.execute(sa.text(f'DROP POLICY {_quote_identifier(conn, policy)} ON {table}'))
+
+
+def _create_policy(
+ conn: sa.Connection,
+ table_name: str,
+ policy_name: str,
+ expression: str,
+ *,
+ command: str,
+) -> None:
+ table = _quote_identifier(conn, table_name)
+ policy = _quote_identifier(conn, policy_name)
+ if command == 'ALL':
+ sql = f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC USING ({expression}) WITH CHECK ({expression})'
+ elif command == 'SELECT':
+ sql = f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR SELECT TO PUBLIC USING ({expression})'
+ else: # pragma: no cover - migration-local invariant
+ raise AssertionError(f'Unsupported RLS command: {command}')
+ op.execute(sa.text(sql))
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ if conn.dialect.name != 'postgresql':
+ return
+
+ existing_tables = set(sa.inspect(conn).get_table_names())
+ missing_tables = set(_TENANT_TABLE_COLUMNS) - existing_tables
+ if missing_tables:
+ raise RuntimeError(f'Cannot enable tenant RLS before all tenant-owned tables exist: {sorted(missing_tables)!r}')
+
+ _record_oss_workspace_scope(conn)
+
+ for table_name, tenant_column in _TENANT_TABLE_COLUMNS.items():
+ table = _quote_identifier(conn, table_name)
+ _drop_all_policies(conn, table_name)
+ op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
+ op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
+ _create_policy(
+ conn,
+ table_name,
+ _POLICY_NAME,
+ _tenant_expression(_quote_identifier(conn, tenant_column)),
+ command='ALL',
+ )
+ for policy_name, expression in _DISCOVERY_POLICIES.get(table_name, {}).items():
+ _create_policy(conn, table_name, policy_name, expression, command='SELECT')
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ if conn.dialect.name != 'postgresql':
+ return
+
+ existing_tables = set(sa.inspect(conn).get_table_names())
+ for table_name in _TENANT_TABLE_COLUMNS:
+ if table_name not in existing_tables:
+ continue
+ table = _quote_identifier(conn, table_name)
+ _drop_all_policies(conn, table_name)
+ op.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
+ op.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
diff --git a/src/langbot/pkg/persistence/alembic/versions/0012_plugin_installation_identity.py b/src/langbot/pkg/persistence/alembic/versions/0012_plugin_installation_identity.py
new file mode 100644
index 000000000..566365867
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0012_plugin_installation_identity.py
@@ -0,0 +1,179 @@
+"""add immutable plugin installation identity
+
+Revision ID: 0012_plugin_identity
+Revises: 0011_postgres_tenant_rls
+Create Date: 2026-07-19
+
+The migration gives every legacy row a random, stable installation UUID. A
+legacy artifact digest is only a valid SHA-256-shaped recovery marker; Core
+replaces it with the package digest and increments ``runtime_revision`` before
+the next package apply.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import uuid
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision = '0012_plugin_identity'
+down_revision = '0011_postgres_tenant_rls'
+branch_labels = None
+depends_on = None
+
+
+_TABLE = 'plugin_settings'
+_INSTALLATION_INDEX = 'ix_plugin_settings_workspace_installation'
+_INSTALLATION_UNIQUE = 'uq_plugin_settings_installation_uuid'
+_REVISION_CHECK = 'ck_plugin_settings_runtime_revision_positive'
+_DIGEST_CHECK = 'ck_plugin_settings_artifact_digest_length'
+
+
+def _column_names(conn: sa.Connection) -> set[str]:
+ inspector = sa.inspect(conn)
+ if _TABLE not in inspector.get_table_names():
+ return set()
+ return {column['name'] for column in inspector.get_columns(_TABLE)}
+
+
+def _legacy_digest(installation_uuid: str) -> str:
+ return hashlib.sha256(f'legacy-installation:{installation_uuid}'.encode()).hexdigest()
+
+
+def _suspend_postgres_rls(conn: sa.Connection) -> tuple[bool, bool]:
+ """Let the release migration backfill every tenant row after revision 0011."""
+
+ if conn.dialect.name != 'postgresql':
+ return False, False
+ row = conn.execute(
+ sa.text('SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = to_regclass(:table_name)'),
+ {'table_name': _TABLE},
+ ).one()
+ rls_enabled, rls_forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity)
+ if rls_forced:
+ op.execute(sa.text(f'ALTER TABLE {_TABLE} NO FORCE ROW LEVEL SECURITY'))
+ if rls_enabled:
+ op.execute(sa.text(f'ALTER TABLE {_TABLE} DISABLE ROW LEVEL SECURITY'))
+ return rls_enabled, rls_forced
+
+
+def _restore_postgres_rls(conn: sa.Connection, state: tuple[bool, bool]) -> None:
+ if conn.dialect.name != 'postgresql':
+ return
+ rls_enabled, rls_forced = state
+ if rls_enabled:
+ op.execute(sa.text(f'ALTER TABLE {_TABLE} ENABLE ROW LEVEL SECURITY'))
+ if rls_forced:
+ op.execute(sa.text(f'ALTER TABLE {_TABLE} FORCE ROW LEVEL SECURITY'))
+
+
+def _backfill(conn: sa.Connection) -> None:
+ table = sa.table(
+ _TABLE,
+ sa.column('workspace_uuid', sa.String(36)),
+ sa.column('plugin_author', sa.String(255)),
+ sa.column('plugin_name', sa.String(255)),
+ sa.column('installation_uuid', sa.String(36)),
+ sa.column('artifact_digest', sa.String(64)),
+ sa.column('runtime_revision', sa.Integer()),
+ )
+ rows = conn.execute(
+ sa.select(
+ table.c.workspace_uuid,
+ table.c.plugin_author,
+ table.c.plugin_name,
+ table.c.installation_uuid,
+ table.c.artifact_digest,
+ table.c.runtime_revision,
+ )
+ ).all()
+ for row in rows:
+ installation_uuid = str(row.installation_uuid or uuid.uuid4())
+ values: dict[str, object] = {}
+ if not row.installation_uuid:
+ values['installation_uuid'] = installation_uuid
+ if not row.artifact_digest or len(str(row.artifact_digest)) != 64:
+ values['artifact_digest'] = _legacy_digest(installation_uuid)
+ if row.runtime_revision is None or row.runtime_revision < 1:
+ values['runtime_revision'] = 1
+ if values:
+ conn.execute(
+ table.update()
+ .where(table.c.workspace_uuid == row.workspace_uuid)
+ .where(table.c.plugin_author == row.plugin_author)
+ .where(table.c.plugin_name == row.plugin_name)
+ .values(**values)
+ )
+
+
+def _constraint_names(conn: sa.Connection, kind: str) -> set[str]:
+ inspector = sa.inspect(conn)
+ getter = inspector.get_unique_constraints if kind == 'unique' else inspector.get_check_constraints
+ return {str(item.get('name')) for item in getter(_TABLE) if item.get('name')}
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ columns = _column_names(conn)
+ if not columns:
+ return
+
+ if 'installation_uuid' not in columns:
+ op.add_column(_TABLE, sa.Column('installation_uuid', sa.String(36), nullable=True))
+ if 'artifact_digest' not in columns:
+ op.add_column(_TABLE, sa.Column('artifact_digest', sa.String(64), nullable=True))
+ if 'runtime_revision' not in columns:
+ op.add_column(_TABLE, sa.Column('runtime_revision', sa.Integer(), nullable=True))
+
+ rls_state = _suspend_postgres_rls(conn)
+ try:
+ _backfill(conn)
+ finally:
+ _restore_postgres_rls(conn, rls_state)
+
+ # Fresh databases are created from current metadata before Alembic runs;
+ # guards make the revision safe for that path and for interrupted upgrades.
+ indexes = {index['name'] for index in sa.inspect(conn).get_indexes(_TABLE)}
+ unique_constraints = _constraint_names(conn, 'unique')
+ check_constraints = _constraint_names(conn, 'check')
+ with op.batch_alter_table(_TABLE) as batch:
+ batch.alter_column('installation_uuid', existing_type=sa.String(36), nullable=False)
+ batch.alter_column('artifact_digest', existing_type=sa.String(64), nullable=False)
+ batch.alter_column('runtime_revision', existing_type=sa.Integer(), nullable=False)
+ if _REVISION_CHECK not in check_constraints:
+ batch.create_check_constraint(_REVISION_CHECK, 'runtime_revision >= 1')
+ if _DIGEST_CHECK not in check_constraints:
+ batch.create_check_constraint(_DIGEST_CHECK, 'length(artifact_digest) = 64')
+ if _INSTALLATION_UNIQUE not in unique_constraints:
+ batch.create_unique_constraint(_INSTALLATION_UNIQUE, ['installation_uuid'])
+ if _INSTALLATION_INDEX not in indexes and _INSTALLATION_INDEX not in unique_constraints:
+ batch.create_index(
+ _INSTALLATION_INDEX,
+ ['workspace_uuid', 'installation_uuid'],
+ unique=True,
+ )
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ columns = _column_names(conn)
+ if not columns:
+ return
+ indexes = {index['name'] for index in sa.inspect(conn).get_indexes(_TABLE)}
+ checks = _constraint_names(conn, 'check')
+ uniques = _constraint_names(conn, 'unique')
+ with op.batch_alter_table(_TABLE) as batch:
+ if _INSTALLATION_INDEX in indexes:
+ batch.drop_index(_INSTALLATION_INDEX)
+ if _DIGEST_CHECK in checks:
+ batch.drop_constraint(_DIGEST_CHECK, type_='check')
+ if _REVISION_CHECK in checks:
+ batch.drop_constraint(_REVISION_CHECK, type_='check')
+ if _INSTALLATION_UNIQUE in uniques:
+ batch.drop_constraint(_INSTALLATION_UNIQUE, type_='unique')
+ for column_name in ('runtime_revision', 'artifact_digest', 'installation_uuid'):
+ if column_name in columns:
+ batch.drop_column(column_name)
diff --git a/src/langbot/pkg/persistence/alembic/versions/0013_tenant_pgvector.py b/src/langbot/pkg/persistence/alembic/versions/0013_tenant_pgvector.py
new file mode 100644
index 000000000..2f328b757
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0013_tenant_pgvector.py
@@ -0,0 +1,382 @@
+"""create tenant-scoped pgvector storage in the business database
+
+Revision ID: 0013_tenant_pgvector
+Revises: 0012_plugin_identity
+Create Date: 2026-07-19
+
+The Cloud application role never executes this DDL. A release migration role
+installs pgvector once, creates an untyped vector column, and builds a bounded
+set of expression/partial ANN indexes. Existing legacy rows are migrated only
+when each row maps unambiguously to one knowledge base.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import typing
+
+import sqlalchemy as sa
+from alembic import op
+from pgvector.sqlalchemy import Vector
+
+
+revision = '0013_tenant_pgvector'
+down_revision = '0012_plugin_identity'
+branch_labels = None
+depends_on = None
+
+
+_VECTOR_TABLE = 'langbot_vectors'
+_LEGACY_TABLE = 'langbot_vectors_legacy_0013'
+_TENANT_POLICY = 'langbot_workspace_isolation'
+_TENANT_SETTING = 'langbot.workspace_uuid'
+_KB_DIMENSION_CHECK = 'ck_knowledge_bases_embedding_dimension_positive'
+_VECTOR_DIMENSION_CHECK = 'ck_langbot_vectors_embedding_dimension'
+_VECTOR_ALLOWED_CHECK = 'ck_langbot_vectors_embedding_dimension_enabled'
+_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
+_LEGACY_SOURCE_TABLES = (
+ 'knowledge_bases',
+ 'knowledge_base_files',
+ 'knowledge_base_chunks',
+)
+
+
+def _quote(conn: sa.Connection, identifier: str) -> str:
+ return conn.dialect.identifier_preparer.quote(identifier)
+
+
+def _columns(conn: sa.Connection, table_name: str) -> set[str]:
+ inspector = sa.inspect(conn)
+ if table_name not in inspector.get_table_names():
+ return set()
+ return {column['name'] for column in inspector.get_columns(table_name)}
+
+
+def _checks(conn: sa.Connection, table_name: str) -> set[str]:
+ return {str(item['name']) for item in sa.inspect(conn).get_check_constraints(table_name) if item.get('name')}
+
+
+def _ensure_knowledge_base_dimension(conn: sa.Connection) -> None:
+ columns = _columns(conn, 'knowledge_bases')
+ if 'embedding_dimension' not in columns:
+ op.add_column('knowledge_bases', sa.Column('embedding_dimension', sa.Integer(), nullable=True))
+ if _KB_DIMENSION_CHECK not in _checks(conn, 'knowledge_bases'):
+ with op.batch_alter_table('knowledge_bases') as batch:
+ batch.create_check_constraint(
+ _KB_DIMENSION_CHECK,
+ 'embedding_dimension IS NULL OR embedding_dimension > 0',
+ )
+
+
+def _create_vector_table() -> None:
+ enabled = ', '.join(str(item) for item in _ALLOWED_DIMENSIONS)
+ op.create_table(
+ _VECTOR_TABLE,
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ sa.Column('knowledge_base_uuid', sa.String(255), nullable=False),
+ sa.Column('vector_id', sa.String(255), nullable=False),
+ sa.Column('embedding_dimension', sa.Integer(), nullable=False),
+ sa.Column('embedding', Vector(), nullable=False),
+ sa.Column('text', sa.Text(), nullable=True),
+ sa.Column('file_id', sa.String(255), nullable=True),
+ sa.Column('chunk_uuid', sa.String(255), nullable=True),
+ sa.PrimaryKeyConstraint(
+ 'workspace_uuid',
+ 'knowledge_base_uuid',
+ 'vector_id',
+ name='pk_langbot_vectors',
+ ),
+ sa.ForeignKeyConstraint(
+ ['workspace_uuid', 'knowledge_base_uuid'],
+ ['knowledge_bases.workspace_uuid', 'knowledge_bases.uuid'],
+ name='fk_langbot_vectors_workspace_kb',
+ ondelete='CASCADE',
+ ),
+ sa.CheckConstraint(
+ 'vector_dims(embedding) = embedding_dimension',
+ name=_VECTOR_DIMENSION_CHECK,
+ ),
+ sa.CheckConstraint(
+ f'embedding_dimension IN ({enabled})',
+ name=_VECTOR_ALLOWED_CHECK,
+ ),
+ )
+ op.create_index(
+ 'ix_langbot_vectors_workspace_kb_file',
+ _VECTOR_TABLE,
+ ['workspace_uuid', 'knowledge_base_uuid', 'file_id'],
+ )
+ op.create_index(
+ 'ix_langbot_vectors_workspace_kb_chunk',
+ _VECTOR_TABLE,
+ ['workspace_uuid', 'knowledge_base_uuid', 'chunk_uuid'],
+ )
+
+
+def _legacy_mapping_predicate() -> str:
+ return """
+ kb.collection_id = legacy.collection
+ OR EXISTS (
+ SELECT 1
+ FROM knowledge_base_files AS files
+ LEFT JOIN knowledge_base_chunks AS chunks
+ ON chunks.workspace_uuid = files.workspace_uuid
+ AND chunks.file_id = files.uuid
+ WHERE files.workspace_uuid = kb.workspace_uuid
+ AND files.kb_id = kb.uuid
+ AND (files.uuid = legacy.file_id OR chunks.uuid = legacy.chunk_uuid)
+ )
+ """
+
+
+def _legacy_source_rls_states(conn: sa.Connection) -> dict[str, tuple[bool, bool]]:
+ rows = (
+ conn.execute(
+ sa.text(
+ """
+ SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
+ FROM pg_class AS c
+ JOIN pg_namespace AS n ON n.oid = c.relnamespace
+ WHERE n.nspname = current_schema()
+ AND c.relname IN :table_names
+ AND c.relkind IN ('r', 'p')
+ """
+ ).bindparams(sa.bindparam('table_names', expanding=True)),
+ {'table_names': _LEGACY_SOURCE_TABLES},
+ )
+ .mappings()
+ .all()
+ )
+ states = {str(row['relname']): (bool(row['relrowsecurity']), bool(row['relforcerowsecurity'])) for row in rows}
+ missing = set(_LEGACY_SOURCE_TABLES) - set(states)
+ if missing:
+ raise RuntimeError(f'Legacy pgvector source tables are missing: {sorted(missing)!r}')
+ return states
+
+
+@contextlib.contextmanager
+def _suspend_legacy_source_rls(conn: sa.Connection) -> typing.Iterator[None]:
+ """Temporarily let the table-owning migrator map all legacy tenant rows.
+
+ Revision 0011 enables and forces RLS on each source table. The release
+ migrator intentionally has neither superuser nor BYPASSRLS, so even a table
+ owner cannot read those rows until FORCE RLS is paused. Preserve both flags
+ independently and restore them in ``finally`` so mixed pre-existing states
+ survive successful, rejected, and interrupted legacy migrations.
+ """
+
+ states = _legacy_source_rls_states(conn)
+ try:
+ for table_name in _LEGACY_SOURCE_TABLES:
+ table = _quote(conn, table_name)
+ conn.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
+ conn.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
+ yield
+ finally:
+ for table_name in _LEGACY_SOURCE_TABLES:
+ table = _quote(conn, table_name)
+ rls_enabled, rls_forced = states[table_name]
+ enabled_clause = 'ENABLE' if rls_enabled else 'DISABLE'
+ forced_clause = 'FORCE' if rls_forced else 'NO FORCE'
+ conn.execute(sa.text(f'ALTER TABLE {table} {enabled_clause} ROW LEVEL SECURITY'))
+ conn.execute(sa.text(f'ALTER TABLE {table} {forced_clause} ROW LEVEL SECURITY'))
+
+
+def _migrate_legacy_rows(conn: sa.Connection) -> None:
+ predicate = _legacy_mapping_predicate()
+ ambiguous = conn.execute(
+ sa.text(
+ f"""
+ WITH candidates AS (
+ SELECT legacy.id, kb.workspace_uuid, kb.uuid AS knowledge_base_uuid
+ FROM {_LEGACY_TABLE} AS legacy
+ JOIN knowledge_bases AS kb ON ({predicate})
+ ), candidate_counts AS (
+ SELECT id, COUNT(*) AS count
+ FROM candidates
+ GROUP BY id
+ )
+ SELECT legacy.id, COALESCE(candidate_counts.count, 0) AS candidate_count
+ FROM {_LEGACY_TABLE} AS legacy
+ LEFT JOIN candidate_counts ON candidate_counts.id = legacy.id
+ WHERE COALESCE(candidate_counts.count, 0) <> 1
+ LIMIT 1
+ """
+ )
+ ).first()
+ if ambiguous is not None:
+ raise RuntimeError(
+ 'Legacy pgvector row cannot be mapped to exactly one Workspace/knowledge base: '
+ f'{ambiguous.id!r} has {ambiguous.candidate_count} candidates'
+ )
+
+ conn.execute(
+ sa.text(
+ f"""
+ INSERT INTO {_VECTOR_TABLE} (
+ workspace_uuid,
+ knowledge_base_uuid,
+ vector_id,
+ embedding_dimension,
+ embedding,
+ text,
+ file_id,
+ chunk_uuid
+ )
+ SELECT
+ kb.workspace_uuid,
+ kb.uuid,
+ legacy.id,
+ vector_dims(legacy.embedding),
+ legacy.embedding,
+ legacy.text,
+ legacy.file_id,
+ legacy.chunk_uuid
+ FROM {_LEGACY_TABLE} AS legacy
+ JOIN knowledge_bases AS kb ON ({predicate})
+ """
+ )
+ )
+
+ mixed_dimension = conn.execute(
+ sa.text(
+ f"""
+ SELECT workspace_uuid, knowledge_base_uuid
+ FROM {_VECTOR_TABLE}
+ GROUP BY workspace_uuid, knowledge_base_uuid
+ HAVING MIN(embedding_dimension) <> MAX(embedding_dimension)
+ LIMIT 1
+ """
+ )
+ ).first()
+ if mixed_dimension is not None:
+ raise RuntimeError('Legacy knowledge base contains mixed embedding dimensions')
+
+ conn.execute(
+ sa.text(
+ f"""
+ UPDATE knowledge_bases AS kb
+ SET embedding_dimension = dimensions.embedding_dimension
+ FROM (
+ SELECT workspace_uuid, knowledge_base_uuid, MIN(embedding_dimension) AS embedding_dimension
+ FROM {_VECTOR_TABLE}
+ GROUP BY workspace_uuid, knowledge_base_uuid
+ ) AS dimensions
+ WHERE kb.workspace_uuid = dimensions.workspace_uuid
+ AND kb.uuid = dimensions.knowledge_base_uuid
+ AND kb.embedding_dimension IS NULL
+ """
+ )
+ )
+
+
+def _drop_all_policies(conn: sa.Connection) -> None:
+ table = _quote(conn, _VECTOR_TABLE)
+ policies = conn.execute(
+ sa.text(
+ """
+ SELECT p.polname
+ FROM pg_policy p
+ JOIN pg_class c ON c.oid = p.polrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = current_schema() AND c.relname = :table_name
+ """
+ ),
+ {'table_name': _VECTOR_TABLE},
+ ).scalars()
+ for policy_name in policies:
+ op.execute(sa.text(f'DROP POLICY {_quote(conn, policy_name)} ON {table}'))
+
+
+def _enable_rls(conn: sa.Connection) -> None:
+ table = _quote(conn, _VECTOR_TABLE)
+ policy = _quote(conn, _TENANT_POLICY)
+ expression = f"workspace_uuid::text = NULLIF(current_setting('{_TENANT_SETTING}', true), '')"
+ _drop_all_policies(conn)
+ op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
+ op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
+ op.execute(
+ sa.text(
+ f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
+ f'USING ({expression}) WITH CHECK ({expression})'
+ )
+ )
+
+
+def _create_ann_indexes(conn: sa.Connection) -> None:
+ table = _quote(conn, _VECTOR_TABLE)
+ for dimension in _ALLOWED_DIMENSIONS:
+ index = _quote(conn, f'ix_langbot_vectors_hnsw_cosine_{dimension}')
+ op.execute(
+ sa.text(
+ f'CREATE INDEX {index} ON {table} USING hnsw '
+ f'((embedding::vector({dimension})) vector_cosine_ops) '
+ f'WHERE embedding_dimension = {dimension}'
+ )
+ )
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ if 'knowledge_bases' not in sa.inspect(conn).get_table_names():
+ if conn.dialect.name == 'postgresql':
+ # The supported PostgreSQL release path creates the business
+ # tables before stamping 0010 and reaching this migration. Missing
+ # knowledge_bases therefore means the operator bypassed the
+ # release bootstrap or the schema is incomplete; stamping head in
+ # that state would make Cloud runtime validation unrecoverable.
+ raise RuntimeError('PostgreSQL release migration requires the knowledge_bases table')
+ # A direct empty SQLite Alembic walk is still used by migration tooling;
+ # Core creates the complete ORM schema on its following compatibility
+ # pass, including the portable embedding_dimension field.
+ return
+
+ _ensure_knowledge_base_dimension(conn)
+
+ # pgvector storage is PostgreSQL-only, but ``embedding_dimension`` is an
+ # ORM field used by both deployment modes. Existing OSS SQLite databases
+ # must receive the column before this revision becomes a no-op.
+ if conn.dialect.name != 'postgresql':
+ return
+
+ op.execute(sa.text('CREATE EXTENSION IF NOT EXISTS vector'))
+ columns = _columns(conn, _VECTOR_TABLE)
+ if columns:
+ scoped_columns = {
+ 'workspace_uuid',
+ 'knowledge_base_uuid',
+ 'vector_id',
+ 'embedding_dimension',
+ 'embedding',
+ }
+ if scoped_columns.issubset(columns):
+ raise RuntimeError('Tenant pgvector table exists before its owning release migration')
+ legacy_columns = {'id', 'collection', 'embedding'}
+ if not legacy_columns.issubset(columns):
+ raise RuntimeError('Existing pgvector table has an unsupported schema')
+ if _LEGACY_TABLE in sa.inspect(conn).get_table_names():
+ raise RuntimeError(f'Interrupted pgvector migration left {_LEGACY_TABLE!r} behind')
+ op.rename_table(_VECTOR_TABLE, _LEGACY_TABLE)
+
+ _create_vector_table()
+ if _LEGACY_TABLE in sa.inspect(conn).get_table_names():
+ with _suspend_legacy_source_rls(conn):
+ _migrate_legacy_rows(conn)
+ op.drop_table(_LEGACY_TABLE)
+ _create_ann_indexes(conn)
+ _enable_rls(conn)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ if conn.dialect.name == 'postgresql' and _VECTOR_TABLE in sa.inspect(conn).get_table_names():
+ _drop_all_policies(conn)
+ op.drop_table(_VECTOR_TABLE)
+
+ columns = _columns(conn, 'knowledge_bases')
+ if 'embedding_dimension' in columns:
+ checks = _checks(conn, 'knowledge_bases')
+ with op.batch_alter_table('knowledge_bases') as batch:
+ if _KB_DIMENSION_CHECK in checks:
+ batch.drop_constraint(_KB_DIMENSION_CHECK, type_='check')
+ batch.drop_column('embedding_dimension')
diff --git a/src/langbot/pkg/persistence/alembic/versions/0014_cloud_directory_projection.py b/src/langbot/pkg/persistence/alembic/versions/0014_cloud_directory_projection.py
new file mode 100644
index 000000000..86b95bb49
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0014_cloud_directory_projection.py
@@ -0,0 +1,268 @@
+"""add the Cloud directory projection persistence boundary
+
+Revision ID: 0014_cloud_directory
+Revises: 0013_tenant_pgvector
+Create Date: 2026-07-24
+
+The open Core projector receives already-verified control-plane data and is the
+only runtime path allowed to mutate projected Workspace directory rows. Its
+transaction-local instance setting is intentionally distinct from both normal
+Workspace scope and the read-only instance discovery scope.
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision = '0014_cloud_directory'
+down_revision = '0013_tenant_pgvector'
+branch_labels = None
+depends_on = None
+
+
+_STATE_TABLE = 'directory_projection_states'
+_INBOX_TABLE = 'directory_projection_inbox'
+_DIRECTORY_POLICY_NAME = 'langbot_directory_projection'
+_TENANT_POLICY_NAME = 'langbot_workspace_isolation'
+_LOCAL_WRITE_POLICY_NAME = 'langbot_workspace_local_directory_write'
+_DIRECTORY_SETTING = 'langbot.directory_instance_uuid'
+_TENANT_SETTING = 'langbot.workspace_uuid'
+_PROJECTED_TENANT_TABLES = (
+ 'workspaces',
+ 'workspace_memberships',
+ 'workspace_execution_states',
+)
+
+
+def _setting(name: str) -> str:
+ return f"NULLIF(current_setting('{name}', true), '')"
+
+
+def _quote(conn: sa.Connection, identifier: str) -> str:
+ return conn.dialect.identifier_preparer.quote(identifier)
+
+
+def _create_tables(conn: sa.Connection) -> None:
+ existing_tables = set(sa.inspect(conn).get_table_names())
+ if _STATE_TABLE not in existing_tables:
+ op.create_table(
+ _STATE_TABLE,
+ sa.Column('instance_uuid', sa.String(255), nullable=False),
+ sa.Column('cursor', sa.BigInteger(), server_default='0', nullable=False),
+ sa.Column('snapshot_coverage_cursor', sa.BigInteger(), server_default='0', nullable=False),
+ sa.Column('snapshot_fingerprint', sa.Text(), nullable=False),
+ sa.Column('last_applied_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True),
+ sa.CheckConstraint(
+ 'cursor >= 0',
+ name='ck_directory_projection_state_cursor',
+ ),
+ sa.CheckConstraint(
+ 'snapshot_coverage_cursor >= 0 AND snapshot_coverage_cursor <= cursor',
+ name='ck_directory_projection_state_snapshot_coverage',
+ ),
+ sa.CheckConstraint(
+ 'length(snapshot_fingerprint) = 64',
+ name='ck_directory_projection_state_fingerprint',
+ ),
+ sa.PrimaryKeyConstraint('instance_uuid'),
+ )
+ if _INBOX_TABLE not in existing_tables:
+ op.create_table(
+ _INBOX_TABLE,
+ sa.Column('instance_uuid', sa.String(255), nullable=False),
+ sa.Column('event_uuid', sa.String(36), nullable=False),
+ sa.Column('cursor', sa.BigInteger(), nullable=False),
+ sa.Column('event_type', sa.String(128), nullable=False),
+ sa.Column('revision', sa.BigInteger(), nullable=False),
+ sa.Column('fingerprint', sa.Text(), nullable=False),
+ sa.Column(
+ 'received_at',
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ sa.Column('applied_at', sa.DateTime(timezone=True), nullable=True),
+ sa.CheckConstraint(
+ 'cursor > 0',
+ name='ck_directory_projection_inbox_cursor',
+ ),
+ sa.CheckConstraint(
+ 'revision > 0',
+ name='ck_directory_projection_inbox_revision',
+ ),
+ sa.CheckConstraint(
+ 'length(fingerprint) = 64',
+ name='ck_directory_projection_inbox_fingerprint',
+ ),
+ sa.PrimaryKeyConstraint('instance_uuid', 'event_uuid'),
+ sa.UniqueConstraint(
+ 'instance_uuid',
+ 'cursor',
+ name='uq_directory_projection_inbox_cursor',
+ ),
+ )
+ op.create_index(
+ 'ix_directory_projection_inbox_pending',
+ _INBOX_TABLE,
+ ['instance_uuid', 'applied_at', 'cursor'],
+ unique=False,
+ )
+
+
+def _drop_policy(conn: sa.Connection, table_name: str, policy_name: str) -> None:
+ table = _quote(conn, table_name)
+ policy = _quote(conn, policy_name)
+ op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
+
+
+def _create_policy(
+ conn: sa.Connection,
+ table_name: str,
+ policy_name: str,
+ expression: str,
+ *,
+ command: str = 'ALL',
+) -> None:
+ table = _quote(conn, table_name)
+ policy = _quote(conn, policy_name)
+ _drop_policy(conn, table_name, policy_name)
+ op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
+ op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
+ if command == 'SELECT':
+ sql = f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR SELECT TO PUBLIC USING ({expression})'
+ elif command == 'ALL':
+ sql = (
+ f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
+ f'USING ({expression}) WITH CHECK ({expression})'
+ )
+ else: # pragma: no cover - migration-local invariant.
+ raise AssertionError(f'Unsupported RLS policy command: {command}')
+ op.execute(sa.text(sql))
+
+
+def _install_postgres_policies(conn: sa.Connection) -> None:
+ existing_tables = set(sa.inspect(conn).get_table_names())
+ required_tables = set(_PROJECTED_TENANT_TABLES) | {_STATE_TABLE, _INBOX_TABLE}
+ missing_tables = required_tables - existing_tables
+ if missing_tables:
+ raise RuntimeError(
+ f'Cannot enable Cloud directory projection RLS before all required tables exist: {sorted(missing_tables)!r}'
+ )
+
+ directory_setting = _setting(_DIRECTORY_SETTING)
+ tenant_setting = _setting(_TENANT_SETTING)
+ directory_expressions = {
+ 'workspaces': (f"instance_uuid::text = {directory_setting} AND source = 'cloud_projection'"),
+ 'workspace_memberships': (
+ 'EXISTS ('
+ 'SELECT 1 FROM workspaces AS directory_workspace '
+ 'WHERE directory_workspace.uuid = workspace_memberships.workspace_uuid '
+ f'AND directory_workspace.instance_uuid::text = {directory_setting} '
+ "AND directory_workspace.source = 'cloud_projection'"
+ ')'
+ ),
+ 'workspace_execution_states': (
+ f"instance_uuid::text = {directory_setting} AND source = 'cloud' AND EXISTS ("
+ 'SELECT 1 FROM workspaces AS directory_workspace '
+ 'WHERE directory_workspace.uuid = workspace_execution_states.workspace_uuid '
+ f'AND directory_workspace.instance_uuid::text = {directory_setting} '
+ "AND directory_workspace.source = 'cloud_projection'"
+ ')'
+ ),
+ _STATE_TABLE: f'instance_uuid::text = {directory_setting}',
+ _INBOX_TABLE: f'instance_uuid::text = {directory_setting}',
+ }
+ tenant_expressions = {
+ 'workspaces': f'uuid::text = {tenant_setting}',
+ 'workspace_memberships': f'workspace_uuid::text = {tenant_setting}',
+ 'workspace_execution_states': f'workspace_uuid::text = {tenant_setting}',
+ }
+ local_write_expressions = {
+ 'workspaces': f"uuid::text = {tenant_setting} AND source = 'local'",
+ 'workspace_memberships': (
+ f'workspace_uuid::text = {tenant_setting} AND EXISTS ('
+ 'SELECT 1 FROM workspaces AS local_workspace '
+ 'WHERE local_workspace.uuid = workspace_memberships.workspace_uuid '
+ "AND local_workspace.source = 'local'"
+ ')'
+ ),
+ 'workspace_execution_states': (
+ f'workspace_uuid::text = {tenant_setting} AND EXISTS ('
+ 'SELECT 1 FROM workspaces AS local_workspace '
+ 'WHERE local_workspace.uuid = workspace_execution_states.workspace_uuid '
+ "AND local_workspace.source = 'local'"
+ ')'
+ ),
+ }
+ for table_name in _PROJECTED_TENANT_TABLES:
+ _create_policy(
+ conn,
+ table_name,
+ _TENANT_POLICY_NAME,
+ tenant_expressions[table_name],
+ command='SELECT',
+ )
+ _create_policy(
+ conn,
+ table_name,
+ _LOCAL_WRITE_POLICY_NAME,
+ local_write_expressions[table_name],
+ )
+ _create_policy(
+ conn,
+ table_name,
+ _DIRECTORY_POLICY_NAME,
+ directory_expressions[table_name],
+ )
+ for table_name in (_STATE_TABLE, _INBOX_TABLE):
+ _create_policy(
+ conn,
+ table_name,
+ _DIRECTORY_POLICY_NAME,
+ directory_expressions[table_name],
+ )
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ _create_tables(conn)
+ if conn.dialect.name == 'postgresql':
+ _install_postgres_policies(conn)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ existing_tables = set(sa.inspect(conn).get_table_names())
+ if conn.dialect.name == 'postgresql':
+ tenant_setting = _setting(_TENANT_SETTING)
+ tenant_columns = {
+ 'workspaces': 'uuid',
+ 'workspace_memberships': 'workspace_uuid',
+ 'workspace_execution_states': 'workspace_uuid',
+ }
+ for table_name in _PROJECTED_TENANT_TABLES:
+ if table_name not in existing_tables:
+ continue
+ _drop_policy(conn, table_name, _DIRECTORY_POLICY_NAME)
+ _drop_policy(conn, table_name, _LOCAL_WRITE_POLICY_NAME)
+ _create_policy(
+ conn,
+ table_name,
+ _TENANT_POLICY_NAME,
+ f'{tenant_columns[table_name]}::text = {tenant_setting}',
+ )
+ for table_name in (_STATE_TABLE, _INBOX_TABLE):
+ if table_name not in existing_tables:
+ continue
+ _drop_policy(conn, table_name, _DIRECTORY_POLICY_NAME)
+ table = _quote(conn, table_name)
+ op.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
+ op.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
+
+ if _INBOX_TABLE in existing_tables:
+ op.drop_table(_INBOX_TABLE)
+ if _STATE_TABLE in existing_tables:
+ op.drop_table(_STATE_TABLE)
diff --git a/src/langbot/pkg/persistence/alembic/versions/0015_cloud_core_collaboration.py b/src/langbot/pkg/persistence/alembic/versions/0015_cloud_core_collaboration.py
new file mode 100644
index 000000000..4a0646e07
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0015_cloud_core_collaboration.py
@@ -0,0 +1,75 @@
+"""allow Core-owned collaboration writes on Cloud Workspaces
+
+Revision ID: 0015_cloud_core_collab
+Revises: 0014_cloud_directory
+Create Date: 2026-07-26
+
+Cloud-projected Workspace identity remains projected by the directory
+boundary, but membership role/remove and invitation acceptance are now owned
+by Core tenant scope.
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision = '0015_cloud_core_collab'
+down_revision = '0014_cloud_directory'
+branch_labels = None
+depends_on = None
+
+
+_TABLE_NAME = 'workspace_memberships'
+_POLICY_NAME = 'langbot_workspace_local_directory_write'
+_TENANT_SETTING = 'langbot.workspace_uuid'
+
+
+def _setting(name: str) -> str:
+ return f"NULLIF(current_setting('{name}', true), '')"
+
+
+def _quote(conn: sa.Connection, identifier: str) -> str:
+ return conn.dialect.identifier_preparer.quote(identifier)
+
+
+def _drop_policy(conn: sa.Connection) -> None:
+ table = _quote(conn, _TABLE_NAME)
+ policy = _quote(conn, _POLICY_NAME)
+ op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
+
+
+def _create_policy(conn: sa.Connection, expression: str) -> None:
+ table = _quote(conn, _TABLE_NAME)
+ policy = _quote(conn, _POLICY_NAME)
+ op.execute(
+ sa.text(
+ f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
+ f'USING ({expression}) WITH CHECK ({expression})'
+ )
+ )
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ if conn.dialect.name != 'postgresql':
+ return
+ expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}'
+ _drop_policy(conn)
+ _create_policy(conn, expression)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ if conn.dialect.name != 'postgresql':
+ return
+ expression = (
+ f'workspace_uuid::text = {_setting(_TENANT_SETTING)} AND EXISTS ('
+ 'SELECT 1 FROM workspaces AS local_workspace '
+ 'WHERE local_workspace.uuid = workspace_memberships.workspace_uuid '
+ "AND local_workspace.source = 'local'"
+ ')'
+ )
+ _drop_policy(conn)
+ _create_policy(conn, expression)
diff --git a/src/langbot/pkg/persistence/alembic_runner.py b/src/langbot/pkg/persistence/alembic_runner.py
index 74c2bac3d..b7108de76 100644
--- a/src/langbot/pkg/persistence/alembic_runner.py
+++ b/src/langbot/pkg/persistence/alembic_runner.py
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
from alembic.config import Config
from alembic import command
from alembic.runtime.migration import MigrationContext
+from alembic.script import ScriptDirectory
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -47,12 +48,28 @@ def _do_stamp(connection: Connection, revision: str = 'head') -> None:
command.stamp(cfg, revision)
+def _do_downgrade(connection: Connection, revision: str) -> None:
+ """Synchronous downgrade — runs inside run_sync."""
+ cfg = _build_config(connection)
+ command.downgrade(cfg, revision)
+
+
def _do_get_current(connection: Connection) -> str | None:
"""Get current alembic revision synchronously."""
ctx = MigrationContext.configure(connection)
return ctx.get_current_revision()
+def get_alembic_head() -> str:
+ """Resolve the single release head without opening a database connection."""
+ cfg = Config()
+ cfg.set_main_option('script_location', _ALEMBIC_DIR)
+ head = ScriptDirectory.from_config(cfg).get_current_head()
+ if head is None:
+ raise RuntimeError('Alembic has no migration head')
+ return head
+
+
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
"""Synchronous autogenerate — runs inside run_sync."""
cfg = _build_config(connection)
@@ -73,6 +90,13 @@ async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -
await conn.commit()
+async def run_alembic_downgrade(async_engine: AsyncEngine, revision: str) -> None:
+ """Run Alembic downgrade to the given revision."""
+ async with async_engine.connect() as conn:
+ await conn.run_sync(_do_downgrade, revision)
+ await conn.commit()
+
+
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
"""Get current alembic revision, or None if not stamped."""
async with async_engine.connect() as conn:
@@ -121,6 +145,7 @@ if __name__ == '__main__':
print('Commands:')
print(' autogenerate "message" — Generate migration from ORM model diff')
print(' upgrade [revision] — Upgrade database (default: head)')
+ print(' downgrade — Downgrade database to a revision')
print(' stamp [revision] — Stamp revision without running (default: head)')
print(' current — Show current revision')
sys.exit(1)
@@ -140,6 +165,13 @@ if __name__ == '__main__':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_stamp(engine, rev))
print(f'Stamped: {rev}')
+ elif cmd == 'downgrade':
+ if len(sys.argv) < 3:
+ print('Usage: python -m langbot.pkg.persistence.alembic_runner downgrade ')
+ sys.exit(1)
+ rev = sys.argv[2]
+ asyncio.run(run_alembic_downgrade(engine, rev))
+ print(f'Downgraded to: {rev}')
elif cmd == 'current':
rev = asyncio.run(get_alembic_current(engine))
print(f'Current revision: {rev}')
diff --git a/src/langbot/pkg/persistence/database.py b/src/langbot/pkg/persistence/database.py
index 4debb03db..2eee09a12 100644
--- a/src/langbot/pkg/persistence/database.py
+++ b/src/langbot/pkg/persistence/database.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import abc
+import sqlalchemy
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
from ..core import app
@@ -30,8 +31,15 @@ class BaseDatabaseManager(abc.ABC):
engine: sqlalchemy_asyncio.AsyncEngine
- def __init__(self, ap: app.Application) -> None:
+ def __init__(
+ self,
+ ap: app.Application,
+ *,
+ url_override: sqlalchemy.engine.URL | None = None,
+ ) -> None:
self.ap = ap
+ self.url_override = url_override
+ self.persistence_mode: str | None = None
@abc.abstractmethod
async def initialize(self) -> None:
diff --git a/src/langbot/pkg/persistence/databases/postgresql.py b/src/langbot/pkg/persistence/databases/postgresql.py
index f63d8f61f..75e340f7f 100644
--- a/src/langbot/pkg/persistence/databases/postgresql.py
+++ b/src/langbot/pkg/persistence/databases/postgresql.py
@@ -1,21 +1,167 @@
from __future__ import annotations
+import sqlalchemy
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
from .. import database
+from ..postgresql_url import normalize_asyncpg_url
+
+
+MAX_POOL_CONNECTIONS = 100
+MAX_POOL_TIMEOUT_SECONDS = 300
+MAX_POOL_RECYCLE_SECONDS = 86_400
+MAX_STATEMENT_TIMEOUT_MS = 300_000
+MAX_LOCK_TIMEOUT_MS = 60_000
+MAX_IDLE_TRANSACTION_TIMEOUT_MS = 300_000
@database.manager_class('postgresql')
class PostgreSQLDatabaseManager(database.BaseDatabaseManager):
"""PostgreSQL database manager"""
- async def initialize(self) -> None:
- postgresql_config = self.ap.instance_config.data.get('database', {}).get('postgresql', {})
+ @staticmethod
+ def _pool_integer(
+ config: dict,
+ name: str,
+ default: int,
+ *,
+ minimum: int,
+ maximum: int,
+ ) -> int:
+ value = config.get(name, default)
+ if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
+ comparator = 'non-negative' if minimum == 0 else 'positive'
+ raise ValueError(f'database.postgresql.{name} must be a {comparator} integer no greater than {maximum}')
+ return value
- host = postgresql_config.get('host', '127.0.0.1')
- port = postgresql_config.get('port', 5432)
- user = postgresql_config.get('user', 'postgres')
- password = postgresql_config.get('password', 'postgres')
- database = postgresql_config.get('database', 'postgres')
- engine_url = f'postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}'
- self.engine = sqlalchemy_asyncio.create_async_engine(engine_url)
+ async def initialize(self) -> None:
+ self._pool_timeouts_total = 0
+ postgresql_config = self.ap.instance_config.data.get('database', {}).get('postgresql', {})
+ if not isinstance(postgresql_config, dict):
+ raise ValueError('database.postgresql must be an object')
+ if self.url_override is not None:
+ engine_url = self.url_override
+ else:
+ explicit_url = postgresql_config.get('url')
+ if explicit_url:
+ if not isinstance(explicit_url, str):
+ raise ValueError('database.postgresql.url must be a string')
+ try:
+ engine_url = sqlalchemy.engine.make_url(explicit_url)
+ except Exception:
+ raise ValueError('database.postgresql.url is invalid') from None
+ try:
+ engine_url = normalize_asyncpg_url(engine_url)
+ except ValueError:
+ raise ValueError('database.postgresql.url must use valid PostgreSQL asyncpg options') from None
+ else:
+ engine_url = sqlalchemy.URL.create(
+ 'postgresql+asyncpg',
+ username=postgresql_config.get('user', 'postgres'),
+ password=postgresql_config.get('password', 'postgres'),
+ host=postgresql_config.get('host', '127.0.0.1'),
+ port=postgresql_config.get('port', 5432),
+ database=postgresql_config.get('database', 'postgres'),
+ )
+ self.pool_size = self._pool_integer(
+ postgresql_config,
+ 'pool_size',
+ 10,
+ minimum=1,
+ maximum=MAX_POOL_CONNECTIONS,
+ )
+ self.max_overflow = self._pool_integer(
+ postgresql_config,
+ 'max_overflow',
+ 10,
+ minimum=0,
+ maximum=MAX_POOL_CONNECTIONS,
+ )
+ if self.pool_size + self.max_overflow > MAX_POOL_CONNECTIONS:
+ raise ValueError(f'database.postgresql pool_size + max_overflow must not exceed {MAX_POOL_CONNECTIONS}')
+ self.pool_timeout_seconds = self._pool_integer(
+ postgresql_config,
+ 'pool_timeout_seconds',
+ 30,
+ minimum=1,
+ maximum=MAX_POOL_TIMEOUT_SECONDS,
+ )
+ self.pool_recycle_seconds = self._pool_integer(
+ postgresql_config,
+ 'pool_recycle_seconds',
+ 1800,
+ minimum=1,
+ maximum=MAX_POOL_RECYCLE_SECONDS,
+ )
+ connect_args = {}
+ self.statement_timeout_ms = 0
+ self.lock_timeout_ms = 0
+ self.idle_transaction_timeout_ms = 0
+ if self.persistence_mode == 'cloud_runtime':
+ self.statement_timeout_ms = self._pool_integer(
+ postgresql_config,
+ 'statement_timeout_ms',
+ 60_000,
+ minimum=1,
+ maximum=MAX_STATEMENT_TIMEOUT_MS,
+ )
+ self.lock_timeout_ms = self._pool_integer(
+ postgresql_config,
+ 'lock_timeout_ms',
+ 5_000,
+ minimum=1,
+ maximum=MAX_LOCK_TIMEOUT_MS,
+ )
+ self.idle_transaction_timeout_ms = self._pool_integer(
+ postgresql_config,
+ 'idle_in_transaction_session_timeout_ms',
+ 60_000,
+ minimum=1,
+ maximum=MAX_IDLE_TRANSACTION_TIMEOUT_MS,
+ )
+ connect_args = {
+ 'server_settings': {
+ 'statement_timeout': str(self.statement_timeout_ms),
+ 'lock_timeout': str(self.lock_timeout_ms),
+ 'idle_in_transaction_session_timeout': str(self.idle_transaction_timeout_ms),
+ }
+ }
+ self.engine = sqlalchemy_asyncio.create_async_engine(
+ engine_url,
+ pool_size=self.pool_size,
+ max_overflow=self.max_overflow,
+ pool_timeout=self.pool_timeout_seconds,
+ pool_recycle=self.pool_recycle_seconds,
+ pool_pre_ping=True,
+ **({'connect_args': connect_args} if connect_args else {}),
+ )
+
+ def resource_stats(self) -> dict[str, int]:
+ """Return aggregate pool gauges without exposing connection details."""
+
+ pool = self.engine.pool
+
+ def read(name: str) -> int:
+ method = getattr(pool, name, None)
+ if not callable(method):
+ return 0
+ try:
+ return int(method())
+ except Exception:
+ return 0
+
+ return {
+ 'configured_size': self.pool_size,
+ 'configured_max_overflow': self.max_overflow,
+ 'configured_capacity': self.pool_size + self.max_overflow,
+ 'statement_timeout_ms': self.statement_timeout_ms,
+ 'lock_timeout_ms': self.lock_timeout_ms,
+ 'idle_in_transaction_session_timeout_ms': self.idle_transaction_timeout_ms,
+ 'timeouts_total': self._pool_timeouts_total,
+ 'checked_in': read('checkedin'),
+ 'checked_out': read('checkedout'),
+ 'overflow': max(read('overflow'), 0),
+ }
+
+ def record_pool_timeout(self) -> None:
+ self._pool_timeouts_total += 1
diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py
index 7ad7b9683..9565dfdd0 100644
--- a/src/langbot/pkg/persistence/mgr.py
+++ b/src/langbot/pkg/persistence/mgr.py
@@ -1,24 +1,118 @@
from __future__ import annotations
import datetime
+import enum
+import sqlite3
import typing
+import contextvars
+import asyncio
+import contextlib
+import re
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
import sqlalchemy
-from . import database, migration
+from . import database, migration, sqlite_migration_backup
from ..entity.persistence import base, metadata, model as persistence_model
+from ..entity.persistence import workspace as persistence_workspace
from ..entity import persistence
from ..core import app
from ..utils import constants, importutil
from . import databases, migrations
+from .tenant_uow import (
+ ActivePersistenceScope,
+ ActiveScopedTransaction,
+ API_KEY_DISCOVERY_POLICY_NAME,
+ ACCOUNT_DISCOVERY_POLICY_NAME,
+ INSTANCE_DISCOVERY_POLICY_NAME,
+ INVITATION_DISCOVERY_POLICY_NAME,
+ LOCAL_DIRECTORY_WRITE_POLICY_NAME,
+ TENANT_POLICY_NAME,
+ TENANT_SETTING,
+ TENANT_TABLE_COLUMNS,
+ CrossScopeTransactionError,
+ DIRECTORY_INSTANCE_SETTING,
+ DIRECTORY_PROJECTED_TENANT_TABLES,
+ DIRECTORY_PROJECTION_POLICY_NAME,
+ DIRECTORY_PROJECTION_TABLE_COLUMNS,
+ PersistenceScope,
+ PersistenceScopeBoundary,
+ PersistenceScopeKind,
+ TenantScopeRequiredError,
+ TenantScopedAsyncSession,
+ TenantUnitOfWork,
+)
importutil.import_modules_in_pkg(databases)
importutil.import_modules_in_pkg(migrations)
importutil.import_modules_in_pkg(persistence)
+_ALEMBIC_TENANT_TABLES = {
+ 'workspaces',
+ 'workspace_memberships',
+ 'workspace_invitations',
+ 'workspace_execution_states',
+ 'workspace_metadata',
+ 'api_keys',
+ 'bots',
+ 'bot_admins',
+ 'binary_storages',
+ 'mcp_servers',
+ 'model_providers',
+ 'llm_models',
+ 'embedding_models',
+ 'rerank_models',
+ 'legacy_pipelines',
+ 'pipeline_run_records',
+ 'plugin_settings',
+ 'knowledge_bases',
+ 'knowledge_base_files',
+ 'knowledge_base_chunks',
+ 'webhooks',
+ 'monitoring_messages',
+ 'monitoring_llm_calls',
+ 'monitoring_tool_calls',
+ 'monitoring_sessions',
+ 'monitoring_errors',
+ 'monitoring_embedding_calls',
+ 'monitoring_feedback',
+ 'langbot_vectors',
+ 'directory_projection_states',
+ 'directory_projection_inbox',
+}
+
+_PRE_WORKSPACE_ALEMBIC_REVISIONS = {
+ '0001_baseline',
+ '0002_sample',
+ '0003_add_rerank_models',
+ '0004_add_mcp_readme',
+ '0005_add_llm_context_length',
+ '0006_normalize_mcp_remote_mode',
+ '0007_add_bot_admins',
+ '0008_mcp_resource_prefs',
+}
+_WORKSPACE_ALEMBIC_REVISION = '0009_workspace_tenancy'
+_RESOURCE_SCOPE_ALEMBIC_REVISION = '0010_scope_resources'
+_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
+_RELEASE_MIGRATION_ADVISORY_LOCK_ID = 0x4C414E47424F5432
+_PGVECTOR_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
+_RUNTIME_SCHEMA = 'public'
+_ALEMBIC_RUNTIME_TABLE = 'alembic_version'
+_RUNTIME_TABLE_PRIVILEGES = frozenset({'SELECT', 'INSERT', 'UPDATE', 'DELETE'})
+_RUNTIME_SEQUENCE_PRIVILEGES = frozenset({'USAGE', 'SELECT'})
+_RUNTIME_ALLOWED_EXTENSIONS = frozenset({'plpgsql', 'vector'})
+
+
+class PersistenceMode(enum.StrEnum):
+ """Trusted persistence startup mode selected by the process entrypoint."""
+
+ OSS_COMPAT = 'oss_compat'
+ CLOUD_RUNTIME = 'cloud_runtime'
+ RELEASE_MIGRATION = 'release_migration'
+
+
class PersistenceManager:
"""Persistence module manager"""
@@ -29,18 +123,121 @@ class PersistenceManager:
meta: sqlalchemy.MetaData
- def __init__(self, ap: app.Application):
+ def __init__(
+ self,
+ ap: app.Application,
+ *,
+ mode: PersistenceMode = PersistenceMode.OSS_COMPAT,
+ database_url: sqlalchemy.engine.URL | None = None,
+ ):
+ if not isinstance(mode, PersistenceMode):
+ raise TypeError('PersistenceManager mode must be a trusted PersistenceMode value')
+ if database_url is not None:
+ if mode != PersistenceMode.RELEASE_MIGRATION:
+ raise ValueError('A database URL override is reserved for the release migration process')
+ if not isinstance(database_url, sqlalchemy.engine.URL):
+ raise TypeError('Release migration database URL must be a parsed SQLAlchemy URL')
+ if database_url.drivername != 'postgresql+asyncpg':
+ raise ValueError('Release migration database URL must use postgresql+asyncpg')
self.ap = ap
self.meta = base.Base.metadata
+ self.mode = mode
+ self._database_url_override = database_url
+ self._active_transaction: contextvars.ContextVar[ActiveScopedTransaction | None] = contextvars.ContextVar(
+ f'langbot_persistence_scope_{id(self)}',
+ default=None,
+ )
+ self._active_scope: contextvars.ContextVar[ActivePersistenceScope | None] = contextvars.ContextVar(
+ f'langbot_persistence_boundary_{id(self)}',
+ default=None,
+ )
async def initialize(self):
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
self.ap.logger.info(f'Initializing database type: {database_type}...')
+ selected_manager: database.BaseDatabaseManager | None = None
for manager in database.preregistered_managers:
if manager.name == database_type:
- self.db = manager(self.ap)
+ self.db = manager(self.ap, url_override=self._database_url_override)
+ self.db.persistence_mode = self.mode.value
await self.db.initialize()
+ selected_manager = self.db
break
+ if selected_manager is None:
+ raise RuntimeError(f'Unsupported database type: {database_type!r}')
+
+ engine = self.get_db_engine()
+ if self.mode in {PersistenceMode.CLOUD_RUNTIME, PersistenceMode.RELEASE_MIGRATION}:
+ if engine.dialect.name != 'postgresql':
+ raise RuntimeError(f'{self.mode.value} persistence mode requires PostgreSQL')
+ await self._validate_postgres_public_schema_session()
+
+ if self.mode == PersistenceMode.CLOUD_RUNTIME:
+ await self._validate_cloud_runtime()
+ return
+
+ self._enable_sqlite_foreign_keys()
+ if self.mode == PersistenceMode.RELEASE_MIGRATION:
+ async with self._release_migration_lock():
+ await self._initialize_managed_schema()
+ await self._validate_release_schema()
+ return
+
+ await self._initialize_managed_schema()
+
+ if self.mode == PersistenceMode.OSS_COMPAT:
+ await self.write_space_model_providers()
+
+ async def shutdown(self) -> None:
+ """Dispose the owned database engine when initialization or runtime ends."""
+
+ db = getattr(self, 'db', None)
+ engine = getattr(db, 'engine', None)
+ if engine is not None:
+ await engine.dispose()
+
+ def get_resource_stats(self) -> dict[str, int]:
+ """Return database-manager-owned aggregate resource gauges."""
+
+ resource_stats = getattr(getattr(self, 'db', None), 'resource_stats', None)
+ if not callable(resource_stats):
+ return {}
+ try:
+ return resource_stats()
+ except Exception:
+ return {}
+
+ @contextlib.asynccontextmanager
+ async def _release_migration_lock(self) -> typing.AsyncIterator[None]:
+ """Serialize the complete PostgreSQL migration and validation window."""
+
+ engine = self.get_db_engine()
+ if engine.dialect.name != 'postgresql':
+ raise RuntimeError('Release migration advisory lock requires PostgreSQL')
+ async with engine.connect() as lock_connection:
+ acquired = await lock_connection.scalar(
+ sqlalchemy.text('SELECT pg_try_advisory_lock(:lock_id)'),
+ {'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
+ )
+ if acquired is not True:
+ raise RuntimeError('Another Cloud release migration already holds the advisory lock')
+ self.ap.logger.info('Acquired the Cloud release migration advisory lock.')
+ try:
+ yield
+ finally:
+ unlocked = await lock_connection.scalar(
+ sqlalchemy.text('SELECT pg_advisory_unlock(:lock_id)'),
+ {'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
+ )
+ if unlocked is not True:
+ raise RuntimeError('Cloud release migration advisory lock ownership was lost')
+
+ async def _initialize_managed_schema(self) -> None:
+ """Create or migrate schema only in OSS and release processes."""
+ from . import alembic_runner
+
+ engine = self.get_db_engine()
+ release_bootstrap = self.mode == PersistenceMode.RELEASE_MIGRATION and await self._is_empty_schema()
await self.create_tables()
@@ -76,15 +273,56 @@ class PersistenceManager:
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
- # Run Alembic migrations (new migration system)
- await self._run_alembic_migrations()
+ if engine.dialect.name == 'postgresql':
+ current_revision = await alembic_runner.get_alembic_current(engine)
+ head_revision = alembic_runner.get_alembic_head()
- await self.write_space_model_providers()
+ if release_bootstrap:
+ # Base.metadata represents the complete 0010 schema. An empty
+ # Cloud database has no legacy data to transform and must not
+ # run 0009's OSS singleton Workspace bootstrap.
+ if current_revision is not None:
+ raise RuntimeError('Empty PostgreSQL release bootstrap unexpectedly has an Alembic revision')
+ await alembic_runner.run_alembic_stamp(engine, _RESOURCE_SCOPE_ALEMBIC_REVISION)
+ elif current_revision != head_revision:
+ # A legacy database may not contain tenant tables introduced
+ # by a newer release. Upgrade the account/resource contract,
+ # then create deferred tables before the RLS migration runs.
+ await self._run_alembic_migrations(_RESOURCE_SCOPE_ALEMBIC_REVISION)
+ await self.create_tables()
+
+ await self._run_alembic_migrations()
+ await self._validate_postgres_tenant_schema(validate_runtime_role=False)
+
+ if self.mode == PersistenceMode.OSS_COMPAT:
+ await self._install_oss_postgres_tenant_scope()
+ else:
+ await self._run_alembic_migrations()
+
+ # SQLite keeps the historical post-migration create_all pass. New
+ # tenant tables are deferred until the Workspace/account schema is
+ # compatible with their foreign keys.
+ await self.create_tables()
async def create_tables(self):
- # create tables
async with self.get_db_engine().connect() as conn:
- await conn.run_sync(self.meta.create_all)
+
+ def create_compatible_tables(sync_conn: sqlalchemy.Connection) -> None:
+ inspector = sqlalchemy.inspect(sync_conn)
+ existing_tables = set(inspector.get_table_names())
+ legacy_users = 'users' in existing_tables and (
+ 'uuid' not in {column['name'] for column in inspector.get_columns('users')}
+ or 'workspaces' not in existing_tables
+ )
+ # On a legacy installation, resource tables already exist
+ # without workspace_uuid and Workspace itself references the
+ # account UUID introduced by 0009. Alembic must expand those
+ # tables before SQLAlchemy may create any new tenant table.
+ excluded_tables = _ALEMBIC_TENANT_TABLES if legacy_users else set()
+ tables_to_create = [table for table in self.meta.sorted_tables if table.name not in excluded_tables]
+ self.meta.create_all(sync_conn, tables=tables_to_create)
+
+ await conn.run_sync(create_compatible_tables)
await conn.commit()
@@ -101,15 +339,1314 @@ class PersistenceManager:
if row is None:
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
+ await self._ensure_instance_uuid_metadata()
+
+ async def _is_empty_schema(self) -> bool:
+ async with self.get_db_engine().connect() as conn:
+ table_names = await conn.run_sync(lambda sync_conn: set(sqlalchemy.inspect(sync_conn).get_table_names()))
+ return not table_names
+
+ async def _install_oss_postgres_tenant_scope(self) -> None:
+ """Default every OSS PostgreSQL transaction to its singleton Workspace."""
+ if getattr(self, '_oss_tenant_scope_listener_installed', False):
+ return
+
+ async with self.get_db_engine().connect() as conn:
+ workspace_uuid = await conn.scalar(
+ sqlalchemy.select(metadata.Metadata.value).where(
+ metadata.Metadata.key == _OSS_WORKSPACE_METADATA_KEY,
+ )
+ )
+ if not isinstance(workspace_uuid, str) or not workspace_uuid.strip():
+ raise RuntimeError(
+ 'PostgreSQL OSS mode requires exactly one local Workspace recorded before tenant RLS is enabled'
+ )
+ workspace_uuid = workspace_uuid.strip()
+
+ def set_oss_tenant_scope(conn: sqlalchemy.Connection) -> None:
+ conn.execute(
+ sqlalchemy.text(f"SELECT set_config('{TENANT_SETTING}', :workspace_uuid, true)"),
+ {'workspace_uuid': workspace_uuid},
+ )
+
+ sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
+ self._oss_tenant_scope_listener_installed = True
+
+ def _enable_sqlite_foreign_keys(self) -> None:
+ """Enable SQLite FK enforcement for every pooled runtime connection."""
+ engine = self.get_db_engine()
+ if engine.dialect.name != 'sqlite':
+ return
+ if getattr(self, '_sqlite_fk_listener_installed', False):
+ return
+
+ def set_sqlite_pragma(dbapi_connection, _connection_record) -> None:
+ # aiosqlite exposes the normal sqlite cursor API through its
+ # SQLAlchemy adapter. Guard the direct sqlite type too for tests.
+ if isinstance(dbapi_connection, sqlite3.Connection) or hasattr(dbapi_connection, 'cursor'):
+ cursor = dbapi_connection.cursor()
+ cursor.execute('PRAGMA foreign_keys=ON')
+ cursor.close()
+
+ sqlalchemy.event.listen(engine.sync_engine, 'connect', set_sqlite_pragma)
+ self._sqlite_fk_listener_installed = True
+
+ async def _ensure_instance_uuid_metadata(self) -> None:
+ """Persist the runtime instance identifier before tenant migrations run."""
+ runtime_instance_uuid = constants.instance_id.strip()
+ if not runtime_instance_uuid:
+ raise RuntimeError('LangBot instance UUID is empty before persistence initialization')
+
+ result = await self.execute_async(
+ sqlalchemy.select(metadata.Metadata.value).where(metadata.Metadata.key == 'instance_uuid')
+ )
+ persisted_instance_uuid = result.scalar_one_or_none()
+
+ if persisted_instance_uuid is None:
+ await self.execute_async(
+ sqlalchemy.insert(metadata.Metadata).values(key='instance_uuid', value=runtime_instance_uuid)
+ )
+ return
+
+ if persisted_instance_uuid != runtime_instance_uuid:
+ raise RuntimeError(
+ 'LangBot instance UUID does not match the value bound to this database: '
+ f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
+ )
+
+ async def _validate_cloud_runtime(self) -> None:
+ """Validate a release-prepared schema without performing any DDL."""
+ from . import alembic_runner
+
+ engine = self.get_db_engine()
+ current_revision = await alembic_runner.get_alembic_current(engine)
+ head_revision = alembic_runner.get_alembic_head()
+ if current_revision != head_revision:
+ raise RuntimeError(
+ f'Cloud runtime database schema is not at the release head: {current_revision!r} != {head_revision!r}'
+ )
+
+ runtime_instance_uuid = constants.instance_id.strip()
+ if not runtime_instance_uuid:
+ raise RuntimeError('LangBot instance UUID is empty before Cloud persistence validation')
+ async with engine.connect() as conn:
+ persisted_instance_uuid = await conn.scalar(
+ sqlalchemy.select(metadata.Metadata.value).where(metadata.Metadata.key == 'instance_uuid')
+ )
+ if persisted_instance_uuid is None:
+ raise RuntimeError("Cloud runtime database is missing metadata['instance_uuid']")
+ if persisted_instance_uuid != runtime_instance_uuid:
+ raise RuntimeError(
+ 'LangBot instance UUID does not match the value bound to this database: '
+ f'{runtime_instance_uuid!r} != {persisted_instance_uuid!r}'
+ )
+
+ await self._validate_postgres_tenant_schema(validate_runtime_role=True)
+ await self._validate_postgres_pgvector_schema()
+ await self._validate_configured_runtime_postgres_role(
+ require_grants=True,
+ require_current_user=True,
+ )
+
+ async def _validate_postgres_public_schema_session(self) -> None:
+ """Pin the first Cloud release to one explicit PostgreSQL schema."""
+
+ async with self.get_db_engine().connect() as conn:
+ schema_state = (
+ (
+ await conn.execute(
+ sqlalchemy.text(
+ """
+ SELECT
+ current_schema() AS current_schema,
+ current_schemas(false) AS effective_schemas,
+ current_setting('session_replication_role') AS session_replication_role,
+ current_setting('row_security') AS row_security,
+ current_setting('lo_compat_privileges') AS lo_compat_privileges
+ """
+ )
+ )
+ )
+ .mappings()
+ .one()
+ )
+ if schema_state['current_schema'] != _RUNTIME_SCHEMA or list(schema_state['effective_schemas']) != [
+ _RUNTIME_SCHEMA
+ ]:
+ raise RuntimeError('Cloud PostgreSQL search_path must resolve exclusively to the public business schema')
+ if schema_state['session_replication_role'] != 'origin':
+ raise RuntimeError('Cloud PostgreSQL session_replication_role must be origin')
+ if schema_state['row_security'] != 'on':
+ raise RuntimeError('Cloud PostgreSQL row_security must be on')
+ if schema_state['lo_compat_privileges'] != 'off':
+ raise RuntimeError('Cloud PostgreSQL lo_compat_privileges must be off')
+
+ async def _validate_release_schema(self) -> None:
+ """Verify the complete Cloud business schema before releasing the lock."""
+ from . import alembic_runner
+
+ engine = self.get_db_engine()
+ current_revision = await alembic_runner.get_alembic_current(engine)
+ head_revision = alembic_runner.get_alembic_head()
+ if current_revision != head_revision:
+ raise RuntimeError(
+ 'Cloud release migration did not reach the exact Alembic head: '
+ f'{current_revision!r} != {head_revision!r}'
+ )
+ await self._validate_postgres_tenant_schema(validate_runtime_role=False)
+ await self._validate_postgres_pgvector_schema()
+ if self._database_url_override is not None:
+ # The one-shot operator process never authenticates with the
+ # runtime password. It validates the configured role before
+ # granting access, provisions only the current business objects,
+ # then validates the resulting ACLs before declaring the release
+ # deployable.
+ await self._grant_configured_runtime_postgres_role_privileges()
+ await self._validate_configured_runtime_postgres_role(require_grants=True)
+
+ def _configured_runtime_postgres_role(self) -> str:
+ postgresql_config = self.ap.instance_config.data.get('database', {}).get('postgresql')
+ if not isinstance(postgresql_config, dict):
+ raise RuntimeError('Cloud runtime PostgreSQL configuration is missing')
+ explicit_url = postgresql_config.get('url')
+ if explicit_url:
+ if not isinstance(explicit_url, str):
+ raise RuntimeError('Cloud runtime PostgreSQL URL must be a string')
+ try:
+ runtime_url = sqlalchemy.engine.make_url(explicit_url)
+ except Exception:
+ raise RuntimeError('Cloud runtime PostgreSQL URL is invalid') from None
+ if runtime_url.drivername not in {'postgresql', 'postgresql+asyncpg'}:
+ raise RuntimeError('Cloud runtime database URL must use PostgreSQL')
+ runtime_role = (runtime_url.username or '').strip()
+ else:
+ runtime_role = str(postgresql_config.get('user', 'postgres') or '').strip()
+ if not runtime_role:
+ raise RuntimeError('Cloud runtime PostgreSQL role is missing')
+ return runtime_role
+
+ def _runtime_business_table_names(self) -> tuple[str, ...]:
+ """Return release-managed application tables, excluding migration metadata."""
+
+ return tuple(sorted({table.name for table in self.meta.tables.values()} | {'langbot_vectors'}))
+
+ def _runtime_table_privilege_allowlist(self) -> dict[str, frozenset[str]]:
+ return {
+ **{table_name: _RUNTIME_TABLE_PRIVILEGES for table_name in self._runtime_business_table_names()},
+ _ALEMBIC_RUNTIME_TABLE: frozenset({'SELECT'}),
+ }
+
+ async def _runtime_business_sequence_names(
+ self,
+ conn: sqlalchemy_asyncio.AsyncConnection,
+ table_names: tuple[str, ...],
+ ) -> tuple[str, ...]:
+ sequence_query = sqlalchemy.text(
+ """
+ SELECT DISTINCT sequence.relname
+ FROM pg_class sequence
+ JOIN pg_namespace sequence_namespace ON sequence_namespace.oid = sequence.relnamespace
+ JOIN pg_depend dependency
+ ON dependency.classid = 'pg_class'::regclass
+ AND dependency.objid = sequence.oid
+ AND dependency.refclassid = 'pg_class'::regclass
+ AND dependency.deptype IN ('a', 'i')
+ JOIN pg_class business_table ON business_table.oid = dependency.refobjid
+ JOIN pg_namespace table_namespace ON table_namespace.oid = business_table.relnamespace
+ WHERE sequence.relkind = 'S'
+ AND sequence_namespace.nspname = 'public'
+ AND table_namespace.nspname = 'public'
+ AND business_table.relname IN :table_names
+ ORDER BY sequence.relname
+ """
+ ).bindparams(sqlalchemy.bindparam('table_names', expanding=True))
+ return tuple((await conn.execute(sequence_query, {'table_names': table_names})).scalars().all())
+
+ async def _grant_configured_runtime_postgres_role_privileges(self) -> None:
+ """Provision the nonprivileged runtime role using the operator connection."""
+
+ await self._validate_configured_runtime_postgres_role(require_grants=False)
+ runtime_role = self._configured_runtime_postgres_role()
+ table_names = self._runtime_business_table_names()
+ relation_allowlist = self._runtime_table_privilege_allowlist()
+ engine = self.get_db_engine()
+ quote = engine.dialect.identifier_preparer.quote
+
+ async with engine.begin() as conn:
+ database_name = await conn.scalar(sqlalchemy.text('SELECT current_database()'))
+ if not isinstance(database_name, str):
+ raise RuntimeError('Cloud release migration could not resolve its PostgreSQL database')
+
+ existing_tables = set(
+ (
+ await conn.execute(
+ sqlalchemy.text(
+ """
+ SELECT c.relname
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relkind IN ('r', 'p')
+ AND c.relname IN :relation_names
+ """
+ ).bindparams(sqlalchemy.bindparam('relation_names', expanding=True)),
+ {'relation_names': tuple(relation_allowlist)},
+ )
+ )
+ .scalars()
+ .all()
+ )
+ missing_tables = sorted(set(relation_allowlist) - existing_tables)
+ if missing_tables:
+ raise RuntimeError(f'Cloud runtime allowlisted tables are missing: {missing_tables!r}')
+
+ sequence_names = await self._runtime_business_sequence_names(conn, table_names)
+ quoted_role = quote(runtime_role)
+ quoted_schema = quote(_RUNTIME_SCHEMA)
+ quoted_tables = ', '.join(f'{quoted_schema}.{quote(table_name)}' for table_name in table_names)
+
+ await conn.execute(sqlalchemy.text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quoted_role}'))
+ await conn.execute(sqlalchemy.text(f'GRANT USAGE ON SCHEMA {quoted_schema} TO {quoted_role}'))
+ await conn.execute(
+ sqlalchemy.text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quoted_role}')
+ )
+ await conn.execute(
+ sqlalchemy.text(
+ f'GRANT SELECT ON TABLE {quoted_schema}.{quote(_ALEMBIC_RUNTIME_TABLE)} TO {quoted_role}'
+ )
+ )
+ if sequence_names:
+ quoted_sequences = ', '.join(
+ f'{quoted_schema}.{quote(sequence_name)}' for sequence_name in sequence_names
+ )
+ await conn.execute(
+ sqlalchemy.text(f'GRANT USAGE, SELECT ON SEQUENCE {quoted_sequences} TO {quoted_role}')
+ )
+
+ async def _validate_configured_runtime_postgres_role(
+ self,
+ *,
+ require_grants: bool = True,
+ require_current_user: bool = False,
+ ) -> None:
+ """Validate the configured least-privilege role using operator catalogs."""
+
+ runtime_role = self._configured_runtime_postgres_role()
+ table_names = self._runtime_business_table_names()
+ relation_allowlist = self._runtime_table_privilege_allowlist()
+
+ role_query = sqlalchemy.text(
+ """
+ SELECT
+ oid,
+ rolcanlogin,
+ rolsuper,
+ rolbypassrls,
+ rolcreatedb,
+ rolcreaterole,
+ rolreplication
+ FROM pg_roles
+ WHERE rolname = :runtime_role
+ """
+ )
+ membership_query = sqlalchemy.text(
+ """
+ SELECT
+ granted_role.rolname AS granted_role,
+ member_role.rolname AS member_role,
+ grantor_role.rolname AS grantor_role,
+ membership.admin_option,
+ membership.inherit_option,
+ membership.set_option
+ FROM pg_auth_members membership
+ JOIN pg_roles granted_role ON granted_role.oid = membership.roleid
+ JOIN pg_roles member_role ON member_role.oid = membership.member
+ JOIN pg_roles grantor_role ON grantor_role.oid = membership.grantor
+ WHERE membership.roleid = :runtime_oid
+ OR membership.member = :runtime_oid
+ OR membership.grantor = :runtime_oid
+ ORDER BY granted_role.rolname, member_role.rolname
+ """
+ )
+ persistent_settings_query = sqlalchemy.text(
+ """
+ SELECT
+ setting.setdatabase,
+ setting.setrole,
+ lower(split_part(config.value, '=', 1)) AS parameter_name
+ FROM pg_db_role_setting setting
+ JOIN pg_database database ON database.datname = current_database()
+ CROSS JOIN LATERAL unnest(setting.setconfig) config(value)
+ WHERE (
+ (
+ setting.setrole = :runtime_oid
+ AND setting.setdatabase IN (0, database.oid)
+ )
+ OR (setting.setrole = 0 AND setting.setdatabase = database.oid)
+ )
+ ORDER BY setting.setdatabase, setting.setrole, parameter_name
+ """
+ )
+ owned_objects_query = sqlalchemy.text(
+ """
+ SELECT c.relname, c.relkind::text AS relkind
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
+ AND c.relowner = :runtime_oid
+ ORDER BY c.relname
+ """
+ )
+ database_schema_owner_query = sqlalchemy.text(
+ """
+ SELECT
+ pg_get_userbyid(database.datdba) = :runtime_role AS owns_database,
+ pg_get_userbyid(namespace.nspowner) = :runtime_role AS owns_schema
+ FROM pg_database database
+ CROSS JOIN pg_namespace namespace
+ WHERE database.datname = current_database()
+ AND namespace.nspname = 'public'
+ """
+ )
+ other_schema_privileges_query = sqlalchemy.text(
+ """
+ SELECT
+ namespace.nspname,
+ namespace.nspowner = :runtime_oid AS owned_by_runtime,
+ has_schema_privilege(:runtime_role, namespace.oid, 'USAGE') AS can_use,
+ has_schema_privilege(:runtime_role, namespace.oid, 'CREATE') AS can_create
+ FROM pg_namespace namespace
+ WHERE namespace.nspname <> 'public'
+ AND namespace.nspname <> 'information_schema'
+ AND left(namespace.nspname, 3) <> 'pg_'
+ ORDER BY namespace.nspname
+ """
+ )
+ database_acl_query = sqlalchemy.text(
+ """
+ SELECT acl.privilege_type, acl.is_grantable
+ FROM pg_database database
+ CROSS JOIN LATERAL aclexplode(database.datacl) acl
+ WHERE database.datname = current_database()
+ AND acl.grantee = :runtime_oid
+ ORDER BY acl.privilege_type
+ """
+ )
+ schema_acl_query = sqlalchemy.text(
+ """
+ SELECT acl.privilege_type, acl.is_grantable
+ FROM pg_namespace namespace
+ CROSS JOIN LATERAL aclexplode(namespace.nspacl) acl
+ WHERE namespace.nspname = 'public'
+ AND acl.grantee = :runtime_oid
+ ORDER BY acl.privilege_type
+ """
+ )
+ object_acl_query = sqlalchemy.text(
+ """
+ SELECT
+ c.relname,
+ c.relkind::text AS relkind,
+ acl.privilege_type,
+ acl.is_grantable
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ CROSS JOIN LATERAL aclexplode(c.relacl) acl
+ WHERE n.nspname = 'public'
+ AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
+ AND acl.grantee = :runtime_oid
+ ORDER BY c.relname, acl.privilege_type
+ """
+ )
+ table_privileges_query = sqlalchemy.text(
+ """
+ SELECT
+ c.relname,
+ has_table_privilege(:runtime_role, c.oid, 'SELECT') AS can_select,
+ has_table_privilege(:runtime_role, c.oid, 'INSERT') AS can_insert,
+ has_table_privilege(:runtime_role, c.oid, 'UPDATE') AS can_update,
+ has_table_privilege(:runtime_role, c.oid, 'DELETE') AS can_delete,
+ has_table_privilege(:runtime_role, c.oid, 'TRUNCATE') AS can_truncate,
+ has_table_privilege(:runtime_role, c.oid, 'REFERENCES') AS can_reference,
+ has_table_privilege(:runtime_role, c.oid, 'TRIGGER') AS can_trigger,
+ has_table_privilege(:runtime_role, c.oid, 'SELECT WITH GRANT OPTION') AS can_grant_select,
+ has_table_privilege(:runtime_role, c.oid, 'INSERT WITH GRANT OPTION') AS can_grant_insert,
+ has_table_privilege(:runtime_role, c.oid, 'UPDATE WITH GRANT OPTION') AS can_grant_update,
+ has_table_privilege(:runtime_role, c.oid, 'DELETE WITH GRANT OPTION') AS can_grant_delete,
+ has_table_privilege(:runtime_role, c.oid, 'TRUNCATE WITH GRANT OPTION') AS can_grant_truncate,
+ has_table_privilege(:runtime_role, c.oid, 'REFERENCES WITH GRANT OPTION') AS can_grant_reference,
+ has_table_privilege(:runtime_role, c.oid, 'TRIGGER WITH GRANT OPTION') AS can_grant_trigger
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
+ ORDER BY c.relname
+ """
+ )
+ sequence_privileges_query = sqlalchemy.text(
+ """
+ SELECT
+ c.relname,
+ has_sequence_privilege(:runtime_role, c.oid, 'USAGE') AS can_use,
+ has_sequence_privilege(:runtime_role, c.oid, 'SELECT') AS can_select,
+ has_sequence_privilege(:runtime_role, c.oid, 'UPDATE') AS can_update,
+ has_sequence_privilege(:runtime_role, c.oid, 'USAGE WITH GRANT OPTION') AS can_grant_use,
+ has_sequence_privilege(:runtime_role, c.oid, 'SELECT WITH GRANT OPTION') AS can_grant_select,
+ has_sequence_privilege(:runtime_role, c.oid, 'UPDATE WITH GRANT OPTION') AS can_grant_update
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relkind = 'S'
+ ORDER BY c.relname
+ """
+ )
+ ddl_privileges_query = sqlalchemy.text(
+ """
+ SELECT
+ current_user AS connected_role,
+ current_schema() AS current_schema,
+ current_schemas(false) AS effective_schemas,
+ has_database_privilege(:runtime_role, current_database(), 'CONNECT') AS can_connect,
+ has_database_privilege(
+ :runtime_role,
+ current_database(),
+ 'CONNECT WITH GRANT OPTION'
+ ) AS can_grant_connect,
+ has_database_privilege(:runtime_role, current_database(), 'CREATE') AS can_create_database_objects,
+ has_database_privilege(:runtime_role, current_database(), 'TEMP') AS can_create_temp_objects,
+ has_schema_privilege(:runtime_role, 'public', 'USAGE') AS can_use_schema,
+ has_schema_privilege(
+ :runtime_role,
+ 'public',
+ 'USAGE WITH GRANT OPTION'
+ ) AS can_grant_schema_usage,
+ has_schema_privilege(:runtime_role, 'public', 'CREATE') AS can_create_schema_objects
+ """
+ )
+ existing_tables_query = sqlalchemy.text(
+ """
+ SELECT c.relname
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relkind IN ('r', 'p')
+ AND c.relname IN :relation_names
+ ORDER BY c.relname
+ """
+ ).bindparams(sqlalchemy.bindparam('relation_names', expanding=True))
+ column_acl_query = sqlalchemy.text(
+ """
+ SELECT c.relname, attribute.attname, acl.privilege_type, acl.is_grantable
+ FROM pg_attribute attribute
+ JOIN pg_class c ON c.oid = attribute.attrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ CROSS JOIN LATERAL aclexplode(attribute.attacl) acl
+ WHERE n.nspname = 'public'
+ AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
+ AND attribute.attnum > 0
+ AND NOT attribute.attisdropped
+ AND acl.grantee IN (0, :runtime_oid)
+ ORDER BY c.relname, attribute.attname, acl.privilege_type
+ """
+ )
+ routine_acl_query = sqlalchemy.text(
+ """
+ SELECT
+ namespace.nspname,
+ procedure.oid::regprocedure::text AS routine,
+ acl.grantee,
+ acl.privilege_type,
+ acl.is_grantable
+ FROM pg_proc procedure
+ JOIN pg_namespace namespace ON namespace.oid = procedure.pronamespace
+ CROSS JOIN LATERAL aclexplode(procedure.proacl) acl
+ WHERE acl.grantee IN (0, :runtime_oid)
+ ORDER BY namespace.nspname, routine, acl.grantee
+ """
+ )
+ owned_routines_query = sqlalchemy.text(
+ """
+ SELECT namespace.nspname, procedure.oid::regprocedure::text AS routine
+ FROM pg_proc procedure
+ JOIN pg_namespace namespace ON namespace.oid = procedure.pronamespace
+ WHERE procedure.proowner = :runtime_oid
+ ORDER BY namespace.nspname, routine
+ """
+ )
+ parameter_acl_query = sqlalchemy.text(
+ """
+ SELECT
+ parameter.parname,
+ acl.grantee,
+ acl.privilege_type,
+ acl.is_grantable
+ FROM pg_parameter_acl parameter
+ CROSS JOIN LATERAL aclexplode(parameter.paracl) acl
+ WHERE acl.grantee IN (0, :runtime_oid)
+ ORDER BY parameter.parname, acl.grantee, acl.privilege_type
+ """
+ )
+ extensions_query = sqlalchemy.text(
+ """
+ SELECT extension.extname, extension.extowner = :runtime_oid AS owned_by_runtime
+ FROM pg_extension extension
+ ORDER BY extension.extname
+ """
+ )
+ # pg_user_mapping is intentionally unreadable by ordinary roles because
+ # its options may contain credentials. pg_user_mappings is the public
+ # view: it exposes all identities while redacting inaccessible options.
+ # Never select umoptions into the validator or its diagnostics.
+ foreign_objects_query = sqlalchemy.text(
+ """
+ SELECT 'foreign data wrapper' AS object_kind, wrapper.fdwname AS object_name
+ FROM pg_foreign_data_wrapper wrapper
+ UNION ALL
+ SELECT 'foreign server', server.srvname
+ FROM pg_foreign_server server
+ UNION ALL
+ SELECT 'user mapping', mapping.srvname || ':' || mapping.usename
+ FROM pg_catalog.pg_user_mappings mapping
+ ORDER BY object_kind, object_name
+ """
+ )
+ security_definer_query = sqlalchemy.text(
+ """
+ SELECT namespace.nspname, procedure.oid::regprocedure::text AS routine
+ FROM pg_proc procedure
+ JOIN pg_namespace namespace ON namespace.oid = procedure.pronamespace
+ WHERE procedure.prosecdef
+ AND has_schema_privilege(:runtime_role, namespace.oid, 'USAGE')
+ AND has_function_privilege(:runtime_role, procedure.oid, 'EXECUTE')
+ ORDER BY namespace.nspname, routine
+ """
+ )
+
+ async with self.get_db_engine().connect() as conn:
+ role = (await conn.execute(role_query, {'runtime_role': runtime_role})).mappings().one_or_none()
+ if role is None:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL role does not exist')
+ if role['rolcanlogin'] is not True:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL role must have LOGIN')
+ if role['rolsuper'] is True or role['rolbypassrls'] is True:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL role must not be superuser or BYPASSRLS')
+ if role['rolcreatedb'] is True or role['rolcreaterole'] is True or role['rolreplication'] is True:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role must not have CREATEDB, CREATEROLE, or REPLICATION'
+ )
+
+ query_parameters = {
+ 'runtime_role': runtime_role,
+ 'runtime_oid': role['oid'],
+ 'table_names': table_names,
+ 'relation_names': tuple(relation_allowlist),
+ }
+ existing_tables = set((await conn.execute(existing_tables_query, query_parameters)).scalars().all())
+ missing_tables = sorted(set(relation_allowlist) - existing_tables)
+ if missing_tables:
+ raise RuntimeError(f'Cloud runtime allowlisted tables are missing: {missing_tables!r}')
+
+ sequence_names = set(await self._runtime_business_sequence_names(conn, table_names))
+ owned_objects = (await conn.execute(owned_objects_query, query_parameters)).mappings().all()
+ ownership = (await conn.execute(database_schema_owner_query, query_parameters)).mappings().one()
+ memberships = (await conn.execute(membership_query, query_parameters)).mappings().all()
+ persistent_settings = (await conn.execute(persistent_settings_query, query_parameters)).mappings().all()
+ other_schema_privileges = (
+ (await conn.execute(other_schema_privileges_query, query_parameters)).mappings().all()
+ )
+ database_acl = (await conn.execute(database_acl_query, query_parameters)).mappings().all()
+ schema_acl = (await conn.execute(schema_acl_query, query_parameters)).mappings().all()
+ object_acl = (await conn.execute(object_acl_query, query_parameters)).mappings().all()
+ table_privileges = (await conn.execute(table_privileges_query, query_parameters)).mappings().all()
+ sequence_privileges = (await conn.execute(sequence_privileges_query, query_parameters)).mappings().all()
+ ddl_privileges = (await conn.execute(ddl_privileges_query, query_parameters)).mappings().one()
+ column_acl = (await conn.execute(column_acl_query, query_parameters)).mappings().all()
+ routine_acl = (await conn.execute(routine_acl_query, query_parameters)).mappings().all()
+ owned_routines = (await conn.execute(owned_routines_query, query_parameters)).mappings().all()
+ parameter_acl = (await conn.execute(parameter_acl_query, query_parameters)).mappings().all()
+ extensions = (await conn.execute(extensions_query, query_parameters)).mappings().all()
+ foreign_objects = (await conn.execute(foreign_objects_query)).mappings().all()
+ security_definers = (await conn.execute(security_definer_query, query_parameters)).mappings().all()
+
+ if require_current_user and ddl_privileges['connected_role'] != runtime_role:
+ raise RuntimeError('Cloud runtime PostgreSQL connection user does not match the configured runtime role')
+ if ddl_privileges['current_schema'] != _RUNTIME_SCHEMA or list(ddl_privileges['effective_schemas']) != [
+ _RUNTIME_SCHEMA
+ ]:
+ raise RuntimeError('Cloud PostgreSQL search_path must resolve exclusively to the public business schema')
+ if memberships:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role must not participate in role memberships: '
+ f'{[dict(row) for row in memberships]!r}'
+ )
+ if persistent_settings:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role/current database must not define persistent session '
+ 'overrides: '
+ f'{[(row["parameter_name"], row["setdatabase"], row["setrole"]) for row in persistent_settings]!r}'
+ )
+
+ extension_names = {str(row['extname']) for row in extensions}
+ runtime_owned_extensions = sorted(str(row['extname']) for row in extensions if row['owned_by_runtime'] is True)
+ if runtime_owned_extensions:
+ raise RuntimeError(
+ f'Configured Cloud runtime PostgreSQL role must not own extensions: {runtime_owned_extensions!r}'
+ )
+ unexpected_extensions = sorted(extension_names - _RUNTIME_ALLOWED_EXTENSIONS)
+ if 'vector' not in extension_names or unexpected_extensions:
+ raise RuntimeError(
+ 'Cloud business PostgreSQL extensions must include vector and be limited to plpgsql/vector: '
+ f'{sorted(extension_names)!r}'
+ )
+ if foreign_objects:
+ raise RuntimeError(
+ 'Cloud business PostgreSQL database must not contain foreign data wrappers, servers, or user '
+ f'mappings: {[(row["object_kind"], row["object_name"]) for row in foreign_objects]!r}'
+ )
+
+ owned_tables = sorted(row['relname'] for row in owned_objects if row['relkind'] in {'r', 'p', 'v', 'm', 'f'})
+ owned_sequences = sorted(row['relname'] for row in owned_objects if row['relkind'] == 'S')
+ if owned_tables:
+ raise RuntimeError(f'Configured Cloud runtime PostgreSQL role owns tenant tables: {owned_tables!r}')
+ if owned_sequences:
+ raise RuntimeError(f'Configured Cloud runtime PostgreSQL role owns business sequences: {owned_sequences!r}')
+ if ownership['owns_database'] is True or ownership['owns_schema'] is True:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL role must not own the runtime database or schema')
+ unsafe_other_schemas = sorted(
+ row['nspname']
+ for row in other_schema_privileges
+ if row['owned_by_runtime'] is True or row['can_use'] is True or row['can_create'] is True
+ )
+ if unsafe_other_schemas:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role can access or own non-business schemas: '
+ f'{unsafe_other_schemas!r}'
+ )
+
+ if ddl_privileges['can_connect'] is not True and require_grants:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL database CONNECT grant is incomplete')
+ if ddl_privileges['can_grant_connect'] is True:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL database CONNECT has effective GRANT OPTION')
+ if ddl_privileges['can_create_database_objects'] is True:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL role must not have database CREATE')
+ # PostgreSQL commonly grants TEMP to PUBLIC when a database is created.
+ # The first Cloud release tolerates that inherited compatibility
+ # privilege, but never grants TEMP directly to the runtime role.
+ if ddl_privileges['can_create_schema_objects'] is True:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL role must not have schema CREATE')
+ if ddl_privileges['can_use_schema'] is not True and require_grants:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL public schema USAGE grant is incomplete')
+ if ddl_privileges['can_grant_schema_usage'] is True:
+ raise RuntimeError('Configured Cloud runtime PostgreSQL schema USAGE has effective GRANT OPTION')
+ if column_acl:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role has forbidden column-level ACLs: '
+ f'{[(row["relname"], row["attname"]) for row in column_acl]!r}'
+ )
+ if owned_routines:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role must not own routines: '
+ f'{[(row["nspname"], row["routine"]) for row in owned_routines]!r}'
+ )
+ if routine_acl:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role or PUBLIC has forbidden explicit EXECUTE privileges on '
+ 'routines: '
+ f'{[(row["nspname"], row["routine"], "PUBLIC" if row["grantee"] == 0 else runtime_role, row["is_grantable"]) for row in routine_acl]!r}'
+ )
+ if parameter_acl:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role or PUBLIC has forbidden explicit SET or ALTER SYSTEM '
+ 'parameter privileges: '
+ f'{[(row["parname"], "PUBLIC" if row["grantee"] == 0 else runtime_role, row["privilege_type"], row["is_grantable"]) for row in parameter_acl]!r}'
+ )
+ if security_definers:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role can execute SECURITY DEFINER routines: '
+ f'{[(row["nspname"], row["routine"]) for row in security_definers]!r}'
+ )
+
+ def validate_direct_acl(
+ rows: typing.Iterable[typing.Mapping[str, typing.Any]],
+ expected: frozenset[str],
+ label: str,
+ ) -> None:
+ actual = {str(row['privilege_type']) for row in rows}
+ grantable = sorted(str(row['privilege_type']) for row in rows if row['is_grantable'] is True)
+ if grantable:
+ raise RuntimeError(
+ f'Configured Cloud runtime PostgreSQL {label} grants have GRANT OPTION: {grantable!r}'
+ )
+ unexpected = sorted(actual - expected)
+ if unexpected:
+ raise RuntimeError(
+ f'Configured Cloud runtime PostgreSQL {label} grants are overprivileged: {unexpected!r}'
+ )
+ if require_grants and actual != expected:
+ missing = sorted(expected - actual)
+ raise RuntimeError(f'Configured Cloud runtime PostgreSQL {label} grants are incomplete: {missing!r}')
+
+ validate_direct_acl(database_acl, frozenset({'CONNECT'}), 'database')
+ validate_direct_acl(schema_acl, frozenset({'USAGE'}), 'schema')
+
+ direct_table_acl: dict[str, list[typing.Mapping[str, typing.Any]]] = {}
+ direct_sequence_acl: dict[str, list[typing.Mapping[str, typing.Any]]] = {}
+ unexpected_acl_objects: set[str] = set()
+ for row in object_acl:
+ object_name = str(row['relname'])
+ if row['relkind'] == 'S':
+ if object_name in sequence_names:
+ direct_sequence_acl.setdefault(object_name, []).append(row)
+ else:
+ unexpected_acl_objects.add(object_name)
+ elif object_name in relation_allowlist:
+ direct_table_acl.setdefault(object_name, []).append(row)
+ else:
+ unexpected_acl_objects.add(object_name)
+ if unexpected_acl_objects:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL role has grants on non-business objects: '
+ f'{sorted(unexpected_acl_objects)!r}'
+ )
+
+ for table_name, expected_privileges in relation_allowlist.items():
+ validate_direct_acl(
+ direct_table_acl.get(table_name, ()),
+ expected_privileges,
+ f'table {table_name!r}',
+ )
+ for sequence_name in sorted(sequence_names):
+ validate_direct_acl(
+ direct_sequence_acl.get(sequence_name, ()),
+ _RUNTIME_SEQUENCE_PRIVILEGES,
+ f'sequence {sequence_name!r}',
+ )
+
+ unsafe_tables: list[str] = []
+ unavailable_tables: list[str] = []
+ for row in table_privileges:
+ table_name = str(row['relname'])
+ expected_privileges = relation_allowlist.get(table_name, frozenset())
+ actual_privileges = {
+ privilege
+ for privilege, key in (
+ ('SELECT', 'can_select'),
+ ('INSERT', 'can_insert'),
+ ('UPDATE', 'can_update'),
+ ('DELETE', 'can_delete'),
+ ('TRUNCATE', 'can_truncate'),
+ ('REFERENCES', 'can_reference'),
+ ('TRIGGER', 'can_trigger'),
+ )
+ if row[key] is True
+ }
+ effective_grant_options = {
+ privilege
+ for privilege, key in (
+ ('SELECT', 'can_grant_select'),
+ ('INSERT', 'can_grant_insert'),
+ ('UPDATE', 'can_grant_update'),
+ ('DELETE', 'can_grant_delete'),
+ ('TRUNCATE', 'can_grant_truncate'),
+ ('REFERENCES', 'can_grant_reference'),
+ ('TRIGGER', 'can_grant_trigger'),
+ )
+ if row[key] is True
+ }
+ if actual_privileges - expected_privileges or effective_grant_options:
+ unsafe_tables.append(table_name)
+ if require_grants and expected_privileges - actual_privileges:
+ unavailable_tables.append(table_name)
+ if unsafe_tables:
+ raise RuntimeError(
+ f'Configured Cloud runtime PostgreSQL table privileges are unsafe: {sorted(unsafe_tables)!r}'
+ )
+ if unavailable_tables:
+ raise RuntimeError(
+ f'Configured Cloud runtime PostgreSQL table privileges are incomplete: {sorted(unavailable_tables)!r}'
+ )
+
+ unsafe_sequences: list[str] = []
+ unavailable_sequences: list[str] = []
+ for row in sequence_privileges:
+ sequence_name = str(row['relname'])
+ expected_privileges = _RUNTIME_SEQUENCE_PRIVILEGES if sequence_name in sequence_names else frozenset()
+ actual_privileges = {
+ privilege
+ for privilege, key in (
+ ('USAGE', 'can_use'),
+ ('SELECT', 'can_select'),
+ ('UPDATE', 'can_update'),
+ )
+ if row[key] is True
+ }
+ effective_grant_options = {
+ privilege
+ for privilege, key in (
+ ('USAGE', 'can_grant_use'),
+ ('SELECT', 'can_grant_select'),
+ ('UPDATE', 'can_grant_update'),
+ )
+ if row[key] is True
+ }
+ if actual_privileges - expected_privileges or effective_grant_options:
+ unsafe_sequences.append(sequence_name)
+ if require_grants and expected_privileges - actual_privileges:
+ unavailable_sequences.append(sequence_name)
+ if unsafe_sequences:
+ raise RuntimeError(
+ f'Configured Cloud runtime PostgreSQL sequence privileges are unsafe: {sorted(unsafe_sequences)!r}'
+ )
+ if unavailable_sequences:
+ raise RuntimeError(
+ 'Configured Cloud runtime PostgreSQL sequence privileges are incomplete: '
+ f'{sorted(unavailable_sequences)!r}'
+ )
+
+ async def _validate_postgres_pgvector_schema(self) -> None:
+ """Fail closed when the release-owned pgvector contract has drifted."""
+
+ engine = self.get_db_engine()
+ if engine.dialect.name != 'postgresql':
+ raise RuntimeError('PostgreSQL pgvector schema validation requires PostgreSQL')
+
+ column_query = sqlalchemy.text(
+ """
+ SELECT
+ a.attname AS column_name,
+ format_type(a.atttypid, a.atttypmod) AS type_name,
+ a.attnotnull AS not_null
+ FROM pg_attribute a
+ JOIN pg_class c ON c.oid = a.attrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relname = 'langbot_vectors'
+ AND a.attnum > 0
+ AND NOT a.attisdropped
+ ORDER BY a.attnum
+ """
+ )
+ constraint_query = sqlalchemy.text(
+ """
+ SELECT
+ c.relname AS table_name,
+ con.conname AS constraint_name,
+ con.contype::text AS constraint_type,
+ pg_get_constraintdef(con.oid) AS definition
+ FROM pg_constraint con
+ JOIN pg_class c ON c.oid = con.conrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relname IN ('knowledge_bases', 'langbot_vectors')
+ ORDER BY c.relname, con.conname
+ """
+ )
+ index_query = sqlalchemy.text(
+ """
+ SELECT
+ idx.relname AS index_name,
+ am.amname AS access_method,
+ ix.indisvalid AS is_valid,
+ ix.indisready AS is_ready,
+ pg_get_indexdef(ix.indexrelid) AS definition,
+ pg_get_expr(ix.indpred, ix.indrelid) AS predicate
+ FROM pg_index ix
+ JOIN pg_class tbl ON tbl.oid = ix.indrelid
+ JOIN pg_namespace n ON n.oid = tbl.relnamespace
+ JOIN pg_class idx ON idx.oid = ix.indexrelid
+ JOIN pg_am am ON am.oid = idx.relam
+ WHERE n.nspname = 'public'
+ AND tbl.relname = 'langbot_vectors'
+ ORDER BY idx.relname
+ """
+ )
+
+ async with engine.connect() as conn:
+ extension_installed = await conn.scalar(
+ sqlalchemy.text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector')")
+ )
+ columns = (await conn.execute(column_query)).mappings().all()
+ constraints = (await conn.execute(constraint_query)).mappings().all()
+ indexes = (await conn.execute(index_query)).mappings().all()
+
+ if extension_installed is not True:
+ raise RuntimeError('PostgreSQL vector extension is not installed')
+
+ expected_columns = {
+ 'workspace_uuid': ('character varying(36)', True),
+ 'knowledge_base_uuid': ('character varying(255)', True),
+ 'vector_id': ('character varying(255)', True),
+ 'embedding_dimension': ('integer', True),
+ # An untyped pgvector column is required because enabled dimensions
+ # share the table and are selected by release-created partial indexes.
+ 'embedding': ('vector', True),
+ 'text': ('text', False),
+ 'file_id': ('character varying(255)', False),
+ 'chunk_uuid': ('character varying(255)', False),
+ }
+ actual_columns = {row['column_name']: (row['type_name'], row['not_null']) for row in columns}
+ if actual_columns != expected_columns:
+ raise RuntimeError('PostgreSQL pgvector columns do not match the release contract')
+
+ by_constraint = {(row['table_name'], row['constraint_name']): row for row in constraints}
+ required_constraints = {
+ ('knowledge_bases', 'ck_knowledge_bases_embedding_dimension_positive'),
+ ('langbot_vectors', 'pk_langbot_vectors'),
+ ('langbot_vectors', 'fk_langbot_vectors_workspace_kb'),
+ ('langbot_vectors', 'ck_langbot_vectors_embedding_dimension'),
+ ('langbot_vectors', 'ck_langbot_vectors_embedding_dimension_enabled'),
+ }
+ missing_constraints = sorted(required_constraints - set(by_constraint))
+ if missing_constraints:
+ raise RuntimeError(f'PostgreSQL pgvector constraints are missing: {missing_constraints!r}')
+
+ def normalized(value: str | None) -> str:
+ return ' '.join((value or '').lower().split())
+
+ primary_key = normalized(by_constraint[('langbot_vectors', 'pk_langbot_vectors')]['definition'])
+ if primary_key != 'primary key (workspace_uuid, knowledge_base_uuid, vector_id)':
+ raise RuntimeError('PostgreSQL pgvector primary key does not match the release contract')
+
+ foreign_key = normalized(by_constraint[('langbot_vectors', 'fk_langbot_vectors_workspace_kb')]['definition'])
+ if (
+ 'foreign key (workspace_uuid, knowledge_base_uuid)' not in foreign_key
+ or 'references knowledge_bases(workspace_uuid, uuid)' not in foreign_key
+ or 'on delete cascade' not in foreign_key
+ ):
+ raise RuntimeError('PostgreSQL pgvector foreign key does not match the release contract')
+
+ kb_dimension = normalized(
+ by_constraint[('knowledge_bases', 'ck_knowledge_bases_embedding_dimension_positive')]['definition']
+ )
+ if 'embedding_dimension is null' not in kb_dimension or 'embedding_dimension > 0' not in kb_dimension:
+ raise RuntimeError('PostgreSQL knowledge-base embedding dimension check is invalid')
+
+ vector_dimension = normalized(
+ by_constraint[('langbot_vectors', 'ck_langbot_vectors_embedding_dimension')]['definition']
+ )
+ if 'vector_dims(embedding)' not in vector_dimension or 'embedding_dimension' not in vector_dimension:
+ raise RuntimeError('PostgreSQL pgvector dimension check is invalid')
+
+ allowed_dimension = normalized(
+ by_constraint[('langbot_vectors', 'ck_langbot_vectors_embedding_dimension_enabled')]['definition']
+ )
+ if {int(item) for item in re.findall(r'\b\d+\b', allowed_dimension)} != set(_PGVECTOR_ALLOWED_DIMENSIONS):
+ raise RuntimeError('PostgreSQL pgvector enabled-dimension check is invalid')
+
+ by_index = {row['index_name']: row for row in indexes}
+ expected_btree_indexes = {
+ 'ix_langbot_vectors_workspace_kb_file': '(workspace_uuid, knowledge_base_uuid, file_id)',
+ 'ix_langbot_vectors_workspace_kb_chunk': '(workspace_uuid, knowledge_base_uuid, chunk_uuid)',
+ }
+ for index_name, columns_fragment in expected_btree_indexes.items():
+ index = by_index.get(index_name)
+ if (
+ index is None
+ or index['access_method'] != 'btree'
+ or index['is_valid'] is not True
+ or index['is_ready'] is not True
+ or columns_fragment not in index['definition']
+ ):
+ raise RuntimeError(f'PostgreSQL pgvector index {index_name!r} is invalid')
+
+ for dimension in _PGVECTOR_ALLOWED_DIMENSIONS:
+ index_name = f'ix_langbot_vectors_hnsw_cosine_{dimension}'
+ index = by_index.get(index_name)
+ index_definition = normalized(None if index is None else index['definition'])
+ predicate = normalized(None if index is None else index['predicate'])
+ if (
+ index is None
+ or index['access_method'] != 'hnsw'
+ or index['is_valid'] is not True
+ or index['is_ready'] is not True
+ or f'vector({dimension})' not in index_definition
+ or f'(embedding)::vector({dimension})' not in index_definition
+ or 'vector_cosine_ops' not in index_definition
+ or predicate.strip('() ') != f'embedding_dimension = {dimension}'
+ ):
+ raise RuntimeError(f'PostgreSQL pgvector ANN index {index_name!r} is invalid')
+
+ async def _validate_postgres_tenant_schema(self, *, validate_runtime_role: bool) -> None:
+ """Fail closed when PostgreSQL cannot enforce the tenant contract."""
+ engine = self.get_db_engine()
+ if engine.dialect.name != 'postgresql':
+ raise RuntimeError('PostgreSQL tenant schema validation requires PostgreSQL')
+ rls_table_names = tuple(sorted(set(TENANT_TABLE_COLUMNS) | set(DIRECTORY_PROJECTION_TABLE_COLUMNS)))
+
+ table_query = sqlalchemy.text(
+ """
+ SELECT
+ c.relname AS table_name,
+ c.relrowsecurity AS rls_enabled,
+ c.relforcerowsecurity AS rls_forced,
+ pg_get_userbyid(c.relowner) = current_user AS owned_by_runtime
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relkind IN ('r', 'p')
+ AND c.relname IN :table_names
+ """
+ ).bindparams(sqlalchemy.bindparam('table_names', expanding=True))
+ policy_query = sqlalchemy.text(
+ """
+ SELECT
+ c.relname AS table_name,
+ p.polname AS policy_name,
+ p.polcmd::text AS command,
+ p.polpermissive AS permissive,
+ p.polroles = ARRAY[0::oid] AS public_only,
+ pg_get_expr(p.polqual, p.polrelid) AS using_expression,
+ pg_get_expr(p.polwithcheck, p.polrelid) AS check_expression
+ FROM pg_policy p
+ JOIN pg_class c ON c.oid = p.polrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public'
+ AND c.relname IN :table_names
+ ORDER BY c.relname, p.polname
+ """
+ ).bindparams(sqlalchemy.bindparam('table_names', expanding=True))
+
+ async with engine.connect() as conn:
+ if validate_runtime_role:
+ role = (
+ (
+ await conn.execute(
+ sqlalchemy.text(
+ """
+ SELECT rolsuper, rolbypassrls
+ FROM pg_roles
+ WHERE rolname = current_user
+ """
+ )
+ )
+ )
+ .mappings()
+ .one_or_none()
+ )
+ if role is None:
+ raise RuntimeError('Cloud runtime PostgreSQL role could not be inspected')
+ if role['rolsuper']:
+ raise RuntimeError('Cloud runtime PostgreSQL role must not be a superuser')
+ if role['rolbypassrls']:
+ raise RuntimeError('Cloud runtime PostgreSQL role must not have BYPASSRLS')
+
+ rows = (
+ (
+ await conn.execute(
+ table_query,
+ {
+ 'table_names': rls_table_names,
+ },
+ )
+ )
+ .mappings()
+ .all()
+ )
+ policy_rows = (
+ (
+ await conn.execute(
+ policy_query,
+ {'table_names': rls_table_names},
+ )
+ )
+ .mappings()
+ .all()
+ )
+
+ by_table = {row['table_name']: row for row in rows}
+ missing_tables = set(rls_table_names) - set(by_table)
+ if missing_tables:
+ raise RuntimeError(f'PostgreSQL tenant tables are missing: {sorted(missing_tables)!r}')
+
+ invalid_rls = sorted(
+ table_name for table_name, row in by_table.items() if not row['rls_enabled'] or not row['rls_forced']
+ )
+ if invalid_rls:
+ raise RuntimeError(f'PostgreSQL tenant RLS contract is incomplete for tables: {invalid_rls!r}')
+
+ expected_policies = self._expected_postgres_tenant_policies()
+ actual_policies = {(row['table_name'], row['policy_name']): row for row in policy_rows}
+ expected_keys = {
+ (table_name, policy_name) for table_name, policies in expected_policies.items() for policy_name in policies
+ }
+ actual_keys = set(actual_policies)
+ if actual_keys != expected_keys:
+ missing = sorted(expected_keys - actual_keys)
+ extra = sorted(actual_keys - expected_keys)
+ raise RuntimeError(
+ f'PostgreSQL tenant policy set does not match the release contract; missing={missing!r}, extra={extra!r}'
+ )
+
+ invalid_policies: list[tuple[str, str]] = []
+ for table_name, policies in expected_policies.items():
+ for policy_name, expected in policies.items():
+ actual = actual_policies[(table_name, policy_name)]
+ if (
+ actual['command'] != expected['command']
+ or actual['permissive'] is not True
+ or actual['public_only'] is not True
+ or actual['using_expression'] != expected['using_expression']
+ or actual['check_expression'] != expected['check_expression']
+ ):
+ invalid_policies.append((table_name, policy_name))
+ if invalid_policies:
+ raise RuntimeError(f'PostgreSQL tenant policy definitions are invalid: {invalid_policies!r}')
+
+ if validate_runtime_role:
+ owned_tables = sorted(table_name for table_name, row in by_table.items() if row['owned_by_runtime'])
+ if owned_tables:
+ raise RuntimeError(f'Cloud runtime PostgreSQL role must not own tenant tables: {owned_tables!r}')
+
+ @staticmethod
+ def _expected_postgres_tenant_policies() -> dict[str, dict[str, dict[str, str | None]]]:
+ """Return the exact PostgreSQL 16 policy expressions emitted by 0011/0014."""
+
+ def setting(name: str) -> str:
+ return f"NULLIF(current_setting('{name}'::text, true), ''::text)"
+
+ policies: dict[str, dict[str, dict[str, str | None]]] = {}
+ for table_name, tenant_column in TENANT_TABLE_COLUMNS.items():
+ expression = f'(({tenant_column})::text = {setting(TENANT_SETTING)})'
+ policies[table_name] = {
+ TENANT_POLICY_NAME: {
+ 'command': '*',
+ 'using_expression': expression,
+ 'check_expression': expression,
+ }
+ }
+
+ local_workspace_expression = (
+ f"(((uuid)::text = {setting(TENANT_SETTING)}) AND ((source)::text = 'local'::text))"
+ )
+ local_membership_expression = f'((workspace_uuid)::text = {setting(TENANT_SETTING)})'
+ local_execution_expression = (
+ f'(((workspace_uuid)::text = {setting(TENANT_SETTING)}) AND (EXISTS ( SELECT 1\n'
+ ' FROM workspaces local_workspace\n'
+ ' WHERE (((local_workspace.uuid)::text = (workspace_execution_states.workspace_uuid)::text) '
+ "AND ((local_workspace.source)::text = 'local'::text)))))"
+ )
+ for table_name, local_write_expression in {
+ 'workspaces': local_workspace_expression,
+ 'workspace_memberships': local_membership_expression,
+ 'workspace_execution_states': local_execution_expression,
+ }.items():
+ tenant_column = TENANT_TABLE_COLUMNS[table_name]
+ tenant_expression = f'(({tenant_column})::text = {setting(TENANT_SETTING)})'
+ policies[table_name][TENANT_POLICY_NAME] = {
+ 'command': 'r',
+ 'using_expression': tenant_expression,
+ 'check_expression': None,
+ }
+ policies[table_name][LOCAL_DIRECTORY_WRITE_POLICY_NAME] = {
+ 'command': '*',
+ 'using_expression': local_write_expression,
+ 'check_expression': local_write_expression,
+ }
+
+ policies['workspace_memberships'][ACCOUNT_DISCOVERY_POLICY_NAME] = {
+ 'command': 'r',
+ 'using_expression': (
+ f"(((account_uuid)::text = {setting('langbot.account_uuid')}) AND ((status)::text = 'active'::text))"
+ ),
+ 'check_expression': None,
+ }
+ policies['api_keys'][API_KEY_DISCOVERY_POLICY_NAME] = {
+ 'command': 'r',
+ 'using_expression': (
+ f'(((key_hash)::text = {setting("langbot.api_key_hash")}) '
+ "AND ((status)::text = 'active'::text) "
+ 'AND ((expires_at IS NULL) OR (expires_at > CURRENT_TIMESTAMP)))'
+ ),
+ 'check_expression': None,
+ }
+ policies['workspace_invitations'][INVITATION_DISCOVERY_POLICY_NAME] = {
+ 'command': 'r',
+ 'using_expression': f'((token_hash)::text = {setting("langbot.invitation_hash")})',
+ 'check_expression': None,
+ }
+ policies['workspace_execution_states'][INSTANCE_DISCOVERY_POLICY_NAME] = {
+ 'command': 'r',
+ 'using_expression': (
+ f'(((instance_uuid)::text = {setting("langbot.instance_uuid")}) '
+ "AND ((state)::text = 'active'::text) AND (write_fenced = false))"
+ ),
+ 'check_expression': None,
+ }
+
+ directory_setting = setting(DIRECTORY_INSTANCE_SETTING)
+ workspace_expression = (
+ f"(((instance_uuid)::text = {directory_setting}) AND ((source)::text = 'cloud_projection'::text))"
+ )
+ membership_expression = (
+ '(EXISTS ( SELECT 1\n'
+ ' FROM workspaces directory_workspace\n'
+ ' WHERE (((directory_workspace.uuid)::text = (workspace_memberships.workspace_uuid)::text) '
+ f'AND ((directory_workspace.instance_uuid)::text = {directory_setting}) '
+ "AND ((directory_workspace.source)::text = 'cloud_projection'::text))))"
+ )
+ execution_expression = (
+ f'(((instance_uuid)::text = {directory_setting}) '
+ "AND ((source)::text = 'cloud'::text) "
+ 'AND (EXISTS ( SELECT 1\n'
+ ' FROM workspaces directory_workspace\n'
+ ' WHERE (((directory_workspace.uuid)::text = (workspace_execution_states.workspace_uuid)::text) '
+ f'AND ((directory_workspace.instance_uuid)::text = {directory_setting}) '
+ "AND ((directory_workspace.source)::text = 'cloud_projection'::text)))))"
+ )
+ directory_tenant_expressions = {
+ 'workspaces': workspace_expression,
+ 'workspace_memberships': membership_expression,
+ 'workspace_execution_states': execution_expression,
+ }
+ for table_name in DIRECTORY_PROJECTED_TENANT_TABLES:
+ expression = directory_tenant_expressions[table_name]
+ policies[table_name][DIRECTORY_PROJECTION_POLICY_NAME] = {
+ 'command': '*',
+ 'using_expression': expression,
+ 'check_expression': expression,
+ }
+ for table_name, instance_column in DIRECTORY_PROJECTION_TABLE_COLUMNS.items():
+ expression = f'(({instance_column})::text = {directory_setting})'
+ policies[table_name] = {
+ DIRECTORY_PROJECTION_POLICY_NAME: {
+ 'command': '*',
+ 'using_expression': expression,
+ 'check_expression': expression,
+ }
+ }
+ return policies
+
async def write_space_model_providers(self):
+ if constants.edition != 'community':
+ # SaaS Workspace/provider linkage is explicit control-plane state;
+ # a process-level compatibility provider must never be projected
+ # into an arbitrary cloud Workspace.
+ return
+
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
)
- # write space model providers
+ workspace_result = await self.execute_async(
+ sqlalchemy.select(persistence_workspace.Workspace.uuid).where(
+ persistence_workspace.Workspace.instance_uuid == constants.instance_id,
+ persistence_workspace.Workspace.source == persistence_workspace.WorkspaceSource.LOCAL.value,
+ )
+ )
+ workspace_uuids = workspace_result.scalars().all()
+ if len(workspace_uuids) != 1:
+ raise RuntimeError(
+ f'The fixed LangBot Models provider requires exactly one local Workspace; found {len(workspace_uuids)}'
+ )
+ workspace_uuid = workspace_uuids[0]
+
+ # The compatibility Space provider belongs to the OSS singleton
+ # Workspace. It must never be discovered or inserted globally.
result = await self.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.requester == 'space-chat-completions'
+ persistence_model.ModelProvider.workspace_uuid == workspace_uuid,
+ persistence_model.ModelProvider.requester == 'space-chat-completions',
)
)
exists_space_chat_completions_model_provider = result.first()
@@ -119,6 +1656,7 @@ class PersistenceManager:
self.ap.logger.info('Creating space model providers...')
space_chat_completions_model_provider = {
'uuid': '00000000-0000-0000-0000-000000000000',
+ 'workspace_uuid': workspace_uuid,
'name': 'LangBot Models',
'requester': 'space-chat-completions',
'base_url': space_models_gateway_api_url,
@@ -132,13 +1670,16 @@ class PersistenceManager:
if exists_space_chat_completions_model_provider.base_url != space_models_gateway_api_url:
await self.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
- .where(persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid)
+ .where(
+ persistence_model.ModelProvider.workspace_uuid == workspace_uuid,
+ persistence_model.ModelProvider.uuid == exists_space_chat_completions_model_provider.uuid,
+ )
.values({'base_url': space_models_gateway_api_url})
)
# =================================
- async def _run_alembic_migrations(self):
+ async def _run_alembic_migrations(self, target_revision: str = 'head'):
"""Run Alembic-based migrations after legacy migrations complete."""
from . import alembic_runner
@@ -153,19 +1694,214 @@ class PersistenceManager:
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
current_rev = '0001_baseline'
- # Upgrade to head
- await alembic_runner.run_alembic_upgrade(engine, 'head')
- self.ap.logger.info('Alembic migrations completed.')
+ if engine.dialect.name == 'sqlite':
+ if current_rev in _PRE_WORKSPACE_ALEMBIC_REVISIONS:
+ await self._run_verified_sqlite_migration(
+ engine,
+ source_revision=current_rev,
+ target_revision=_WORKSPACE_ALEMBIC_REVISION,
+ )
+ current_rev = await alembic_runner.get_alembic_current(engine)
+ if current_rev == _WORKSPACE_ALEMBIC_REVISION:
+ await self._run_verified_sqlite_migration(
+ engine,
+ source_revision=current_rev,
+ target_revision=_RESOURCE_SCOPE_ALEMBIC_REVISION,
+ )
+
+ # PostgreSQL has transactional DDL. SQLite has already crossed the
+ # two destructive tenancy boundaries under verified backups; this
+ # final call is a no-op today and applies future migrations.
+ await alembic_runner.run_alembic_upgrade(engine, target_revision)
+ self.ap.logger.info(f'Alembic migrations completed at {target_revision}.')
except Exception as e:
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
raise
+ async def _run_verified_sqlite_migration(
+ self,
+ engine: sqlalchemy_asyncio.AsyncEngine,
+ *,
+ source_revision: str,
+ target_revision: str,
+ ) -> None:
+ from . import alembic_runner
+
+ backup = await sqlite_migration_backup.create_verified_backup(
+ engine,
+ source_revision=source_revision,
+ target_revision=target_revision,
+ )
+ self.ap.logger.info(f'Created verified SQLite migration backup {backup.backup_path} before {target_revision}.')
+ try:
+ await alembic_runner.run_alembic_upgrade(engine, target_revision)
+ completed_revision = await alembic_runner.get_alembic_current(engine)
+ if completed_revision != target_revision:
+ raise RuntimeError(f'Alembic stopped at {completed_revision!r}, expected {target_revision!r}')
+ await sqlite_migration_backup.mark_migration_succeeded(
+ backup,
+ completed_revision=completed_revision,
+ )
+ except BaseException:
+ await sqlite_migration_backup.restore_verified_backup(engine, backup)
+ restored_revision = await alembic_runner.get_alembic_current(engine)
+ if restored_revision != source_revision:
+ raise RuntimeError(
+ f'SQLite migration recovery restored revision {restored_revision!r}, expected {source_revision!r}'
+ )
+ self.ap.logger.error(
+ f'SQLite migration to {target_revision} failed; restored verified backup '
+ f'{backup.backup_path} at revision {source_revision}.'
+ )
+ raise
+
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
+ active = self._get_active_transaction()
+ if active is not None:
+ try:
+ return await self._execute_on_scoped_connection(active.session, *args, **kwargs)
+ except BaseException as exc:
+ # Business code may intentionally catch a constraint failure.
+ # PostgreSQL still leaves that transaction aborted, so record
+ # the failure here and make the owning UoW fail closed on exit.
+ active.mark_rollback_only(exc)
+ raise
+ active_scope = self._get_active_scope()
+ if active_scope is not None:
+ async with self._scoped_uow(active_scope.scope) as uow:
+ return await self._execute_on_scoped_connection(uow.session, *args, **kwargs)
+ if self.mode == PersistenceMode.CLOUD_RUNTIME:
+ raise TenantScopeRequiredError(
+ 'Cloud persistence access requires an explicit Workspace or discovery scope/unit of work'
+ )
async with self.get_db_engine().connect() as conn:
result = await conn.execute(*args, **kwargs)
await conn.commit()
return result
+ @staticmethod
+ async def _execute_on_scoped_connection(
+ session: sqlalchemy_asyncio.AsyncSession,
+ *args: typing.Any,
+ **kwargs: typing.Any,
+ ) -> sqlalchemy.engine.cursor.CursorResult:
+ """Preserve the historical ``execute_async`` Core-result contract.
+
+ ``execute_async`` originally delegated to ``AsyncConnection.execute``.
+ Routing it through ``AsyncSession.execute`` inside a tenant unit of work
+ subtly changes ``select(Model)`` from a flat column row into a one-item
+ ORM row. Legacy callers consequently lose attributes such as ``uuid``
+ and ``user``. Flush pending ORM state, then execute on the Session's
+ transaction-bound connection so row, scalar, and cursor consumers keep
+ the same behavior without escaping the scoped transaction.
+
+ Explicit ``TenantUnitOfWork.session`` and ``TenantUnitOfWork.execute``
+ calls retain normal ORM result semantics.
+ """
+
+ if not isinstance(session, TenantScopedAsyncSession):
+ raise TypeError('Scoped Core execution requires a TenantScopedAsyncSession')
+ return await session.execute_on_transaction_connection(*args, **kwargs)
+
+ def tenant_uow(self, workspace_uuid: str) -> TenantUnitOfWork:
+ return self._scoped_uow(PersistenceScope.workspace(workspace_uuid))
+
+ def tenant_scope(self, workspace_uuid: str) -> PersistenceScopeBoundary:
+ """Bind a Workspace without holding a database session between calls."""
+
+ return PersistenceScopeBoundary(
+ PersistenceScope.workspace(workspace_uuid),
+ active_scope=self._active_scope,
+ active_transaction=self._active_transaction,
+ )
+
+ def account_discovery_uow(self, account_uuid: str) -> TenantUnitOfWork:
+ return self._scoped_uow(PersistenceScope.account(account_uuid))
+
+ def api_key_discovery_uow(self, key_hash: str) -> TenantUnitOfWork:
+ return self._scoped_uow(PersistenceScope.api_key(key_hash))
+
+ def invitation_discovery_uow(self, invitation_hash: str) -> TenantUnitOfWork:
+ return self._scoped_uow(PersistenceScope.invitation(invitation_hash))
+
+ def instance_discovery_uow(self, instance_uuid: str) -> TenantUnitOfWork:
+ return self._scoped_uow(PersistenceScope.instance(instance_uuid))
+
+ def directory_projection_uow(self, instance_uuid: str) -> TenantUnitOfWork:
+ return self._scoped_uow(PersistenceScope.directory(instance_uuid))
+
+ def identity_discovery_uow(self, identity_digest: str) -> TenantUnitOfWork:
+ return self._scoped_uow(PersistenceScope.identity(identity_digest))
+
+ def current_session(self) -> sqlalchemy_asyncio.AsyncSession | None:
+ """Return the transaction-bound session for the current task, if any."""
+
+ active = self._get_active_transaction()
+ return None if active is None else active.session
+
+ def current_scope(self) -> PersistenceScope | None:
+ active = self._get_active_transaction()
+ if active is not None:
+ return active.scope
+ active_scope = self._get_active_scope()
+ return None if active_scope is None else active_scope.scope
+
+ def create_after_commit_gate(self) -> asyncio.Future[None] | None:
+ """Return a gate resolved only after the current scoped transaction commits.
+
+ Detached tasks register while still in the request task, before its
+ ContextVars are cleared. When there is no active transaction they may
+ start immediately. A rollback cancels the gate so no side effect is
+ launched for data that was never committed.
+ """
+
+ active = self._get_active_transaction()
+ if active is None:
+ return None
+ gate = asyncio.get_running_loop().create_future()
+ active.after_commit_waiters.append(gate)
+ return gate
+
+ def require_current_session(
+ self,
+ *allowed_scope_kinds: PersistenceScopeKind,
+ ) -> sqlalchemy_asyncio.AsyncSession:
+ active = self._get_active_transaction()
+ if active is None:
+ raise TenantScopeRequiredError('An explicit persistence unit of work is required')
+ if allowed_scope_kinds and active.scope.kind not in allowed_scope_kinds:
+ allowed = ', '.join(kind.value for kind in allowed_scope_kinds)
+ raise CrossScopeTransactionError(
+ f'Persistence scope {active.scope.kind.value} is not valid here; expected one of: {allowed}'
+ )
+ return active.session
+
+ def _scoped_uow(self, scope: PersistenceScope) -> TenantUnitOfWork:
+ on_pool_timeout = getattr(getattr(self, 'db', None), 'record_pool_timeout', None)
+ return TenantUnitOfWork(
+ self.get_db_engine(),
+ scope=scope,
+ active_transaction=self._active_transaction,
+ active_scope=self._active_scope,
+ on_pool_timeout=(on_pool_timeout if callable(on_pool_timeout) else None),
+ )
+
+ def _get_active_transaction(self) -> ActiveScopedTransaction | None:
+ active = self._active_transaction.get()
+ if active is not None and active.owner_task is not asyncio.current_task():
+ raise CrossScopeTransactionError(
+ 'Scoped database transactions cannot be inherited by child tasks; open an explicit task scope'
+ )
+ return active
+
+ def _get_active_scope(self) -> ActivePersistenceScope | None:
+ active = self._active_scope.get()
+ if active is not None and active.owner_task is not asyncio.current_task():
+ raise CrossScopeTransactionError(
+ 'Scoped persistence boundaries cannot be inherited by child tasks; open an explicit task scope'
+ )
+ return active
+
def get_db_engine(self) -> sqlalchemy_asyncio.AsyncEngine:
return self.db.get_engine()
diff --git a/src/langbot/pkg/persistence/postgresql_url.py b/src/langbot/pkg/persistence/postgresql_url.py
new file mode 100644
index 000000000..dbc465bc4
--- /dev/null
+++ b/src/langbot/pkg/persistence/postgresql_url.py
@@ -0,0 +1,25 @@
+"""Safe PostgreSQL URL normalization shared by runtime and migration jobs."""
+
+from __future__ import annotations
+
+import sqlalchemy
+
+
+def normalize_asyncpg_url(url: sqlalchemy.engine.URL) -> sqlalchemy.engine.URL:
+ """Select asyncpg and translate the common libpq TLS query spelling."""
+
+ if url.drivername == 'postgresql':
+ url = url.set(drivername='postgresql+asyncpg')
+ elif url.drivername != 'postgresql+asyncpg':
+ raise ValueError('PostgreSQL URL must use PostgreSQL with the asyncpg driver')
+
+ query = dict(url.query)
+ sslmode = query.pop('sslmode', None)
+ if sslmode is not None:
+ if 'ssl' in query and query['ssl'] != sslmode:
+ raise ValueError('PostgreSQL URL cannot specify conflicting ssl and sslmode options')
+ # SQLAlchemy expands URL query keys into asyncpg keyword arguments.
+ # asyncpg calls this keyword ``ssl`` even though PostgreSQL DSNs
+ # conventionally spell the same mode ``sslmode``.
+ query['ssl'] = sslmode
+ return url.set(query=query)
diff --git a/src/langbot/pkg/persistence/release_migration.py b/src/langbot/pkg/persistence/release_migration.py
new file mode 100644
index 000000000..0834c0d28
--- /dev/null
+++ b/src/langbot/pkg/persistence/release_migration.py
@@ -0,0 +1,190 @@
+"""One-shot, operator-only Cloud PostgreSQL release migration entrypoint."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import re
+from collections.abc import Mapping
+
+import sqlalchemy
+
+from ..cloud.bootstrap import SUPPORTED_PGVECTOR_DIMENSIONS
+from ..core import app as core_app
+from ..core.stages.load_config import LoadConfigStage
+from ..core.stages.setup_logger import SetupLoggerStage
+from .mgr import PersistenceManager, PersistenceMode
+from .postgresql_url import normalize_asyncpg_url
+
+
+DEFAULT_OPERATOR_DSN_ENV = 'LANGBOT_CLOUD_MIGRATION_DSN'
+_ENV_NAME = re.compile(r'^[A-Z_][A-Z0-9_]*$')
+
+
+class CloudReleaseMigrationConfigurationError(RuntimeError):
+ """Raised before any database operation when migration input is unsafe."""
+
+
+def _url_endpoint(url: sqlalchemy.engine.URL, *, label: str) -> tuple[str, int]:
+ """Return a comparison-safe PostgreSQL endpoint without leaking its DSN."""
+
+ try:
+ host = (url.host or '').strip().casefold()
+ port = url.port or 5432
+ except (TypeError, ValueError):
+ raise CloudReleaseMigrationConfigurationError(f'{label} PostgreSQL host or port is invalid') from None
+ if not host or isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
+ raise CloudReleaseMigrationConfigurationError(f'{label} PostgreSQL host or port is invalid')
+ return host, port
+
+
+def _operator_database_url(
+ instance_config: dict,
+ *,
+ environ: Mapping[str, str],
+) -> sqlalchemy.engine.URL:
+ database_config = instance_config.get('database')
+ if not isinstance(database_config, dict) or database_config.get('use') != 'postgresql':
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud release migration requires explicit database.use=postgresql; SQLite fallback is forbidden'
+ )
+
+ runtime_config = database_config.get('postgresql')
+ if not isinstance(runtime_config, dict):
+ raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL configuration is missing')
+
+ migration_config = database_config.get('cloud_migration', {})
+ if not isinstance(migration_config, dict):
+ raise CloudReleaseMigrationConfigurationError('database.cloud_migration must be a mapping')
+ dsn_env = migration_config.get('operator_dsn_env', DEFAULT_OPERATOR_DSN_ENV)
+ if not isinstance(dsn_env, str) or not _ENV_NAME.fullmatch(dsn_env):
+ raise CloudReleaseMigrationConfigurationError(
+ 'database.cloud_migration.operator_dsn_env must name an uppercase environment variable'
+ )
+
+ raw_dsn = environ.get(dsn_env, '').strip()
+ if not raw_dsn:
+ raise CloudReleaseMigrationConfigurationError(
+ f'Cloud release migration requires the operator DSN in environment variable {dsn_env}'
+ )
+ try:
+ operator_url = sqlalchemy.engine.make_url(raw_dsn)
+ except Exception:
+ # Never echo a malformed DSN because it may contain an unescaped secret.
+ raise CloudReleaseMigrationConfigurationError('Cloud release migration operator DSN is invalid') from None
+
+ try:
+ operator_url = normalize_asyncpg_url(operator_url)
+ except ValueError:
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud release migration operator DSN must use valid PostgreSQL asyncpg options'
+ ) from None
+
+ operator_user = (operator_url.username or '').strip()
+ operator_database = (operator_url.database or '').strip()
+ operator_host, operator_port = _url_endpoint(operator_url, label='Cloud release migration operator')
+ runtime_url_value = runtime_config.get('url')
+ if runtime_url_value:
+ if not isinstance(runtime_url_value, str):
+ raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL URL must be a string')
+ try:
+ runtime_url = sqlalchemy.engine.make_url(runtime_url_value)
+ except Exception:
+ raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL URL is invalid') from None
+ if runtime_url.drivername not in {'postgresql', 'postgresql+asyncpg'}:
+ raise CloudReleaseMigrationConfigurationError('Cloud runtime database URL must use PostgreSQL')
+ runtime_user = (runtime_url.username or '').strip()
+ runtime_database = (runtime_url.database or '').strip()
+ runtime_host, runtime_port = _url_endpoint(runtime_url, label='Cloud runtime')
+ else:
+ runtime_user = str(runtime_config.get('user', 'postgres') or '').strip()
+ runtime_database = str(runtime_config.get('database', 'postgres') or '').strip()
+ runtime_host = str(runtime_config.get('host', '') or '').strip().casefold()
+ runtime_port = runtime_config.get('port', 5432)
+ if not operator_user or not operator_database:
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud release migration operator DSN must include a user, host, and database'
+ )
+ if (
+ not runtime_user
+ or not runtime_database
+ or not runtime_host
+ or isinstance(runtime_port, bool)
+ or not isinstance(runtime_port, int)
+ or not 1 <= runtime_port <= 65535
+ ):
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud runtime PostgreSQL user, host, port, and database are required'
+ )
+ if operator_user == runtime_user:
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud release migration requires a distinct operator role from the runtime PostgreSQL role'
+ )
+ if operator_database != runtime_database:
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud release migration operator DSN must target the configured runtime database'
+ )
+ if operator_host != runtime_host or operator_port != runtime_port:
+ # The first Cloud release intentionally requires the migrator and
+ # runtime to use the same PostgreSQL endpoint. Supporting a direct
+ # operator endpoint plus a runtime pooler requires a database-backed
+ # immutable cluster identity check; accepting aliases here would turn a
+ # same-named database on another cluster into a silent migration target.
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud release migration operator DSN must target the configured runtime PostgreSQL endpoint'
+ )
+
+ vdb_config = instance_config.get('vdb')
+ if not isinstance(vdb_config, dict) or vdb_config.get('use') != 'pgvector':
+ raise CloudReleaseMigrationConfigurationError('Cloud release migration requires vdb.use=pgvector')
+ pgvector_config = vdb_config.get('pgvector')
+ if not isinstance(pgvector_config, dict) or pgvector_config.get('use_business_database') is not True:
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud release migration requires vdb.pgvector.use_business_database=true'
+ )
+ dimensions = pgvector_config.get('allowed_dimensions')
+ if (
+ not isinstance(dimensions, list)
+ or not dimensions
+ or any(isinstance(item, bool) or not isinstance(item, int) for item in dimensions)
+ or not set(dimensions).issubset(SUPPORTED_PGVECTOR_DIMENSIONS)
+ ):
+ raise CloudReleaseMigrationConfigurationError(
+ 'Cloud release migration pgvector dimensions are outside the release-created index set'
+ )
+
+ return operator_url
+
+
+async def run_cloud_release_migration(
+ ap: core_app.Application,
+ *,
+ environ: Mapping[str, str] | None = None,
+) -> None:
+ """Run and validate one release migration with an isolated operator DSN."""
+
+ operator_url = _operator_database_url(
+ ap.instance_config.data,
+ environ=os.environ if environ is None else environ,
+ )
+ manager = PersistenceManager(
+ ap,
+ mode=PersistenceMode.RELEASE_MIGRATION,
+ database_url=operator_url,
+ )
+ ap.persistence_mgr = manager
+ try:
+ await manager.initialize()
+ ap.logger.info('Cloud PostgreSQL release migration reached and validated the exact release head.')
+ finally:
+ await manager.shutdown()
+
+
+async def run_cloud_release_migration_from_config(loop: asyncio.AbstractEventLoop) -> None:
+ """Load only process configuration/logging, then run the one-shot job."""
+
+ ap = core_app.Application()
+ ap.event_loop = loop
+ await LoadConfigStage().run(ap)
+ await SetupLoggerStage().run(ap)
+ await run_cloud_release_migration(ap)
diff --git a/src/langbot/pkg/persistence/sqlite_migration_backup.py b/src/langbot/pkg/persistence/sqlite_migration_backup.py
new file mode 100644
index 000000000..5e1f7e683
--- /dev/null
+++ b/src/langbot/pkg/persistence/sqlite_migration_backup.py
@@ -0,0 +1,272 @@
+"""Durable SQLite backups for destructive Alembic migration boundaries."""
+
+from __future__ import annotations
+
+import asyncio
+import dataclasses
+import datetime
+import json
+import os
+import pathlib
+import re
+import secrets
+import sqlite3
+import tempfile
+import typing
+
+from sqlalchemy.ext.asyncio import AsyncEngine
+
+
+class SQLiteMigrationBackupError(RuntimeError):
+ """A verified migration backup could not be created or restored."""
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class SQLiteMigrationBackup:
+ database_path: pathlib.Path
+ backup_path: pathlib.Path
+ manifest_path: pathlib.Path
+ source_revision: str
+ target_revision: str
+ created_at: str
+
+
+def _safe_label(value: str) -> str:
+ label = re.sub(r'[^A-Za-z0-9_.-]+', '-', value).strip('-')
+ return label or 'unknown'
+
+
+def _database_path(engine: AsyncEngine) -> pathlib.Path:
+ if engine.dialect.name != 'sqlite':
+ raise SQLiteMigrationBackupError('SQLite migration backups require a SQLite engine')
+ database = engine.url.database
+ if not database or database == ':memory:' or engine.url.query.get('mode') == 'memory':
+ raise SQLiteMigrationBackupError('Tenant schema migrations require a file-backed SQLite database for recovery')
+ database_path = pathlib.Path(database).expanduser()
+ if not database_path.is_absolute():
+ database_path = pathlib.Path.cwd() / database_path
+ database_path = database_path.resolve()
+ if not database_path.is_file():
+ raise SQLiteMigrationBackupError(f'SQLite database does not exist: {database_path}')
+ return database_path
+
+
+def _open_read_only(path: pathlib.Path) -> sqlite3.Connection:
+ return sqlite3.connect(f'{path.as_uri()}?mode=ro', uri=True, timeout=30)
+
+
+def _read_revision(connection: sqlite3.Connection) -> str | None:
+ has_version_table = connection.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'"
+ ).fetchone()
+ if has_version_table is None:
+ return None
+ rows = connection.execute('SELECT version_num FROM alembic_version').fetchall()
+ if not rows:
+ return None
+ if len(rows) != 1 or not isinstance(rows[0][0], str):
+ raise SQLiteMigrationBackupError('SQLite backup has an invalid Alembic revision table')
+ return rows[0][0]
+
+
+def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -> None:
+ quick_check = connection.execute('PRAGMA quick_check').fetchall()
+ if quick_check != [('ok',)]:
+ raise SQLiteMigrationBackupError(f'SQLite quick_check failed: {quick_check[:5]!r}')
+ actual_revision = _read_revision(connection)
+ if actual_revision != expected_revision:
+ raise SQLiteMigrationBackupError(
+ f'SQLite backup revision mismatch: {actual_revision!r} != {expected_revision!r}'
+ )
+
+
+def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
+ with _open_read_only(path) as connection:
+ _verify_connection(connection, expected_revision)
+
+
+def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.Any) -> None:
+ payload: dict[str, typing.Any] = {
+ 'version': 1,
+ 'status': status,
+ 'created_at': backup.created_at,
+ 'database_path': str(backup.database_path),
+ 'backup_path': str(backup.backup_path),
+ 'source_revision': backup.source_revision,
+ 'target_revision': backup.target_revision,
+ 'quick_check': 'ok',
+ **extra,
+ }
+ backup.manifest_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f'.{backup.manifest_path.name}.',
+ suffix='.tmp',
+ dir=backup.manifest_path.parent,
+ )
+ temporary_path = pathlib.Path(temporary_name)
+ try:
+ with os.fdopen(descriptor, 'w', encoding='utf-8') as file:
+ json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
+ file.write('\n')
+ file.flush()
+ os.fsync(file.fileno())
+ os.chmod(temporary_path, 0o600)
+ os.replace(temporary_path, backup.manifest_path)
+ _fsync_directory(backup.manifest_path.parent)
+ finally:
+ temporary_path.unlink(missing_ok=True)
+
+
+def _fsync_file(path: pathlib.Path) -> None:
+ descriptor = os.open(path, os.O_RDONLY)
+ try:
+ os.fsync(descriptor)
+ finally:
+ os.close(descriptor)
+
+
+def _fsync_directory(path: pathlib.Path) -> None:
+ descriptor = os.open(path, os.O_RDONLY)
+ try:
+ os.fsync(descriptor)
+ finally:
+ os.close(descriptor)
+
+
+def _create_backup(
+ database_path: pathlib.Path,
+ source_revision: str,
+ target_revision: str,
+) -> SQLiteMigrationBackup:
+ backup_directory = database_path.parent / 'migration-backups'
+ backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
+ os.chmod(backup_directory, 0o700)
+ created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
+ stem = (
+ f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
+ f'from-{_safe_label(source_revision)}-{created_at}-{secrets.token_hex(4)}'
+ )
+ backup_path = backup_directory / f'{stem}.sqlite3'
+ manifest_path = backup_directory / f'{stem}.json'
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f'.{stem}.',
+ suffix='.creating',
+ dir=backup_directory,
+ )
+ os.close(descriptor)
+ temporary_path = pathlib.Path(temporary_name)
+ try:
+ with (
+ _open_read_only(database_path) as source,
+ sqlite3.connect(
+ temporary_path,
+ timeout=30,
+ ) as destination,
+ ):
+ source.execute('PRAGMA busy_timeout = 30000')
+ source.backup(destination)
+ destination.commit()
+ _verify_connection(destination, source_revision)
+ os.chmod(temporary_path, 0o600)
+ _fsync_file(temporary_path)
+ os.replace(temporary_path, backup_path)
+ _fsync_file(backup_path)
+ _fsync_directory(backup_directory)
+ backup = SQLiteMigrationBackup(
+ database_path=database_path,
+ backup_path=backup_path,
+ manifest_path=manifest_path,
+ source_revision=source_revision,
+ target_revision=target_revision,
+ created_at=created_at,
+ )
+ _write_manifest(backup, 'verified')
+ return backup
+ except Exception:
+ backup_path.unlink(missing_ok=True)
+ manifest_path.unlink(missing_ok=True)
+ raise
+ finally:
+ temporary_path.unlink(missing_ok=True)
+
+
+async def create_verified_backup(
+ engine: AsyncEngine,
+ *,
+ source_revision: str,
+ target_revision: str,
+) -> SQLiteMigrationBackup:
+ """Create and verify an online-consistent backup next to instance data."""
+
+ database_path = _database_path(engine)
+ return await asyncio.to_thread(
+ _create_backup,
+ database_path,
+ source_revision,
+ target_revision,
+ )
+
+
+def _restore_backup(backup: SQLiteMigrationBackup) -> None:
+ _verify_file(backup.backup_path, backup.source_revision)
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f'.{backup.database_path.name}.',
+ suffix='.restoring',
+ dir=backup.database_path.parent,
+ )
+ os.close(descriptor)
+ temporary_path = pathlib.Path(temporary_name)
+ try:
+ with (
+ _open_read_only(backup.backup_path) as source,
+ sqlite3.connect(
+ temporary_path,
+ timeout=30,
+ ) as destination,
+ ):
+ source.backup(destination)
+ destination.commit()
+ _verify_connection(destination, backup.source_revision)
+ os.chmod(temporary_path, 0o600)
+ _fsync_file(temporary_path)
+
+ # A stale WAL could replay pages from the failed migration after the
+ # main database file is replaced. The engine is disposed before this
+ # function runs, so these exact sidecars are safe to remove.
+ for suffix in ('-wal', '-shm', '-journal'):
+ pathlib.Path(f'{backup.database_path}{suffix}').unlink(missing_ok=True)
+ os.replace(temporary_path, backup.database_path)
+ _fsync_file(backup.database_path)
+ _fsync_directory(backup.database_path.parent)
+ _verify_file(backup.database_path, backup.source_revision)
+ finally:
+ temporary_path.unlink(missing_ok=True)
+
+
+async def restore_verified_backup(engine: AsyncEngine, backup: SQLiteMigrationBackup) -> None:
+ """Atomically restore a verified backup after a migration failure."""
+
+ await engine.dispose()
+ await asyncio.to_thread(_restore_backup, backup)
+ await asyncio.to_thread(
+ _write_manifest,
+ backup,
+ 'restored_after_failure',
+ restored_at=datetime.datetime.now(datetime.UTC).isoformat(),
+ )
+
+
+async def mark_migration_succeeded(
+ backup: SQLiteMigrationBackup,
+ *,
+ completed_revision: str,
+) -> None:
+ """Mark a retained verified backup after its migration boundary succeeds."""
+
+ await asyncio.to_thread(
+ _write_manifest,
+ backup,
+ 'migration_succeeded',
+ completed_at=datetime.datetime.now(datetime.UTC).isoformat(),
+ completed_revision=completed_revision,
+ )
diff --git a/src/langbot/pkg/persistence/tenant_uow.py b/src/langbot/pkg/persistence/tenant_uow.py
new file mode 100644
index 000000000..27db2d64a
--- /dev/null
+++ b/src/langbot/pkg/persistence/tenant_uow.py
@@ -0,0 +1,1446 @@
+from __future__ import annotations
+
+import asyncio
+import collections.abc
+import contextlib
+import contextvars
+import dataclasses
+import enum
+import functools
+import types
+import typing
+
+import sqlalchemy
+import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
+import sqlalchemy.orm as sqlalchemy_orm
+from pgvector.sqlalchemy import Vector
+from sqlalchemy.dialects.postgresql.dml import OnConflictDoNothing as PostgreSQLOnConflictDoNothing
+from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLOnConflictDoUpdate
+from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
+from sqlalchemy.dialects.sqlite.dml import OnConflictDoUpdate as SQLiteOnConflictDoUpdate
+
+
+TENANT_SETTING = 'langbot.workspace_uuid'
+ACCOUNT_SETTING = 'langbot.account_uuid'
+API_KEY_HASH_SETTING = 'langbot.api_key_hash'
+INVITATION_HASH_SETTING = 'langbot.invitation_hash'
+INSTANCE_SETTING = 'langbot.instance_uuid'
+IDENTITY_DIGEST_SETTING = 'langbot.identity_digest'
+DIRECTORY_INSTANCE_SETTING = 'langbot.directory_instance_uuid'
+
+TENANT_POLICY_NAME = 'langbot_workspace_isolation'
+LOCAL_DIRECTORY_WRITE_POLICY_NAME = 'langbot_workspace_local_directory_write'
+ACCOUNT_DISCOVERY_POLICY_NAME = 'langbot_account_discovery'
+API_KEY_DISCOVERY_POLICY_NAME = 'langbot_api_key_discovery'
+INVITATION_DISCOVERY_POLICY_NAME = 'langbot_invitation_discovery'
+INSTANCE_DISCOVERY_POLICY_NAME = 'langbot_instance_discovery'
+DIRECTORY_PROJECTION_POLICY_NAME = 'langbot_directory_projection'
+
+# Keep this contract explicit. A new tenant-owned table must be added to both
+# this runtime list and the corresponding Alembic migration before release.
+TENANT_TABLE_COLUMNS: dict[str, str] = {
+ 'workspaces': 'uuid',
+ 'workspace_memberships': 'workspace_uuid',
+ 'workspace_invitations': 'workspace_uuid',
+ 'workspace_execution_states': 'workspace_uuid',
+ 'workspace_metadata': 'workspace_uuid',
+ 'api_keys': 'workspace_uuid',
+ 'bots': 'workspace_uuid',
+ 'bot_admins': 'workspace_uuid',
+ 'binary_storages': 'workspace_uuid',
+ 'mcp_servers': 'workspace_uuid',
+ 'model_providers': 'workspace_uuid',
+ 'llm_models': 'workspace_uuid',
+ 'embedding_models': 'workspace_uuid',
+ 'rerank_models': 'workspace_uuid',
+ 'legacy_pipelines': 'workspace_uuid',
+ 'pipeline_run_records': 'workspace_uuid',
+ 'plugin_settings': 'workspace_uuid',
+ 'knowledge_bases': 'workspace_uuid',
+ 'knowledge_base_files': 'workspace_uuid',
+ 'knowledge_base_chunks': 'workspace_uuid',
+ 'webhooks': 'workspace_uuid',
+ 'monitoring_messages': 'workspace_uuid',
+ 'monitoring_llm_calls': 'workspace_uuid',
+ 'monitoring_tool_calls': 'workspace_uuid',
+ 'monitoring_sessions': 'workspace_uuid',
+ 'monitoring_errors': 'workspace_uuid',
+ 'monitoring_embedding_calls': 'workspace_uuid',
+ 'monitoring_feedback': 'workspace_uuid',
+ # Created by 0013 rather than ORM metadata; it is still part of the same
+ # business-database RLS contract and permits no discovery policies.
+ 'langbot_vectors': 'workspace_uuid',
+}
+
+DIRECTORY_PROJECTION_TABLE_COLUMNS: dict[str, str] = {
+ 'directory_projection_states': 'instance_uuid',
+ 'directory_projection_inbox': 'instance_uuid',
+}
+
+DIRECTORY_PROJECTED_TENANT_TABLES = frozenset(
+ {
+ 'workspaces',
+ 'workspace_memberships',
+ 'workspace_execution_states',
+ }
+)
+
+
+class PersistenceScopeKind(enum.StrEnum):
+ WORKSPACE = 'workspace'
+ ACCOUNT_DISCOVERY = 'account_discovery'
+ API_KEY_DISCOVERY = 'api_key_discovery'
+ INVITATION_DISCOVERY = 'invitation_discovery'
+ INSTANCE_DISCOVERY = 'instance_discovery'
+ IDENTITY_DISCOVERY = 'identity_discovery'
+ DIRECTORY_PROJECTION = 'directory_projection'
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class PersistenceScope:
+ """A trusted, transaction-local database visibility scope."""
+
+ kind: PersistenceScopeKind
+ settings: tuple[tuple[str, str], ...]
+
+ @classmethod
+ def workspace(cls, workspace_uuid: str) -> PersistenceScope:
+ return cls._one(PersistenceScopeKind.WORKSPACE, TENANT_SETTING, workspace_uuid)
+
+ @classmethod
+ def account(cls, account_uuid: str) -> PersistenceScope:
+ return cls._one(PersistenceScopeKind.ACCOUNT_DISCOVERY, ACCOUNT_SETTING, account_uuid)
+
+ @classmethod
+ def api_key(cls, key_hash: str) -> PersistenceScope:
+ return cls._one(PersistenceScopeKind.API_KEY_DISCOVERY, API_KEY_HASH_SETTING, key_hash)
+
+ @classmethod
+ def invitation(cls, token_hash: str) -> PersistenceScope:
+ return cls._one(PersistenceScopeKind.INVITATION_DISCOVERY, INVITATION_HASH_SETTING, token_hash)
+
+ @classmethod
+ def instance(cls, instance_uuid: str) -> PersistenceScope:
+ return cls._one(PersistenceScopeKind.INSTANCE_DISCOVERY, INSTANCE_SETTING, instance_uuid)
+
+ @classmethod
+ def identity(cls, identity_digest: str) -> PersistenceScope:
+ return cls._one(PersistenceScopeKind.IDENTITY_DISCOVERY, IDENTITY_DIGEST_SETTING, identity_digest)
+
+ @classmethod
+ def directory(cls, instance_uuid: str) -> PersistenceScope:
+ return cls._one(
+ PersistenceScopeKind.DIRECTORY_PROJECTION,
+ DIRECTORY_INSTANCE_SETTING,
+ instance_uuid,
+ )
+
+ @classmethod
+ def _one(cls, kind: PersistenceScopeKind, setting: str, value: str) -> PersistenceScope:
+ return cls(kind, ((setting, cls._normalize(value, kind.value)),))
+
+ @staticmethod
+ def _normalize(value: str, label: str) -> str:
+ if not isinstance(value, str):
+ raise TypeError(f'{label} must be a string')
+ normalized = value.strip()
+ if not normalized:
+ raise ValueError(f'{label} must not be empty')
+ if len(normalized) > 512:
+ raise ValueError(f'{label} exceeds the database scope limit')
+ return normalized
+
+
+class TenantScopeRequiredError(RuntimeError):
+ """Raised when Cloud business data is accessed without a trusted scope."""
+
+
+class CrossScopeTransactionError(RuntimeError):
+ """Raised when code tries to change scope inside an active transaction."""
+
+
+class TransactionRollbackOnlyError(RuntimeError):
+ """Raised when a caught failure made the outer transaction unsafe to commit."""
+
+
+class ScopedSessionTransactionError(RuntimeError):
+ """Raised when callers try to escape a UoW-owned transaction boundary."""
+
+
+@dataclasses.dataclass(slots=True)
+class ActiveScopedTransaction:
+ scope: PersistenceScope
+ session: sqlalchemy_asyncio.AsyncSession
+ engine: sqlalchemy_asyncio.AsyncEngine
+ owner_task: asyncio.Task[typing.Any] | None
+ depth: int = 1
+ rollback_only: bool = False
+ rollback_only_cause: BaseException | None = None
+ after_commit_waiters: list[asyncio.Future[None]] = dataclasses.field(default_factory=list)
+
+ def mark_rollback_only(self, cause: BaseException | None = None) -> None:
+ """Remember that this transaction must never release commit-gated work."""
+
+ self.rollback_only = True
+ if self.rollback_only_cause is None:
+ self.rollback_only_cause = cause
+
+
+_UOW_SESSION_CONTROL_CAPABILITY = object()
+
+
+@dataclasses.dataclass(slots=True)
+class _ScopedSessionGuardState:
+ owner_task: asyncio.Task[typing.Any] | None
+ transaction_state: ActiveScopedTransaction | None = None
+ active: bool = True
+ session_events_blocked: bool = False
+
+
+_SYNC_PROXY_CAPABILITY: contextvars.ContextVar[_ScopedSessionGuardState | None] = contextvars.ContextVar(
+ 'langbot_sync_session_proxy_capability',
+ default=None,
+)
+
+_ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
+ 'coalesce': sqlalchemy.sql.functions.coalesce,
+ 'count': sqlalchemy.sql.functions.count,
+ 'now': sqlalchemy.sql.functions.now,
+ 'sum': sqlalchemy.sql.functions.sum,
+}
+_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'length', 'nullif'})
+_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
+_ALLOWED_SCOPED_STATEMENT_TYPES = (
+ sqlalchemy.sql.dml.UpdateBase,
+ sqlalchemy.sql.selectable.SelectBase,
+)
+_ALLOWED_SCOPED_POST_VALUES_TYPES = (
+ PostgreSQLOnConflictDoNothing,
+ PostgreSQLOnConflictDoUpdate,
+ SQLiteOnConflictDoNothing,
+ SQLiteOnConflictDoUpdate,
+)
+
+
+def _collect_embedded_clause_elements(value: typing.Any) -> list[sqlalchemy.sql.elements.ClauseElement]:
+ """Find executable expressions in SQLAlchemy containers omitted by visitors."""
+
+ if isinstance(value, sqlalchemy.sql.elements.ClauseElement):
+ return [value]
+ if isinstance(value, collections.abc.Mapping):
+ elements: list[sqlalchemy.sql.elements.ClauseElement] = []
+ for key, item in value.items():
+ elements.extend(_collect_embedded_clause_elements(key))
+ elements.extend(_collect_embedded_clause_elements(item))
+ return elements
+ if isinstance(value, (list, tuple)):
+ elements = []
+ for item in value:
+ elements.extend(_collect_embedded_clause_elements(item))
+ return elements
+ clause_factory = getattr(value, '__clause_element__', None)
+ if callable(clause_factory):
+ clause_element = clause_factory()
+ if isinstance(clause_element, sqlalchemy.sql.elements.ClauseElement):
+ return [clause_element]
+ return []
+
+
+def _validate_opaque_identifier(
+ value: typing.Any,
+ *,
+ label: str,
+) -> list[sqlalchemy.sql.elements.ClauseElement]:
+ """Validate identifiers stored outside SQLAlchemy's normal AST traversal."""
+
+ if isinstance(value, sqlalchemy.sql.elements.ClauseElement):
+ return [value]
+ if not isinstance(value, str):
+ raise ScopedSessionTransactionError(f'TenantUnitOfWork does not allow an unknown {label}')
+ if isinstance(value, sqlalchemy.sql.elements.quoted_name) and value.quote is False:
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow forced-unquoted SQL identifiers')
+ if not value or value[0].isdigit() or any(not (character.isalnum() or character == '_') for character in value):
+ raise ScopedSessionTransactionError(f'TenantUnitOfWork does not allow an unsafe {label}')
+ return []
+
+
+def _validate_scoped_sql_type(
+ sql_type: typing.Any,
+ *,
+ seen: set[int] | None = None,
+) -> None:
+ """Reject caller-defined SQL compilers hidden behind otherwise safe nodes."""
+
+ if not isinstance(sql_type, sqlalchemy.types.TypeEngine):
+ return
+ if seen is None:
+ seen = set()
+ identity = id(sql_type)
+ if identity in seen:
+ return
+ seen.add(identity)
+
+ if type(sql_type) is Vector:
+ return
+ if not type(sql_type).__module__.startswith('sqlalchemy.'):
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
+
+ for identifier_attribute in ('name', 'schema', 'collation'):
+ identifier = getattr(sql_type, identifier_attribute, None)
+ if isinstance(identifier, sqlalchemy.sql.elements.quoted_name) and identifier.quote is False:
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow forced-unquoted SQL type identifiers')
+
+ nested_types: list[typing.Any] = [
+ getattr(sql_type, 'item_type', None),
+ getattr(sql_type, 'impl', None),
+ ]
+ nested_types.extend(getattr(sql_type, 'types', ()) or ())
+ for nested_type in nested_types:
+ if nested_type is not sql_type:
+ _validate_scoped_sql_type(nested_type, seen=seen)
+
+
+def _collect_multi_value_elements(
+ multi_values: typing.Any,
+) -> list[sqlalchemy.sql.elements.ClauseElement]:
+ """Validate batch INSERT rows which SQLAlchemy omits from AST visitors."""
+
+ children: list[sqlalchemy.sql.elements.ClauseElement] = []
+ for value_group in multi_values or ():
+ if not isinstance(value_group, (list, tuple)):
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow an unknown batch INSERT shape')
+ for row in value_group:
+ if isinstance(row, collections.abc.Mapping):
+ for key, value in row.items():
+ children.extend(_validate_opaque_identifier(key, label='batch INSERT column'))
+ children.extend(_collect_embedded_clause_elements(value))
+ elif isinstance(row, (list, tuple)):
+ children.extend(_collect_embedded_clause_elements(row))
+ else:
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow an unknown batch INSERT row')
+ return children
+
+
+def _iter_scoped_statement_elements(
+ statement: sqlalchemy.sql.elements.ClauseElement,
+) -> typing.Iterator[sqlalchemy.sql.elements.ClauseElement]:
+ """Walk the SQL AST, including dialect clauses SQLAlchemy omits from visitors.
+
+ PostgreSQL and SQLite ``ON CONFLICT`` clauses currently expose no children
+ through ``get_children()``. Their conflict targets and update expressions
+ still form executable SQL, so this walker adds those containers explicitly
+ and rejects any future, unknown post-values clause by default.
+ """
+
+ pending: list[sqlalchemy.sql.elements.ClauseElement] = [statement]
+ seen: set[int] = set()
+ while pending:
+ element = pending.pop()
+ identity = id(element)
+ if identity in seen:
+ continue
+ seen.add(identity)
+ yield element
+
+ children = [
+ child for child in element.get_children() if isinstance(child, sqlalchemy.sql.elements.ClauseElement)
+ ]
+ post_values_clause = getattr(element, '_post_values_clause', None)
+ if post_values_clause is not None:
+ if not isinstance(post_values_clause, _ALLOWED_SCOPED_POST_VALUES_TYPES):
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow an unknown dialect post-values SQL clause'
+ )
+ children.append(post_values_clause)
+
+ children.extend(_collect_multi_value_elements(getattr(element, '_multi_values', ())))
+
+ if isinstance(element, _ALLOWED_SCOPED_POST_VALUES_TYPES):
+ if getattr(element, 'constraint_target', None) is not None:
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow named ON CONFLICT constraints')
+ for target in getattr(element, 'inferred_target_elements', ()) or ():
+ children.extend(_validate_opaque_identifier(target, label='ON CONFLICT target'))
+ children.extend(_collect_embedded_clause_elements(getattr(element, 'inferred_target_whereclause', None)))
+ children.extend(_collect_embedded_clause_elements(getattr(element, 'update_whereclause', None)))
+ for update_pair in getattr(element, 'update_values_to_set', ()):
+ if not isinstance(update_pair, (list, tuple)) or len(update_pair) != 2:
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow an unknown ON CONFLICT update shape'
+ )
+ key, value = update_pair
+ children.extend(_validate_opaque_identifier(key, label='ON CONFLICT update column'))
+ children.extend(_collect_embedded_clause_elements(value))
+
+ pending.extend(children)
+
+
+def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> None:
+ """Admit only structured SQLAlchemy statements with a small safe vocabulary.
+
+ A textual denylist cannot be complete on PostgreSQL: ordinary built-in
+ functions can execute SQL supplied in string arguments, while statement
+ prefixes, suffixes, hints, and custom operators can inject syntax without
+ appearing as a top-level ``TextClause``. Tenant code therefore uses
+ SQLAlchemy's structured query/DML AST exclusively. Raw fragments and
+ unknown functions/operators fail closed before compilation.
+ """
+
+ statement = args[0] if args else kwargs.get('statement')
+ if statement is None:
+ return
+ if kwargs.get('execution_options'):
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow public SQL execution options')
+ if kwargs.get('bind_arguments'):
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow foreign database bind arguments')
+ if not isinstance(statement, _ALLOWED_SCOPED_STATEMENT_TYPES):
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork public SQL must use a structured SQLAlchemy query, values, or DML statement'
+ )
+
+ for element in _iter_scoped_statement_elements(statement):
+ if not type(element).__module__.startswith('sqlalchemy.'):
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow custom SQL AST nodes in public statements'
+ )
+
+ _validate_scoped_sql_type(getattr(element, 'type', None))
+
+ if isinstance(element, sqlalchemy.sql.selectable.Values):
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow SQL VALUES constructs in public statements'
+ )
+
+ if isinstance(element, sqlalchemy.sql.dml.Insert) and getattr(element, 'select', None) is not None:
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow INSERT FROM SELECT in public statements'
+ )
+
+ for identifier_attribute in ('name', 'schema', 'collation'):
+ identifier = getattr(element, identifier_attribute, None)
+ if isinstance(identifier, sqlalchemy.sql.elements.quoted_name) and identifier.quote is False:
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow forced-unquoted SQL identifiers')
+
+ if any(
+ getattr(element, attribute_name, None)
+ for attribute_name in (
+ '_prefixes',
+ '_suffixes',
+ '_hints',
+ '_statement_hints',
+ '_execution_options',
+ '_with_options',
+ )
+ ):
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow textual statement modifiers or execution options'
+ )
+
+ if (
+ isinstance(element, sqlalchemy.sql.elements.TextClause)
+ or getattr(
+ element,
+ '__visit_name__',
+ None,
+ )
+ == 'textual_label_reference'
+ ):
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow raw or textual SQL fragments in public statements'
+ )
+
+ if isinstance(element, sqlalchemy.sql.elements.ColumnClause) and element.is_literal and element.name != '*':
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow literal SQL columns in public statements'
+ )
+
+ if isinstance(element, sqlalchemy.sql.elements.Extract):
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork does not allow SQL EXTRACT fields in public statements'
+ )
+
+ if isinstance(element, sqlalchemy.sql.elements.BindParameter) and element.literal_execute:
+ raise ScopedSessionTransactionError('TenantUnitOfWork does not allow literal-execute SQL parameters')
+
+ if isinstance(element, sqlalchemy.sql.elements.Cast) and type(element.type) is not Vector:
+ raise ScopedSessionTransactionError(
+ 'TenantUnitOfWork only allows the trusted pgvector cast used by tenant vector search'
+ )
+
+ if isinstance(element, sqlalchemy.sql.functions.FunctionElement):
+ function_name = str(getattr(element, 'name', '')).casefold()
+ package_names = tuple(getattr(element, 'packagenames', ()))
+ generic_function_allowed = (
+ type(element) is sqlalchemy.sql.functions.Function
+ and function_name in _ALLOWED_SCOPED_GENERIC_FUNCTIONS
+ )
+ builtin_function_allowed = _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES.get(function_name) is type(element)
+ if package_names or not (generic_function_allowed or builtin_function_allowed):
+ raise ScopedSessionTransactionError(
+ f'TenantUnitOfWork does not allow SQL function {function_name or ""!r}'
+ )
+
+ for operator_attribute in ('operator', 'modifier'):
+ operator = getattr(element, operator_attribute, None)
+ if not isinstance(operator, sqlalchemy.sql.operators.custom_op):
+ continue
+ operator_name = str(operator.opstring)
+ if not (
+ isinstance(element, sqlalchemy.sql.elements.BinaryExpression)
+ and operator_attribute == 'operator'
+ and operator_name in _ALLOWED_SCOPED_CUSTOM_OPERATORS
+ ):
+ raise ScopedSessionTransactionError(
+ f'TenantUnitOfWork does not allow custom SQL operator {operator_name!r}'
+ )
+
+
+class _NoopSessionEventCollection:
+ """Empty SQLAlchemy SessionEvents collection used after fail-closed detection."""
+
+ def __bool__(self) -> bool:
+ return False
+
+ def __iter__(self) -> typing.Iterator[typing.Any]:
+ return iter(())
+
+ def __call__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
+ del args, kwargs
+
+
+class _NoopSessionEventsDispatch:
+ """Prevent a rejected SessionEvents listener from firing during rollback cleanup."""
+
+ _event_names: tuple[str, ...] = ()
+
+ def __getattr__(self, name: str) -> _NoopSessionEventCollection:
+ del name
+ return _NOOP_SESSION_EVENT_COLLECTION
+
+
+_NOOP_SESSION_EVENT_COLLECTION = _NoopSessionEventCollection()
+_NOOP_SESSION_EVENTS_DISPATCH = _NoopSessionEventsDispatch()
+
+
+class TenantScopedSyncSession(sqlalchemy_orm.Session):
+ """Synchronous proxy usable only from its owning AsyncSession operation."""
+
+ def __init__(
+ self,
+ bind: sqlalchemy.engine.Engine | sqlalchemy.engine.Connection | None = None,
+ *,
+ binds: dict[typing.Any, typing.Any] | None = None,
+ langbot_guard_state: _ScopedSessionGuardState,
+ **kwargs: typing.Any,
+ ) -> None:
+ if binds:
+ raise ScopedSessionTransactionError('Tenant-scoped Sessions cannot use multiple database binds')
+ object.__setattr__(self, '_langbot_guard_state', langbot_guard_state)
+ object.__setattr__(self, '_langbot_expected_bind', bind)
+ object.__setattr__(self, '_langbot_initializing', True)
+ try:
+ super().__init__(bind=bind, binds=None, **kwargs)
+ finally:
+ object.__setattr__(self, '_langbot_initializing', False)
+
+ def __getattribute__(self, name: str) -> typing.Any:
+ if name.startswith('_'):
+ return super().__getattribute__(name)
+ self._require_proxy_capability()
+ guard = object.__getattribute__(self, '_langbot_guard_state')
+ if name == 'dispatch' and guard.session_events_blocked:
+ return _NOOP_SESSION_EVENTS_DISPATCH
+ attribute = super().__getattribute__(name)
+ if name == 'dispatch' and not object.__getattribute__(self, '_langbot_initializing'):
+ event_name = next(
+ (candidate for candidate in attribute._event_names if bool(getattr(attribute, candidate))),
+ None,
+ )
+ if event_name is not None:
+ guard.session_events_blocked = True
+ self._reject_proxy_escape(f'ORM Session event listener {event_name}')
+ if isinstance(attribute, types.MethodType):
+ return self._guard_method_call(attribute)
+ return attribute
+
+ def __setattr__(self, name: str, value: typing.Any) -> None:
+ if name.startswith('_') or object.__getattribute__(self, '_langbot_initializing'):
+ super().__setattr__(name, value)
+ return
+ self._require_proxy_capability()
+ super().__setattr__(name, value)
+
+ def __delattr__(self, name: str) -> None:
+ if name.startswith('_') or object.__getattribute__(self, '_langbot_initializing'):
+ super().__delattr__(name)
+ return
+ self._require_proxy_capability()
+ super().__delattr__(name)
+
+ def get_bind(
+ self,
+ mapper: typing.Any = None,
+ *,
+ clause: typing.Any = None,
+ bind: typing.Any = None,
+ **kwargs: typing.Any,
+ ) -> sqlalchemy.engine.Engine | sqlalchemy.engine.Connection:
+ self._require_proxy_capability()
+ expected_bind = object.__getattribute__(self, '_langbot_expected_bind')
+ if bind is not None and bind is not expected_bind:
+ self._reject_proxy_escape('foreign database bind')
+ resolved = super().get_bind(mapper, clause=clause, bind=bind, **kwargs)
+ if resolved is not expected_bind:
+ self._reject_proxy_escape('foreign database bind')
+ return resolved
+
+ def flush(self, objects: typing.Sequence[typing.Any] | None = None) -> None:
+ """Reject caller-supplied SQL expressions hidden in ORM state."""
+
+ self._require_proxy_capability()
+ for instance in self.new.union(self.dirty):
+ state = sqlalchemy.inspect(instance)
+ for column_property in state.mapper.column_attrs:
+ for value in state.attrs[column_property.key].history.added:
+ if isinstance(value, sqlalchemy.sql.elements.ClauseElement):
+ self._reject_proxy_escape('ORM SQL expression attribute value')
+ clause_factory = getattr(value, '__clause_element__', None)
+ if callable(clause_factory) and isinstance(
+ clause_factory(),
+ sqlalchemy.sql.elements.ClauseElement,
+ ):
+ self._reject_proxy_escape('ORM SQL expression attribute value')
+ super().flush(objects)
+
+ def _guard_method_call(self, method: types.MethodType) -> typing.Callable[..., typing.Any]:
+ @functools.wraps(method)
+ def guarded(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ self._require_proxy_capability()
+ return method(*args, **kwargs)
+
+ return guarded
+
+ def _require_proxy_capability(self) -> None:
+ if object.__getattribute__(self, '_langbot_initializing'):
+ return
+ guard = object.__getattribute__(self, '_langbot_guard_state')
+ if not guard.active:
+ raise ScopedSessionTransactionError('TenantUnitOfWork scoped Session is no longer active')
+ try:
+ current_task = asyncio.current_task()
+ except RuntimeError:
+ current_task = None
+ if guard.owner_task is not current_task:
+ raise CrossScopeTransactionError(
+ 'Scoped database sessions cannot be inherited by child tasks; open an explicit task scope'
+ )
+ if _SYNC_PROXY_CAPABILITY.get() is not guard:
+ self._reject_proxy_escape('synchronous Session access')
+
+ def _reject_proxy_escape(self, operation: str) -> typing.NoReturn:
+ guard = object.__getattribute__(self, '_langbot_guard_state')
+ if guard.transaction_state is not None:
+ error = ScopedSessionTransactionError(
+ f'TenantUnitOfWork owns AsyncSession lifecycle; direct {operation} is not allowed'
+ )
+ guard.transaction_state.mark_rollback_only(error)
+ raise error
+ raise ScopedSessionTransactionError(
+ f'TenantUnitOfWork owns AsyncSession lifecycle; direct {operation} is not allowed'
+ )
+
+
+class TenantScopedAsyncSession(sqlalchemy_asyncio.AsyncSession):
+ """An AsyncSession whose task and transaction lifecycle are UoW-owned.
+
+ Normal ORM operations remain available to the task which opened the UoW.
+ Transaction-control and connection-escape APIs are deliberately withheld:
+ committing, rolling back, or closing the raw Session would otherwise let a
+ nested caller defeat the outer unit of work. A blanket public-attribute
+ owner check also covers a Session captured in the parent task and later
+ passed directly to a child task.
+ """
+
+ def __init__(
+ self,
+ bind: sqlalchemy_asyncio.AsyncEngine,
+ *,
+ owner_task: asyncio.Task[typing.Any] | None,
+ **kwargs: typing.Any,
+ ) -> None:
+ guard = _ScopedSessionGuardState(owner_task=owner_task)
+ object.__setattr__(self, '_langbot_guard_state', guard)
+ object.__setattr__(self, '_langbot_bind', None)
+ object.__setattr__(self, '_langbot_internal_access_depth', 0)
+ object.__setattr__(self, '_langbot_sync_capability_tokens', [])
+ object.__setattr__(self, '_langbot_initializing', True)
+ capability_token = _SYNC_PROXY_CAPABILITY.set(guard)
+ try:
+ super().__init__(
+ bind,
+ sync_session_class=TenantScopedSyncSession,
+ langbot_guard_state=guard,
+ **kwargs,
+ )
+ finally:
+ _SYNC_PROXY_CAPABILITY.reset(capability_token)
+ object.__setattr__(self, '_langbot_initializing', False)
+
+ def __getattribute__(self, name: str) -> typing.Any:
+ # ContextVars are copied into asyncio child tasks, and callers can also
+ # copy the Session object itself. Guard the whole supported public
+ # surface rather than trying to enumerate every current/future ORM I/O
+ # method. Private attributes remain an internal implementation detail.
+ if name.startswith('_'):
+ return super().__getattribute__(name)
+
+ self._require_owner_task()
+ if (
+ name in {'sync_session', 'binds', 'object_session'}
+ and object.__getattribute__(self, '_langbot_internal_access_depth') == 0
+ ):
+ self._reject_transaction_escape(f'{name} access')
+ self._enter_internal_access()
+ try:
+ attribute = super().__getattribute__(name)
+ finally:
+ self._exit_internal_access()
+ if isinstance(attribute, types.MethodType):
+ return self._guard_method_call(attribute)
+ return attribute
+
+ def __setattr__(self, name: str, value: typing.Any) -> None:
+ if name.startswith('_'):
+ super().__setattr__(name, value)
+ return
+ self._require_owner_task()
+ if not object.__getattribute__(self, '_langbot_initializing') and name in {'bind', 'binds', 'sync_session'}:
+ self._reject_transaction_escape(f'{name} replacement')
+ super().__setattr__(name, value)
+
+ def __delattr__(self, name: str) -> None:
+ if name.startswith('_'):
+ super().__delattr__(name)
+ return
+ self._require_owner_task()
+ if name in {'bind', 'binds', 'sync_session'}:
+ self._reject_transaction_escape(f'{name} deletion')
+ super().__delattr__(name)
+
+ @property
+ def bind(self) -> typing.NoReturn:
+ self._reject_transaction_escape('bind access')
+
+ @bind.setter
+ def bind(self, value: sqlalchemy_asyncio.AsyncEngine) -> None:
+ existing = object.__getattribute__(self, '_langbot_bind')
+ if existing is not None:
+ self._reject_transaction_escape('bind replacement')
+ object.__setattr__(self, '_langbot_bind', value)
+
+ @property
+ def no_autoflush(self) -> typing.ContextManager[None]:
+ """Preserve SQLAlchemy's helper without exposing the synchronous Session."""
+
+ @contextlib.contextmanager
+ def boundary() -> typing.Iterator[None]:
+ self._require_owner_task()
+ sync_session = object.__getattribute__(self, '_proxied')
+ previous = object.__getattribute__(sync_session, 'autoflush')
+ object.__setattr__(sync_session, 'autoflush', False)
+ try:
+ yield
+ finally:
+ self._require_owner_task()
+ object.__setattr__(sync_session, 'autoflush', previous)
+
+ return boundary()
+
+ def _bind_transaction_state(self, capability: object, state: ActiveScopedTransaction) -> None:
+ self._require_control_capability(capability, 'transaction-state binding')
+ self._require_owner_task()
+ object.__getattribute__(self, '_langbot_guard_state').transaction_state = state
+
+ def begin(self) -> typing.NoReturn:
+ self._reject_transaction_escape('begin')
+
+ def begin_nested(self) -> typing.NoReturn:
+ self._reject_transaction_escape('begin_nested')
+
+ async def commit(self) -> typing.NoReturn:
+ self._reject_transaction_escape('commit')
+
+ async def rollback(self) -> typing.NoReturn:
+ self._reject_transaction_escape('rollback')
+
+ async def close(self) -> typing.NoReturn:
+ self._reject_transaction_escape('close')
+
+ async def aclose(self) -> typing.NoReturn:
+ self._reject_transaction_escape('aclose')
+
+ async def reset(self) -> typing.NoReturn:
+ self._reject_transaction_escape('reset')
+
+ async def invalidate(self) -> typing.NoReturn:
+ self._reject_transaction_escape('invalidate')
+
+ async def close_all(self) -> typing.NoReturn:
+ self._reject_transaction_escape('close_all')
+
+ async def connection(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn:
+ del args, kwargs
+ self._reject_transaction_escape('connection')
+
+ def get_bind(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn:
+ del args, kwargs
+ self._reject_transaction_escape('get_bind')
+
+ def get_transaction(self) -> typing.NoReturn:
+ self._reject_transaction_escape('get_transaction')
+
+ def get_nested_transaction(self) -> typing.NoReturn:
+ self._reject_transaction_escape('get_nested_transaction')
+
+ async def run_sync(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn:
+ del args, kwargs
+ self._reject_transaction_escape('run_sync')
+
+ async def __aenter__(self) -> typing.NoReturn:
+ self._reject_transaction_escape('context-manager entry')
+
+ async def __aexit__(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn:
+ del args, kwargs
+ self._reject_transaction_escape('context-manager exit')
+
+ async def _start_owned_transaction(self, capability: object) -> sqlalchemy_asyncio.AsyncSessionTransaction:
+ """Start the one root transaction; callable only by TenantUnitOfWork."""
+
+ self._require_control_capability(capability, 'owned transaction start')
+ self._require_owner_task()
+ self._enter_internal_access()
+ try:
+ transaction = super().begin()
+ await transaction
+ return transaction
+ finally:
+ self._exit_internal_access()
+
+ async def _close_owned_session(self, capability: object) -> None:
+ """Close the Session after its root transaction has been finalized."""
+
+ self._require_control_capability(capability, 'owned Session close')
+ self._require_owner_task()
+ self._enter_internal_access()
+ try:
+ await super().close()
+ finally:
+ try:
+ self._exit_internal_access()
+ finally:
+ guard = object.__getattribute__(self, '_langbot_guard_state')
+ guard.active = False
+ guard.transaction_state = None
+ guard.owner_task = None
+
+ async def _commit_owned_transaction(
+ self,
+ capability: object,
+ transaction: sqlalchemy_asyncio.AsyncSessionTransaction,
+ ) -> None:
+ self._require_control_capability(capability, 'owned transaction commit')
+ self._require_owner_task()
+ self._enter_internal_access()
+ try:
+ await transaction.commit()
+ finally:
+ self._exit_internal_access()
+
+ async def _rollback_owned_transaction(
+ self,
+ capability: object,
+ transaction: sqlalchemy_asyncio.AsyncSessionTransaction,
+ ) -> None:
+ self._require_control_capability(capability, 'owned transaction rollback')
+ self._require_owner_task()
+ self._enter_internal_access()
+ try:
+ await transaction.rollback()
+ finally:
+ self._exit_internal_access()
+
+ async def _apply_owned_scope_setting(
+ self,
+ capability: object,
+ setting_name: str,
+ setting_value: str,
+ ) -> None:
+ self._require_control_capability(capability, 'tenant scope configuration')
+ if setting_name not in {
+ TENANT_SETTING,
+ ACCOUNT_SETTING,
+ API_KEY_HASH_SETTING,
+ INVITATION_HASH_SETTING,
+ INSTANCE_SETTING,
+ IDENTITY_DIGEST_SETTING,
+ DIRECTORY_INSTANCE_SETTING,
+ }:
+ self._reject_transaction_escape('unknown tenant scope configuration')
+ self._require_owner_task()
+ self._enter_internal_access()
+ try:
+ await super().execute(
+ sqlalchemy.text('SELECT set_config(:setting_name, :setting_value, true)'),
+ {'setting_name': setting_name, 'setting_value': setting_value},
+ )
+ finally:
+ self._exit_internal_access()
+
+ async def execute_on_transaction_connection(
+ self,
+ *args: typing.Any,
+ **kwargs: typing.Any,
+ ) -> sqlalchemy.engine.cursor.CursorResult[typing.Any]:
+ """Execute Core SQL without exposing the transaction-bound Connection."""
+
+ self._require_owner_task()
+ self._validate_statement_or_reject(args, kwargs)
+ self._enter_internal_access()
+ try:
+ await super().flush()
+ connection = await super().connection()
+ return await connection.execute(*args, **kwargs)
+ finally:
+ self._exit_internal_access()
+
+ async def execute(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.Result[typing.Any]:
+ self._validate_statement_or_reject(args, kwargs)
+ return await super().execute(*args, **kwargs)
+
+ async def get(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ self._reject_nonempty_orm_options(
+ 'get',
+ kwargs,
+ {'options', 'with_for_update', 'identity_token', 'execution_options', 'bind_arguments'},
+ )
+ return await super().get(*args, **kwargs)
+
+ async def get_one(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ self._reject_nonempty_orm_options(
+ 'get_one',
+ kwargs,
+ {'options', 'with_for_update', 'identity_token', 'execution_options', 'bind_arguments'},
+ )
+ return await super().get_one(*args, **kwargs)
+
+ async def refresh(self, *args: typing.Any, **kwargs: typing.Any) -> None:
+ self._reject_nonempty_orm_options('refresh', kwargs, {'with_for_update'})
+ await super().refresh(*args, **kwargs)
+
+ async def merge(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ self._reject_nonempty_orm_options('merge', kwargs, {'options'})
+ return await super().merge(*args, **kwargs)
+
+ async def scalar(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ self._validate_statement_or_reject(args, kwargs)
+ return await super().scalar(*args, **kwargs)
+
+ async def scalars(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.ScalarResult[typing.Any]:
+ self._validate_statement_or_reject(args, kwargs)
+ return await super().scalars(*args, **kwargs)
+
+ async def stream(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy_asyncio.AsyncResult[typing.Any]:
+ del args, kwargs
+ self._reject_transaction_escape('stream; live database results cannot outlive the task-owned UoW operation')
+
+ async def stream_scalars(
+ self,
+ *args: typing.Any,
+ **kwargs: typing.Any,
+ ) -> sqlalchemy_asyncio.AsyncScalarResult[typing.Any]:
+ del args, kwargs
+ self._reject_transaction_escape(
+ 'stream_scalars; live database results cannot outlive the task-owned UoW operation'
+ )
+
+ def _validate_statement_or_reject(
+ self,
+ args: tuple[typing.Any, ...],
+ kwargs: dict[str, typing.Any],
+ ) -> None:
+ try:
+ _validate_scoped_statement_call(args, kwargs)
+ except ScopedSessionTransactionError as exc:
+ guard = object.__getattribute__(self, '_langbot_guard_state')
+ if guard.transaction_state is not None:
+ guard.transaction_state.mark_rollback_only(exc)
+ raise
+
+ def _reject_nonempty_orm_options(
+ self,
+ operation: str,
+ kwargs: dict[str, typing.Any],
+ option_names: set[str],
+ ) -> None:
+ for option_name in option_names:
+ value = kwargs.get(option_name)
+ if option_name == 'with_for_update':
+ if value is None or value is False:
+ continue
+ self._reject_transaction_escape(f'{operation} option {option_name}')
+ if option_name == 'identity_token':
+ if value is None:
+ continue
+ self._reject_transaction_escape(f'{operation} option {option_name}')
+ if value is None or value is False:
+ continue
+ if isinstance(value, collections.abc.Sized) and len(value) == 0:
+ continue
+ self._reject_transaction_escape(f'{operation} option {option_name}')
+
+ def _guard_method_call(self, method: types.MethodType) -> typing.Callable[..., typing.Any]:
+ if asyncio.iscoroutinefunction(method):
+
+ @functools.wraps(method)
+ async def guarded_async(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ self._require_owner_task()
+ self._enter_internal_access()
+ try:
+ return await method(*args, **kwargs)
+ finally:
+ self._exit_internal_access()
+
+ return guarded_async
+
+ @functools.wraps(method)
+ def guarded_sync(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
+ self._require_owner_task()
+ self._enter_internal_access()
+ try:
+ return method(*args, **kwargs)
+ finally:
+ self._exit_internal_access()
+
+ return guarded_sync
+
+ def _enter_internal_access(self) -> None:
+ depth = object.__getattribute__(self, '_langbot_internal_access_depth')
+ guard = object.__getattribute__(self, '_langbot_guard_state')
+ tokens = object.__getattribute__(self, '_langbot_sync_capability_tokens')
+ tokens.append(_SYNC_PROXY_CAPABILITY.set(guard))
+ object.__setattr__(self, '_langbot_internal_access_depth', depth + 1)
+
+ def _exit_internal_access(self) -> None:
+ depth = object.__getattribute__(self, '_langbot_internal_access_depth')
+ if depth <= 0:
+ raise RuntimeError('Scoped Session internal access stack underflow')
+ tokens = object.__getattribute__(self, '_langbot_sync_capability_tokens')
+ _SYNC_PROXY_CAPABILITY.reset(tokens.pop())
+ object.__setattr__(self, '_langbot_internal_access_depth', depth - 1)
+
+ def _require_control_capability(self, capability: object, operation: str) -> None:
+ if capability is not _UOW_SESSION_CONTROL_CAPABILITY:
+ self._reject_transaction_escape(operation)
+
+ def _require_owner_task(self) -> None:
+ guard = object.__getattribute__(self, '_langbot_guard_state')
+ if not guard.active:
+ raise ScopedSessionTransactionError('TenantUnitOfWork scoped Session is no longer active')
+ owner_task = guard.owner_task
+ try:
+ current_task = asyncio.current_task()
+ except RuntimeError:
+ current_task = None
+ if owner_task is not current_task:
+ raise CrossScopeTransactionError(
+ 'Scoped database sessions cannot be inherited by child tasks; open an explicit task scope'
+ )
+
+ def _reject_transaction_escape(self, operation: str) -> typing.NoReturn:
+ self._require_owner_task()
+ error = ScopedSessionTransactionError(
+ f'TenantUnitOfWork owns AsyncSession transaction lifecycle; direct {operation} is not allowed'
+ )
+ state = object.__getattribute__(self, '_langbot_guard_state').transaction_state
+ if state is not None:
+ state.mark_rollback_only(error)
+ raise error
+
+
+ActiveTransactionVar = contextvars.ContextVar[ActiveScopedTransaction | None]
+
+# ``AsyncSession`` delegates database I/O to a greenlet-backed synchronous
+# Engine, but Python context variables are preserved across that boundary. A
+# per-engine ``handle_error`` listener therefore covers DBAPI failures from all
+# direct Session APIs (execute/scalar/get/flush) as well as callers which obtain
+# the transaction-bound Connection. The owner-task and engine checks prevent
+# copied child-task contexts or unrelated database engines from poisoning this
+# transaction.
+_DATABASE_OPERATION_TRANSACTION: contextvars.ContextVar[ActiveScopedTransaction | None] = contextvars.ContextVar(
+ 'langbot_database_operation_transaction',
+ default=None,
+)
+
+
+def _mark_current_transaction_rollback_only(exception_context: typing.Any) -> None:
+ state = _DATABASE_OPERATION_TRANSACTION.get()
+ if state is None:
+ return
+ try:
+ current_task = asyncio.current_task()
+ except RuntimeError: # pragma: no cover - async engines always run in an event loop
+ return
+ if state.owner_task is not current_task or exception_context.engine is not state.engine.sync_engine:
+ return
+ cause = exception_context.sqlalchemy_exception or exception_context.original_exception
+ state.mark_rollback_only(cause)
+
+
+def _install_rollback_only_error_listener(engine: sqlalchemy_asyncio.AsyncEngine) -> None:
+ sync_engine = engine.sync_engine
+ if not sqlalchemy.event.contains(sync_engine, 'handle_error', _mark_current_transaction_rollback_only):
+ sqlalchemy.event.listen(sync_engine, 'handle_error', _mark_current_transaction_rollback_only)
+
+
+@dataclasses.dataclass(slots=True)
+class ActivePersistenceScope:
+ """A trusted visibility scope without an open database transaction."""
+
+ scope: PersistenceScope
+ owner_task: asyncio.Task[typing.Any] | None
+ depth: int = 1
+
+
+ActivePersistenceScopeVar = contextvars.ContextVar[ActivePersistenceScope | None]
+
+
+class PersistenceScopeBoundary:
+ """Carry a trusted persistence scope across long-running async work.
+
+ Unlike :class:`TenantUnitOfWork`, this boundary never opens a database
+ session. ``PersistenceManager.execute_async`` materializes the scope as a
+ short transaction for each database call. This makes the boundary suitable
+ for request and pipeline lifetimes which can spend substantial time waiting
+ on model providers, tools, or streaming clients.
+
+ Context variables are copied into child tasks, so implicit use from a child
+ task is rejected by the manager. A child may establish its own explicit
+ boundary because the scope value still comes from trusted application code.
+ """
+
+ def __init__(
+ self,
+ scope: PersistenceScope,
+ *,
+ active_scope: ActivePersistenceScopeVar,
+ active_transaction: ActiveTransactionVar,
+ ) -> None:
+ self.scope = scope
+ self._active_scope = active_scope
+ self._active_transaction = active_transaction
+ self._active_state: ActivePersistenceScope | None = None
+ self._context_token: contextvars.Token[ActivePersistenceScope | None] | None = None
+ self._used = False
+ self._owns_scope = False
+
+ async def __aenter__(self) -> PersistenceScopeBoundary:
+ if self._used:
+ raise RuntimeError('PersistenceScopeBoundary instances cannot be reused')
+ self._used = True
+
+ transaction = self._active_transaction.get()
+ if transaction is not None:
+ if transaction.owner_task is not asyncio.current_task():
+ raise CrossScopeTransactionError(
+ 'Scoped database transactions cannot be inherited by child tasks; open an explicit task scope'
+ )
+ if transaction.scope != self.scope:
+ raise CrossScopeTransactionError(
+ f'Cannot enter {self.scope.kind.value} scope while {transaction.scope.kind.value} scope is active'
+ )
+
+ active = self._active_scope.get()
+ if active is not None and active.owner_task is asyncio.current_task():
+ if active.scope != self.scope:
+ raise CrossScopeTransactionError(
+ f'Cannot enter {self.scope.kind.value} scope while {active.scope.kind.value} scope is active'
+ )
+ active.depth += 1
+ self._active_state = active
+ return self
+
+ state = ActivePersistenceScope(
+ scope=self.scope,
+ owner_task=asyncio.current_task(),
+ )
+ self._active_state = state
+ self._context_token = self._active_scope.set(state)
+ self._owns_scope = True
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: types.TracebackType | None,
+ ) -> bool:
+ del exc_type, exc_value, traceback
+ state = self._active_state
+ if state is None:
+ raise RuntimeError('PersistenceScopeBoundary has no active state')
+ if state.owner_task is not asyncio.current_task():
+ raise CrossScopeTransactionError('Scoped persistence boundaries cannot be exited by a different task')
+
+ if not self._owns_scope:
+ state.depth -= 1
+ self._active_state = None
+ return False
+
+ if self._context_token is None:
+ raise RuntimeError('PersistenceScopeBoundary has no context token')
+ self._active_scope.reset(self._context_token)
+ state.depth = 0
+ self._active_state = None
+ return False
+
+
+class TenantUnitOfWork:
+ """Bind one trusted visibility scope to one database transaction.
+
+ PostgreSQL receives transaction-local settings consumed by RLS policies.
+ SQLite keeps the same transaction boundary so OSS code exercises the same
+ request/task ownership and rollback semantics. A manager-owned ContextVar
+ lets all legacy ``execute_async`` helpers reuse this exact session.
+ """
+
+ def __init__(
+ self,
+ engine: sqlalchemy_asyncio.AsyncEngine,
+ workspace_uuid: str | None = None,
+ *,
+ scope: PersistenceScope | None = None,
+ active_transaction: ActiveTransactionVar | None = None,
+ active_scope: ActivePersistenceScopeVar | None = None,
+ on_pool_timeout: typing.Callable[[], None] | None = None,
+ ) -> None:
+ if (workspace_uuid is None) == (scope is None):
+ raise ValueError('TenantUnitOfWork requires exactly one Workspace or persistence scope')
+
+ self._engine = engine
+ self.scope = scope or PersistenceScope.workspace(typing.cast(str, workspace_uuid))
+ self.workspace_uuid = self.scope.settings[0][1] if self.scope.kind == PersistenceScopeKind.WORKSPACE else None
+ self._active_transaction = active_transaction
+ self._active_scope = active_scope
+ self._on_pool_timeout = on_pool_timeout
+ self._session: sqlalchemy_asyncio.AsyncSession | None = None
+ self._transaction: sqlalchemy_asyncio.AsyncSessionTransaction | None = None
+ self._active_state: ActiveScopedTransaction | None = None
+ self._context_token: contextvars.Token[ActiveScopedTransaction | None] | None = None
+ self._database_operation_token: contextvars.Token[ActiveScopedTransaction | None] | None = None
+ self._used = False
+ self._owns_transaction = False
+ _install_rollback_only_error_listener(engine)
+
+ @property
+ def session(self) -> sqlalchemy_asyncio.AsyncSession:
+ if self._session is None:
+ raise RuntimeError('TenantUnitOfWork is not active')
+ if self._active_state is not None:
+ self._require_owner_task(self._active_state)
+ return self._session
+
+ async def __aenter__(self) -> TenantUnitOfWork:
+ if self._used:
+ raise RuntimeError('TenantUnitOfWork instances cannot be reused')
+ self._used = True
+
+ active_scope = self._active_scope.get() if self._active_scope is not None else None
+ if active_scope is not None and active_scope.owner_task is asyncio.current_task():
+ if active_scope.scope != self.scope:
+ # A request/runtime Workspace boundary is transaction-free and
+ # may temporarily enter a narrower trusted discovery UoW (for
+ # example, resolving an account record by its verified UUID).
+ # It must still never switch directly to another Workspace.
+ workspace_to_discovery = (
+ active_scope.scope.kind == PersistenceScopeKind.WORKSPACE
+ and self.scope.kind
+ in {
+ PersistenceScopeKind.ACCOUNT_DISCOVERY,
+ PersistenceScopeKind.API_KEY_DISCOVERY,
+ PersistenceScopeKind.INVITATION_DISCOVERY,
+ PersistenceScopeKind.INSTANCE_DISCOVERY,
+ PersistenceScopeKind.IDENTITY_DISCOVERY,
+ }
+ )
+ if not workspace_to_discovery:
+ raise CrossScopeTransactionError(
+ f'Cannot enter {self.scope.kind.value} scope '
+ f'while {active_scope.scope.kind.value} scope is active'
+ )
+
+ active = self._active_transaction.get() if self._active_transaction is not None else None
+ if active is not None:
+ if active.owner_task is asyncio.current_task():
+ if active.scope != self.scope:
+ raise CrossScopeTransactionError(
+ f'Cannot enter {self.scope.kind.value} scope while {active.scope.kind.value} scope is active'
+ )
+ active.depth += 1
+ self._active_state = active
+ self._session = active.session
+ return self
+ # ContextVars are copied into child tasks. Merely calling a helper
+ # there must fail (PersistenceManager enforces that), but an
+ # explicit UoW is allowed to replace the inherited pointer with an
+ # independent transaction owned by the child task.
+
+ owner_task = asyncio.current_task()
+ session = TenantScopedAsyncSession(
+ self._engine,
+ owner_task=owner_task,
+ expire_on_commit=False,
+ close_resets_only=False,
+ )
+ self._session = session
+ transaction: sqlalchemy_asyncio.AsyncSessionTransaction | None = None
+ try:
+ transaction = await session._start_owned_transaction(_UOW_SESSION_CONTROL_CAPABILITY)
+ self._transaction = transaction
+ if self._engine.dialect.name == 'postgresql':
+ for setting_name, setting_value in self.scope.settings:
+ await session._apply_owned_scope_setting(
+ _UOW_SESSION_CONTROL_CAPABILITY,
+ setting_name,
+ setting_value,
+ )
+ state = ActiveScopedTransaction(
+ scope=self.scope,
+ session=session,
+ engine=self._engine,
+ owner_task=owner_task,
+ )
+ session._bind_transaction_state(_UOW_SESSION_CONTROL_CAPABILITY, state)
+ self._active_state = state
+ if self._active_transaction is not None:
+ self._context_token = self._active_transaction.set(state)
+ self._database_operation_token = _DATABASE_OPERATION_TRANSACTION.set(state)
+ self._owns_transaction = True
+ except BaseException as exc:
+ if isinstance(exc, sqlalchemy.exc.TimeoutError) and self._on_pool_timeout is not None:
+ self._on_pool_timeout()
+ if self._database_operation_token is not None:
+ _DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
+ self._database_operation_token = None
+ if self._active_transaction is not None and self._context_token is not None:
+ self._active_transaction.reset(self._context_token)
+ self._context_token = None
+ if transaction is not None and transaction.sync_transaction is not None:
+ await session._rollback_owned_transaction(_UOW_SESSION_CONTROL_CAPABILITY, transaction)
+ await session._close_owned_session(_UOW_SESSION_CONTROL_CAPABILITY)
+ self._session = None
+ self._transaction = None
+ raise
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: types.TracebackType | None,
+ ) -> bool:
+ state = self._active_state
+ if state is None:
+ raise RuntimeError('TenantUnitOfWork has no active transaction state')
+ self._require_owner_task(state)
+
+ if not self._owns_transaction:
+ if exc_type is not None:
+ state.mark_rollback_only(exc_value)
+ state.depth -= 1
+ self._session = None
+ self._active_state = None
+ return False
+
+ session = self.session
+ transaction = self._transaction
+ if transaction is None:
+ raise RuntimeError('TenantUnitOfWork has no active root transaction')
+ if exc_type is not None:
+ state.mark_rollback_only(exc_value)
+ rollback_only = state.rollback_only
+ committed = False
+ try:
+ if exc_type is None and not rollback_only:
+ await typing.cast(TenantScopedAsyncSession, session)._commit_owned_transaction(
+ _UOW_SESSION_CONTROL_CAPABILITY,
+ transaction,
+ )
+ committed = True
+ else:
+ await typing.cast(TenantScopedAsyncSession, session)._rollback_owned_transaction(
+ _UOW_SESSION_CONTROL_CAPABILITY,
+ transaction,
+ )
+ finally:
+ try:
+ if self._active_transaction is not None and self._context_token is not None:
+ self._active_transaction.reset(self._context_token)
+ await typing.cast(TenantScopedAsyncSession, session)._close_owned_session(
+ _UOW_SESSION_CONTROL_CAPABILITY
+ )
+ finally:
+ if self._database_operation_token is not None:
+ _DATABASE_OPERATION_TRANSACTION.reset(self._database_operation_token)
+ self._database_operation_token = None
+ self._session = None
+ self._transaction = None
+ self._active_state = None
+ state.depth = 0
+ self._complete_after_commit_waiters(state, committed=committed)
+
+ if exc_type is None and rollback_only:
+ raise TransactionRollbackOnlyError(
+ 'A scoped database operation failed or rolled back; '
+ 'the transaction was rolled back and after-commit work was cancelled'
+ ) from state.rollback_only_cause
+ return False
+
+ async def execute(self, *args: typing.Any, **kwargs: typing.Any) -> sqlalchemy.engine.Result[typing.Any]:
+ try:
+ return await self.session.execute(*args, **kwargs)
+ except BaseException as exc:
+ self._mark_rollback_only(exc)
+ raise
+
+ async def flush(self) -> None:
+ try:
+ await self.session.flush()
+ except BaseException as exc:
+ self._mark_rollback_only(exc)
+ raise
+
+ def _mark_rollback_only(self, cause: BaseException) -> None:
+ state = self._active_state
+ if state is not None:
+ state.mark_rollback_only(cause)
+
+ @staticmethod
+ def _require_owner_task(state: ActiveScopedTransaction) -> None:
+ if state.owner_task is not asyncio.current_task():
+ raise CrossScopeTransactionError(
+ 'Scoped database transactions cannot be inherited by child tasks; open an explicit task scope'
+ )
+
+ @staticmethod
+ def _complete_after_commit_waiters(
+ state: ActiveScopedTransaction,
+ *,
+ committed: bool,
+ ) -> None:
+ for waiter in state.after_commit_waiters:
+ if waiter.done():
+ continue
+ if committed:
+ waiter.set_result(None)
+ else:
+ waiter.cancel()
+ state.after_commit_waiters.clear()
diff --git a/src/langbot/pkg/pipeline/aggregator.py b/src/langbot/pkg/pipeline/aggregator.py
index 96358e329..c24468306 100644
--- a/src/langbot/pkg/pipeline/aggregator.py
+++ b/src/langbot/pkg/pipeline/aggregator.py
@@ -1,10 +1,4 @@
-"""Message Aggregator Module
-
-This module provides message aggregation/debounce functionality.
-When users send multiple messages consecutively, the aggregator will wait
-for a configurable delay period and merge them into a single message
-before processing.
-"""
+"""Workspace-scoped message aggregation and debounce support."""
from __future__ import annotations
@@ -13,96 +7,125 @@ import time
import typing
from dataclasses import dataclass, field
-import langbot_plugin.api.entities.builtin.platform.message as platform_message
-import langbot_plugin.api.entities.builtin.platform.events as platform_events
-import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
+import langbot_plugin.api.entities.builtin.platform.events as platform_events
+import langbot_plugin.api.entities.builtin.platform.message as platform_message
+import langbot_plugin.api.entities.builtin.provider.session as provider_session
+
+from ..api.http.context import ExecutionContext
+from ..core.task_boundary import create_detached_task, run_in_workspace_uow
+from .pool import ExecutionContextMismatchError
+from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
if typing.TYPE_CHECKING:
from ..core import app
-# Maximum number of messages to buffer before forcing a flush
MAX_BUFFER_MESSAGES = 10
+AggregationKey = tuple[
+ str,
+ str,
+ int,
+ str,
+ str | None,
+ str,
+ int | str,
+]
+
@dataclass
class PendingMessage:
- """A pending message waiting to be aggregated"""
+ """A pending message carrying its trusted execution scope."""
+ execution_context: ExecutionContext
bot_uuid: str
launcher_type: provider_session.LauncherTypes
- launcher_id: typing.Union[int, str]
- sender_id: typing.Union[int, str]
+ launcher_id: int | str
+ sender_id: int | str
message_event: platform_events.MessageEvent
message_chain: platform_message.MessageChain
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
- pipeline_uuid: typing.Optional[str]
+ pipeline_uuid: str | None
routed_by_rule: bool = False
timestamp: float = field(default_factory=time.time)
@dataclass
class SessionBuffer:
- """Buffer for a single session's pending messages"""
+ """Pending messages for one scoped aggregation key."""
- session_id: str
+ aggregation_key: AggregationKey
+ execution_context: ExecutionContext
messages: list[PendingMessage] = field(default_factory=list)
- timer_task: typing.Optional[asyncio.Task] = None
+ timer_task: asyncio.Task | None = None
last_message_time: float = field(default_factory=time.time)
class MessageAggregator:
- """Message aggregator that buffers and merges consecutive messages
-
- This class implements a debounce mechanism for incoming messages.
- When a message arrives, it starts a timer. If more messages arrive
- before the timer expires, they are buffered. When the timer expires,
- all buffered messages are merged and sent to the query pool.
- """
+ """Debounce consecutive messages without crossing Workspace boundaries."""
ap: app.Application
-
- buffers: dict[str, SessionBuffer]
- """Session ID -> SessionBuffer mapping"""
-
+ buffers: dict[AggregationKey, SessionBuffer]
lock: asyncio.Lock
- """Lock for thread-safe buffer operations"""
def __init__(self, ap: app.Application):
self.ap = ap
self.buffers = {}
+ self._buffer_counts_by_scope: dict[
+ tuple[str, str, int],
+ int,
+ ] = {}
self.lock = asyncio.Lock()
+ concurrency = self.ap.instance_config.data.get('concurrency', {})
+ self.max_buffers = max(int(concurrency.get('pending_queries', 1000)), 1)
+ self.max_buffers_per_workspace = max(
+ int(concurrency.get('pending_queries_per_workspace', 100)),
+ 1,
+ )
- def _get_session_id(
+ def _get_aggregation_key(
self,
+ execution_context: ExecutionContext,
bot_uuid: str,
launcher_type: provider_session.LauncherTypes,
- launcher_id: typing.Union[int, str],
- ) -> str:
- """Generate a unique session ID"""
- return f'{bot_uuid}:{launcher_type.value}:{launcher_id}'
+ launcher_id: int | str,
+ pipeline_uuid: str | None,
+ ) -> AggregationKey:
+ """Build a key that cannot alias another Workspace, bot, or pipeline."""
- async def _get_aggregation_config(self, pipeline_uuid: typing.Optional[str]) -> tuple[bool, float]:
- """Get aggregation configuration for a pipeline
+ return (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ bot_uuid,
+ pipeline_uuid,
+ launcher_type.value,
+ launcher_id,
+ )
+
+ async def _get_aggregation_config(
+ self,
+ execution_context: ExecutionContext,
+ pipeline_uuid: str | None,
+ ) -> tuple[bool, float]:
+ """Return aggregation enablement and a clamped debounce delay."""
- Returns:
- tuple: (enabled, delay_seconds)
- """
default_enabled = False
default_delay = 1.5
if pipeline_uuid is None:
return default_enabled, default_delay
- # Get pipeline from pipeline manager
- pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
+ pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(
+ execution_context,
+ pipeline_uuid,
+ )
if pipeline is None:
return default_enabled, default_delay
config = pipeline.pipeline_entity.config or {}
trigger_config = config.get('trigger', {})
aggregation_config = trigger_config.get('message-aggregation', {})
-
enabled = aggregation_config.get('enabled', default_enabled)
delay_raw = aggregation_config.get('delay', default_delay)
@@ -111,33 +134,31 @@ class MessageAggregator:
except (TypeError, ValueError):
delay = default_delay
- # Clamp delay to valid range
- delay = max(1.0, min(10.0, delay))
-
- return enabled, delay
+ return enabled, max(1.0, min(10.0, delay))
async def add_message(
self,
bot_uuid: str,
launcher_type: provider_session.LauncherTypes,
- launcher_id: typing.Union[int, str],
- sender_id: typing.Union[int, str],
+ launcher_id: int | str,
+ sender_id: int | str,
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
- pipeline_uuid: typing.Optional[str] = None,
+ pipeline_uuid: str | None = None,
routed_by_rule: bool = False,
+ execution_context: ExecutionContext | None = None,
) -> None:
- """Add a message to the aggregation buffer
+ """Buffer or directly enqueue a message in its trusted Workspace."""
- If aggregation is disabled for the pipeline, the message is sent
- directly to the query pool. Otherwise, it's buffered and will be
- merged with other messages from the same session.
- """
- enabled, delay = await self._get_aggregation_config(pipeline_uuid)
+ execution_context = await self.ap.query_pool.resolve_execution_context(
+ execution_context,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ )
+ enabled, delay = await self._get_aggregation_config(execution_context, pipeline_uuid)
if not enabled:
- # Aggregation disabled, send directly to query pool
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
@@ -148,12 +169,19 @@ class MessageAggregator:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
+ execution_context=execution_context,
)
return
- session_id = self._get_session_id(bot_uuid, launcher_type, launcher_id)
-
+ aggregation_key = self._get_aggregation_key(
+ execution_context,
+ bot_uuid,
+ launcher_type,
+ launcher_id,
+ pipeline_uuid,
+ )
pending_msg = PendingMessage(
+ execution_context=execution_context,
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
@@ -166,107 +194,167 @@ class MessageAggregator:
)
force_flush = False
+ bypass_aggregation = False
async with self.lock:
- if session_id in self.buffers:
- buffer = self.buffers[session_id]
- # Cancel existing timer (just cancel, don't await inside lock)
+ buffer = self.buffers.get(aggregation_key)
+ if buffer is None:
+ scope_key = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ )
+ workspace_buffer_count = self._buffer_counts_by_scope.get(
+ scope_key,
+ 0,
+ )
+ if len(self.buffers) >= self.max_buffers or workspace_buffer_count >= self.max_buffers_per_workspace:
+ bypass_aggregation = True
+ else:
+ buffer = SessionBuffer(
+ aggregation_key=aggregation_key,
+ execution_context=execution_context,
+ messages=[pending_msg],
+ )
+ self.buffers[aggregation_key] = buffer
+ self._buffer_counts_by_scope[scope_key] = workspace_buffer_count + 1
+ else:
+ if buffer.execution_context != execution_context:
+ raise ExecutionContextMismatchError('Aggregation buffer ExecutionContext changed for the same key')
if buffer.timer_task and not buffer.timer_task.done():
buffer.timer_task.cancel()
buffer.messages.append(pending_msg)
- else:
- buffer = SessionBuffer(
- session_id=session_id,
- messages=[pending_msg],
- )
- self.buffers[session_id] = buffer
- buffer.last_message_time = time.time()
+ if not bypass_aggregation:
+ buffer.last_message_time = time.time()
+ if len(buffer.messages) >= MAX_BUFFER_MESSAGES:
+ force_flush = True
+ else:
+ buffer.timer_task = create_detached_task(
+ self._delayed_flush(aggregation_key, delay, execution_context),
+ after_commit_manager=getattr(self.ap, 'persistence_mgr', None),
+ workspace_uuid=execution_context.workspace_uuid,
+ )
- # Check if buffer reached max capacity
- if len(buffer.messages) >= MAX_BUFFER_MESSAGES:
- force_flush = True
- else:
- # Start new timer
- buffer.timer_task = asyncio.create_task(self._delayed_flush(session_id, delay))
-
- if force_flush:
- await self._flush_buffer(session_id)
-
- async def _delayed_flush(self, session_id: str, delay: float) -> None:
- """Wait for delay then flush the buffer"""
- try:
- await asyncio.sleep(delay)
- await self._flush_buffer(session_id)
- except asyncio.CancelledError:
- # Timer was cancelled, new message arrived
- pass
-
- async def _flush_buffer(self, session_id: str) -> None:
- """Flush the buffer for a session, merging all messages"""
- async with self.lock:
- buffer = self.buffers.pop(session_id, None)
-
- if buffer is None or not buffer.messages:
- return
-
- if len(buffer.messages) == 1:
- # Only one message, no need to merge
- msg = buffer.messages[0]
+ if bypass_aggregation:
await self.ap.query_pool.add_query(
- bot_uuid=msg.bot_uuid,
- launcher_type=msg.launcher_type,
- launcher_id=msg.launcher_id,
- sender_id=msg.sender_id,
- message_event=msg.message_event,
- message_chain=msg.message_chain,
- adapter=msg.adapter,
- pipeline_uuid=msg.pipeline_uuid,
- routed_by_rule=msg.routed_by_rule,
+ bot_uuid=bot_uuid,
+ launcher_type=launcher_type,
+ launcher_id=launcher_id,
+ sender_id=sender_id,
+ message_event=message_event,
+ message_chain=message_chain,
+ adapter=adapter,
+ pipeline_uuid=pipeline_uuid,
+ routed_by_rule=routed_by_rule,
+ execution_context=execution_context,
)
return
- # Merge multiple messages
- merged_msg = self._merge_messages(buffer.messages)
+ if force_flush:
+ await self._flush_buffer(aggregation_key, execution_context)
+
+ async def _delayed_flush(
+ self,
+ aggregation_key: AggregationKey,
+ delay: float,
+ execution_context: ExecutionContext,
+ ) -> None:
+ """Flush after the debounce delay using the captured context."""
+
+ try:
+ await asyncio.sleep(delay)
+ await run_in_workspace_uow(
+ self.ap,
+ execution_context.workspace_uuid,
+ lambda: self._flush_buffer(aggregation_key, execution_context),
+ )
+ except asyncio.CancelledError:
+ pass
+ except WorkspaceError as exc:
+ self.ap.logger.info(
+ f'Dropped an aggregated message because its Workspace execution binding is stale: {exc}'
+ )
+
+ async def _flush_buffer(
+ self,
+ aggregation_key: AggregationKey,
+ execution_context: ExecutionContext,
+ ) -> None:
+ """Flush one buffer only when the captured scope still matches."""
+
+ async with self.lock:
+ buffer = self.buffers.get(aggregation_key)
+ if buffer is None:
+ return
+ if buffer.execution_context != execution_context:
+ raise ExecutionContextMismatchError('Timer ExecutionContext does not match the aggregation buffer')
+ self.buffers.pop(aggregation_key)
+ scope_key = aggregation_key[:3]
+ scope_count = self._buffer_counts_by_scope.get(scope_key, 0)
+ if scope_count <= 1:
+ self._buffer_counts_by_scope.pop(scope_key, None)
+ else:
+ self._buffer_counts_by_scope[scope_key] = scope_count - 1
+
+ if not buffer.messages:
+ return
+
+ message = buffer.messages[0] if len(buffer.messages) == 1 else self._merge_messages(buffer.messages)
+ binding = await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceInvariantError('Aggregation buffer instance does not match the active Workspace binding')
await self.ap.query_pool.add_query(
- bot_uuid=merged_msg.bot_uuid,
- launcher_type=merged_msg.launcher_type,
- launcher_id=merged_msg.launcher_id,
- sender_id=merged_msg.sender_id,
- message_event=merged_msg.message_event,
- message_chain=merged_msg.message_chain,
- adapter=merged_msg.adapter,
- pipeline_uuid=merged_msg.pipeline_uuid,
- routed_by_rule=merged_msg.routed_by_rule,
+ bot_uuid=message.bot_uuid,
+ launcher_type=message.launcher_type,
+ launcher_id=message.launcher_id,
+ sender_id=message.sender_id,
+ message_event=message.message_event,
+ message_chain=message.message_chain,
+ adapter=message.adapter,
+ pipeline_uuid=message.pipeline_uuid,
+ routed_by_rule=message.routed_by_rule,
+ execution_context=message.execution_context,
)
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
- """Merge multiple messages into one
+ """Merge message chains after proving all messages share one scope."""
- The merged message uses the first message as base and combines
- all message chains with newline separators.
- The original message_event is kept unmodified to preserve
- message metadata (message_id, etc.) for reply/quote.
- """
+ if not messages:
+ raise ValueError('At least one pending message is required')
if len(messages) == 1:
return messages[0]
base_msg = messages[0]
+ base_key = self._get_aggregation_key(
+ base_msg.execution_context,
+ base_msg.bot_uuid,
+ base_msg.launcher_type,
+ base_msg.launcher_id,
+ base_msg.pipeline_uuid,
+ )
+ for message in messages[1:]:
+ message_key = self._get_aggregation_key(
+ message.execution_context,
+ message.bot_uuid,
+ message.launcher_type,
+ message.launcher_id,
+ message.pipeline_uuid,
+ )
+ if message_key != base_key or message.execution_context != base_msg.execution_context:
+ raise ExecutionContextMismatchError('Cannot merge pending messages from different execution scopes')
- # Build merged message chain
merged_chain = platform_message.MessageChain([])
-
- for i, msg in enumerate(messages):
- if i > 0:
- # Add newline separator between messages
+ for index, message in enumerate(messages):
+ if index > 0:
merged_chain.append(platform_message.Plain(text='\n'))
-
- # Copy all components from this message
- for component in msg.message_chain:
+ for component in message.message_chain:
merged_chain.append(component)
- # Keep message_event unmodified (preserves original message_id and
- # metadata for reply/quote), only pass merged chain separately
return PendingMessage(
+ execution_context=base_msg.execution_context,
bot_uuid=base_msg.bot_uuid,
launcher_type=base_msg.launcher_type,
launcher_id=base_msg.launcher_id,
@@ -275,22 +363,23 @@ class MessageAggregator:
message_chain=merged_chain,
adapter=base_msg.adapter,
pipeline_uuid=base_msg.pipeline_uuid,
- routed_by_rule=any(msg.routed_by_rule for msg in messages),
+ routed_by_rule=any(message.routed_by_rule for message in messages),
)
async def flush_all(self) -> None:
- """Flush all pending buffers immediately
+ """Flush all pending buffers without dropping their captured scopes."""
- This is useful during shutdown to ensure no messages are lost.
- """
- # Snapshot session IDs and cancel all timers under lock
async with self.lock:
- session_ids = list(self.buffers.keys())
- for sid in session_ids:
- buffer = self.buffers.get(sid)
- if buffer and buffer.timer_task and not buffer.timer_task.done():
+ pending_buffers = [(key, buffer.execution_context) for key, buffer in self.buffers.items()]
+ for buffer in self.buffers.values():
+ if buffer.timer_task and not buffer.timer_task.done():
buffer.timer_task.cancel()
- # Flush each buffer outside the lock
- for session_id in session_ids:
- await self._flush_buffer(session_id)
+ for aggregation_key, execution_context in pending_buffers:
+ try:
+ await self._flush_buffer(aggregation_key, execution_context)
+ except WorkspaceError as exc:
+ self.ap.logger.info(
+ 'Dropped an aggregated message during shutdown because its '
+ f'Workspace execution binding is stale: {exc}'
+ )
diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py
index a376310f6..8c74150b0 100644
--- a/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py
+++ b/src/langbot/pkg/pipeline/cntfilter/filters/baiduexamine.py
@@ -23,7 +23,7 @@ class BaiduCloudExamine(filter_model.ContentFilter):
'client_secret': self.ap.pipeline_cfg.data['baidu-cloud-examine']['api-secret'],
},
) as resp:
- return (await resp.json())['access_token']
+ return (await httpclient.read_json_limited(resp))['access_token']
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
session = httpclient.get_session()
@@ -35,7 +35,7 @@ class BaiduCloudExamine(filter_model.ContentFilter):
},
data=f'text={message}'.encode('utf-8'),
) as resp:
- result = await resp.json()
+ result = await httpclient.read_json_limited(resp)
if 'error_code' in result:
return entities.FilterResult(
diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py
index 05b25013d..39796b33c 100644
--- a/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py
+++ b/src/langbot/pkg/pipeline/cntfilter/filters/banwords.py
@@ -1,9 +1,9 @@
from __future__ import annotations
-import re
from .. import filter as filter_model
from .. import entities
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from ....utils.safe_regex import SafeRegexError, mask_patterns
@filter_model.filter_class('ban-word-filter')
@@ -14,22 +14,20 @@ class BanWordFilter(filter_model.ContentFilter):
pass
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
- found = False
-
- for word in self.ap.sensitive_meta.data['words']:
- match = re.findall(word, message)
-
- if len(match) > 0:
- found = True
-
- for i in range(len(match)):
- if self.ap.sensitive_meta.data['mask_word'] == '':
- message = message.replace(
- match[i],
- self.ap.sensitive_meta.data['mask'] * len(match[i]),
- )
- else:
- message = message.replace(match[i], self.ap.sensitive_meta.data['mask_word'])
+ try:
+ found, message = await mask_patterns(
+ self.ap.sensitive_meta.data['words'],
+ message,
+ mask=self.ap.sensitive_meta.data['mask'],
+ mask_word=self.ap.sensitive_meta.data['mask_word'],
+ )
+ except SafeRegexError as exc:
+ return entities.FilterResult(
+ level=entities.ResultLevel.BLOCK,
+ replacement='',
+ user_notice='内容检查规则执行失败,请联系管理员',
+ console_notice=f'Sensitive-word regex rejected: {exc}',
+ )
return entities.FilterResult(
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
diff --git a/src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py b/src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py
index 731ab3924..1b6515891 100644
--- a/src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py
+++ b/src/langbot/pkg/pipeline/cntfilter/filters/cntignore.py
@@ -1,9 +1,9 @@
from __future__ import annotations
-import re
from .. import entities
from .. import filter as filter_model
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from ....utils.safe_regex import SafeRegexError, matches_any
@filter_model.filter_class('content-ignore')
@@ -28,14 +28,25 @@ class ContentIgnore(filter_model.ContentFilter):
)
if 'regexp' in query.pipeline_config['trigger']['ignore-rules']:
- for rule in query.pipeline_config['trigger']['ignore-rules']['regexp']:
- if re.search(rule, message):
- return entities.FilterResult(
- level=entities.ResultLevel.BLOCK,
- replacement='',
- user_notice='',
- console_notice='Ignore message according to regexp rule in ignore_rules',
- )
+ try:
+ matches = await matches_any(
+ query.pipeline_config['trigger']['ignore-rules']['regexp'],
+ message,
+ )
+ except SafeRegexError as exc:
+ return entities.FilterResult(
+ level=entities.ResultLevel.BLOCK,
+ replacement='',
+ user_notice='',
+ console_notice=f'Ignore-rule regex rejected: {exc}',
+ )
+ if matches:
+ return entities.FilterResult(
+ level=entities.ResultLevel.BLOCK,
+ replacement='',
+ user_notice='',
+ console_notice='Ignore message according to regexp rule in ignore_rules',
+ )
return entities.FilterResult(
level=entities.ResultLevel.PASS,
diff --git a/src/langbot/pkg/pipeline/controller.py b/src/langbot/pkg/pipeline/controller.py
index 09d18a582..d14288c29 100644
--- a/src/langbot/pkg/pipeline/controller.py
+++ b/src/langbot/pkg/pipeline/controller.py
@@ -5,8 +5,10 @@ import traceback
from ..core import app
from ..core import entities as core_entities
+from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from .pool import get_query_execution_context
class Controller:
@@ -21,11 +23,95 @@ class Controller:
self.ap = ap
self.semaphore = asyncio.Semaphore(self.ap.instance_config.data['concurrency']['pipeline'])
+ async def _assert_query_execution_active(
+ self,
+ query: pipeline_query.Query,
+ ):
+ """Revalidate a queued query immediately before runtime work starts."""
+
+ execution_context = get_query_execution_context(query)
+ binding = await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceInvariantError('Queued query instance does not match the active Workspace binding')
+ return execution_context
+
+ async def _process_query(
+ self,
+ selected_query: pipeline_query.Query,
+ *,
+ selected_session=None,
+ global_slot_reserved: bool = False,
+ ) -> None:
+ """Run one selected query and always release its scheduling slot."""
+
+ try:
+ queued_context = get_query_execution_context(selected_query)
+
+ async def run_scoped_query() -> None:
+ execution_context = await self._assert_query_execution_active(selected_query)
+ pipeline_uuid = selected_query.pipeline_uuid
+
+ if pipeline_uuid:
+ pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(
+ execution_context,
+ pipeline_uuid,
+ )
+ if pipeline:
+ await pipeline.run(selected_query)
+ else:
+ self.ap.logger.warning(
+ f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
+ )
+ else:
+ self.ap.logger.warning(f'No pipeline_uuid for query {selected_query.query_id}, query dropped')
+
+ tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
+ cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ if not callable(tenant_scope):
+ raise RuntimeError('Cloud query processing requires an explicit tenant scope')
+ async with tenant_scope(queued_context.workspace_uuid):
+ await run_scoped_query()
+ else:
+ await run_scoped_query()
+ except WorkspaceError as exc:
+ self.ap.logger.info(
+ f'Dropped query {selected_query.query_id} because its Workspace execution binding is stale: {exc}'
+ )
+ finally:
+ try:
+ try:
+ await self.ap.query_pool.remove_query(selected_query)
+ finally:
+ async with self.ap.query_pool:
+ session = selected_session or await self.ap.sess_mgr.get_session(selected_query)
+ try:
+ session._semaphore.release()
+ finally:
+ self.ap.query_pool.condition.notify_all()
+ finally:
+ if global_slot_reserved:
+ self.semaphore.release()
+
+ async def _drop_selected_query(self, selected_query, selected_session) -> None:
+ """Undo scheduler ownership when work cannot be handed to a task."""
+
+ try:
+ await self.ap.query_pool.remove_query(selected_query)
+ finally:
+ async with self.ap.query_pool:
+ selected_session._semaphore.release()
+ self.ap.query_pool.condition.notify_all()
+
async def consumer(self):
"""事件处理循环"""
- try:
- while True:
+ while True:
+ try:
selected_query: pipeline_query.Query = None
+ selected_session = None
# 取请求
async with self.ap.query_pool:
@@ -38,7 +124,9 @@ class Controller:
if not session._semaphore.locked():
selected_query = query
+ selected_session = session
await session._semaphore.acquire()
+ self.ap.query_pool.mark_query_running_locked(query)
# Only log when actually selecting a query
self.ap.logger.debug(f'Selected query {query.query_id} for processing')
@@ -51,46 +139,48 @@ class Controller:
continue
if selected_query:
+ try:
+ # Reserve global capacity before creating the task.
+ # At most one selected query is held by this consumer
+ # while all pipeline slots are busy.
+ await self.semaphore.acquire()
+ except asyncio.CancelledError:
+ await self._drop_selected_query(selected_query, selected_session)
+ raise
- async def _process_query(selected_query: pipeline_query.Query):
- async with self.semaphore: # 总并发上限
- # find pipeline
- # Here firstly find the bot, then find the pipeline, in case the bot adapter's config is not the latest one.
- # Like aiocqhttp, once a client is connected, even the adapter was updated and restarted, the existing client connection will not be affected.
- pipeline_uuid = selected_query.pipeline_uuid
-
- if pipeline_uuid:
- pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
- if pipeline:
- await pipeline.run(selected_query)
- else:
- self.ap.logger.warning(
- f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
- )
- else:
- self.ap.logger.warning(
- f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
- )
-
- async with self.ap.query_pool:
- (await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
- # 通知其他协程,有新的请求可以处理了
- self.ap.query_pool.condition.notify_all()
-
- self.ap.task_mgr.create_task(
- _process_query(selected_query),
- kind='query',
- name=f'query-{selected_query.query_id}',
- scopes=[
- core_entities.LifecycleControlScope.APPLICATION,
- core_entities.LifecycleControlScope.PLATFORM,
- ],
+ execution_context = get_query_execution_context(selected_query)
+ process_coro = self._process_query(
+ selected_query,
+ selected_session=selected_session,
+ global_slot_reserved=True,
)
+ try:
+ self.ap.task_mgr.create_task(
+ process_coro,
+ kind='query',
+ name=f'query-{selected_query.query_id}',
+ scopes=[
+ core_entities.LifecycleControlScope.APPLICATION,
+ core_entities.LifecycleControlScope.PLATFORM,
+ ],
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ )
+ except Exception:
+ process_coro.close()
+ self.semaphore.release()
+ await self._drop_selected_query(selected_query, selected_session)
+ raise
- except Exception as e:
- # traceback.print_exc()
- self.ap.logger.error(f'控制器循环出错: {e}')
- self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
+ except asyncio.CancelledError:
+ raise
+ except Exception as e:
+ self.ap.logger.error(f'控制器循环出错: {e}')
+ self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
+ # A persistent external failure must not turn this recovery
+ # loop into a CPU spin.
+ await asyncio.sleep(1)
async def run(self):
"""运行控制器"""
diff --git a/src/langbot/pkg/pipeline/longtext/strategies/image.py b/src/langbot/pkg/pipeline/longtext/strategies/image.py
index 110f1f81c..8b25ee455 100644
--- a/src/langbot/pkg/pipeline/longtext/strategies/image.py
+++ b/src/langbot/pkg/pipeline/longtext/strategies/image.py
@@ -1,19 +1,32 @@
from __future__ import annotations
+import asyncio
import os
import base64
import time
import re
+import uuid
from PIL import Image, ImageDraw, ImageFont
import functools
from .. import strategy as strategy_model
+from .forward import ForwardComponentStrategy
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.message as platform_message
+_MAX_TEXT_TO_IMAGE_CHARS = 100000
+_MAX_TEXT_TO_IMAGE_LINES = 256
+_MAX_TEXT_TO_IMAGE_PIXELS = 8_000_000
+_MAX_RENDERED_IMAGE_BYTES = 10 * 1024 * 1024
+
+
+class _TextToImageCapacityError(ValueError):
+ """The requested image would exceed a deterministic resource boundary."""
+
+
@strategy_model.strategy_class('image')
class Text2ImageStrategy(strategy_model.LongTextStrategy):
async def initialize(self):
@@ -28,28 +41,49 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
)
async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]:
- img_path = self.text_to_image(
- text_str=message,
- save_as='temp/{}.png'.format(int(time.time())),
- query=query,
- )
+ if len(message) > _MAX_TEXT_TO_IMAGE_CHARS:
+ self.ap.logger.warning(
+ f'Text-to-image input exceeds {_MAX_TEXT_TO_IMAGE_CHARS} characters; using forward message'
+ )
+ return await ForwardComponentStrategy(self.ap).process(message, query)
- compressed_path, size = self.compress_image(img_path, outfile='temp/{}_compressed.png'.format(int(time.time())))
+ def render() -> str:
+ render_id = f'{int(time.time())}-{uuid.uuid4().hex}'
+ img_path = f'temp/{render_id}.png'
+ compressed_path = f'temp/{render_id}-compressed.png'
+ try:
+ self.text_to_image(
+ text_str=message,
+ save_as=img_path,
+ query=query,
+ )
+ compressed_path, _ = self.compress_image(
+ img_path,
+ outfile=compressed_path,
+ )
+ with open(compressed_path, 'rb') as f:
+ image_bytes = f.read(_MAX_RENDERED_IMAGE_BYTES + 1)
+ if len(image_bytes) > _MAX_RENDERED_IMAGE_BYTES:
+ raise _TextToImageCapacityError(
+ f'Rendered image exceeds the {_MAX_RENDERED_IMAGE_BYTES}-byte limit'
+ )
+ return base64.b64encode(image_bytes).decode('utf-8')
+ finally:
+ for path in {img_path, compressed_path}:
+ if os.path.exists(path):
+ os.remove(path)
- with open(compressed_path, 'rb') as f:
- img = f.read()
-
- b64 = base64.b64encode(img)
-
- # 删除图片
- os.remove(img_path)
-
- if os.path.exists(compressed_path):
- os.remove(compressed_path)
+ # Font measurement, image rendering and compression are CPU-bound PIL
+ # work and must not block the shared asyncio loop for every tenant.
+ try:
+ image_base64 = await asyncio.to_thread(render)
+ except _TextToImageCapacityError as exc:
+ self.ap.logger.warning(f'{exc}; using forward message')
+ return await ForwardComponentStrategy(self.ap).process(message, query)
return [
platform_message.Image(
- base64=b64.decode('utf-8'),
+ base64=image_base64,
)
]
@@ -59,38 +93,7 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
:param path:目标字符串
:return:: : [['1', 16], ['2', 35], ['1', 51]]
"""
- kv = []
- nums = []
- beforeDatas = re.findall('[\\d]+', path)
- for num in beforeDatas:
- indexV = []
- times = path.count(num)
- if times > 1:
- if num not in nums:
- indexs = re.finditer(num, path)
- for index in indexs:
- iV = []
- i = index.span()[0]
- iV.append(num)
- iV.append(i)
- kv.append(iV)
- nums.append(num)
- else:
- index = path.find(num)
- indexV.append(num)
- indexV.append(index)
- kv.append(indexV)
- # 根据数字位置排序
- indexSort = []
- resultIndex = []
- for vi in kv:
- indexSort.append(vi[1])
- indexSort.sort()
- for i in indexSort:
- for v in kv:
- if i == v[1]:
- resultIndex.append(v)
- return resultIndex
+ return [[match.group(0), match.start()] for match in re.finditer(r'\d+', path)]
def get_size(self, file):
# 获取文件大小:KB
@@ -118,14 +121,55 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
return infile, o_size
outfile = self.get_outfile(infile, outfile)
while o_size > kb:
- im = Image.open(infile)
- im.save(outfile, quality=quality)
- if quality - step < 0:
+ with Image.open(infile) as im:
+ im.save(outfile, quality=quality)
+ if step <= 0 or quality - step < 0:
break
quality -= step
o_size = self.get_size(outfile)
return outfile, self.get_size(outfile)
+ def _split_text_lines(self, text_str: str, text_width: int, font) -> list[str]:
+ """Split text while guaranteeing that every loop iteration advances."""
+
+ if len(text_str) > _MAX_TEXT_TO_IMAGE_CHARS:
+ raise _TextToImageCapacityError(f'Text-to-image input exceeds {_MAX_TEXT_TO_IMAGE_CHARS} characters')
+
+ final_lines: list[str] = []
+
+ def append_line(value: str) -> None:
+ if len(final_lines) >= _MAX_TEXT_TO_IMAGE_LINES:
+ raise _TextToImageCapacityError(f'Text-to-image output exceeds {_MAX_TEXT_TO_IMAGE_LINES} lines')
+ final_lines.append(value)
+
+ text_width = max(int(text_width), 1)
+ for line in text_str.replace('\t', ' ').split('\n'):
+ line_width = font.getlength(line)
+ if not line or line_width < text_width:
+ append_line(line)
+ continue
+
+ rest_text = line
+ while rest_text:
+ line_width = max(font.getlength(rest_text), 1)
+ point = int(len(rest_text) * (text_width / line_width))
+ point = max(1, min(point, len(rest_text)))
+
+ if 0 < point < len(rest_text) and rest_text[point - 1].isdigit() and rest_text[point].isdigit():
+ number_start = point - 1
+ while number_start > 0 and rest_text[number_start - 1].isdigit():
+ number_start -= 1
+ if number_start > 0:
+ point = number_start
+
+ point = max(1, min(point, len(rest_text)))
+ append_line(rest_text[:point])
+ rest_text = rest_text[point:]
+ if rest_text and font.getlength(rest_text) < text_width:
+ append_line(rest_text)
+ break
+ return final_lines
+
def text_to_image(
self,
text_str: str,
@@ -133,79 +177,34 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
width=800,
query: pipeline_query.Query = None,
):
- text_str = text_str.replace('\t', ' ')
-
- # 分行
- lines = text_str.split('\n')
-
- # 计算并分割
- final_lines = []
-
- text_width = width - 80
-
- self.ap.logger.debug('lines: {}, text_width: {}'.format(lines, text_width))
- for line in lines:
- # 如果长了就分割
- line_width = self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']).getlength(
- line
- )
- self.ap.logger.debug('line_width: {}'.format(line_width))
- if line_width < text_width:
- final_lines.append(line)
- continue
- else:
- rest_text = line
- while True:
- # 分割最前面的一行
- point = int(len(rest_text) * (text_width / line_width))
-
- # 检查断点是否在数字中间
- numbers = self.indexNumber(rest_text)
-
- for number in numbers:
- if number[1] < point < number[1] + len(number[0]) and number[1] != 0:
- point = number[1]
- break
-
- final_lines.append(rest_text[:point])
- rest_text = rest_text[point:]
- line_width = self.get_font(
- query.pipeline_config['output']['long-text-processing']['font-path']
- ).getlength(rest_text)
- if line_width < text_width:
- final_lines.append(rest_text)
- break
- else:
- continue
+ width = int(width)
+ if width < 1:
+ raise _TextToImageCapacityError('Text-to-image width must be positive')
+ font = self.get_font(query.pipeline_config['output']['long-text-processing']['font-path'])
+ text_width = max(width - 80, 1)
+ final_lines = self._split_text_lines(text_str, text_width, font)
+ image_height = max(280, len(final_lines) * 35 + 65)
+ if width * image_height > _MAX_TEXT_TO_IMAGE_PIXELS:
+ raise _TextToImageCapacityError(f'Text-to-image canvas exceeds the {_MAX_TEXT_TO_IMAGE_PIXELS}-pixel limit')
# 准备画布
- img = Image.new('RGBA', (width, max(280, len(final_lines) * 35 + 65)), (255, 255, 255, 255))
- draw = ImageDraw.Draw(img, mode='RGBA')
+ img = Image.new('RGBA', (width, image_height), (255, 255, 255, 255))
+ try:
+ draw = ImageDraw.Draw(img, mode='RGBA')
- self.ap.logger.debug('正在绘制图片...')
- # 绘制正文
- line_number = 0
- offset_x = 20
- offset_y = 30
- for final_line in final_lines:
- draw.text(
- (offset_x, offset_y + 35 * line_number),
- final_line,
- fill=(0, 0, 0),
- font=self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']),
- )
- # 遍历此行,检查是否有emoji
- idx_in_line = 0
- for ch in final_line:
- # 检查字符占位宽
- char_code = ord(ch)
- if char_code >= 127:
- idx_in_line += 1
- else:
- idx_in_line += 0.5
+ self.ap.logger.debug('正在绘制图片...')
+ offset_x = 20
+ offset_y = 30
+ for line_number, final_line in enumerate(final_lines):
+ draw.text(
+ (offset_x, offset_y + 35 * line_number),
+ final_line,
+ fill=(0, 0, 0),
+ font=font,
+ )
- line_number += 1
-
- self.ap.logger.debug('正在保存图片...')
- img.save(save_as)
+ self.ap.logger.debug('正在保存图片...')
+ img.save(save_as)
+ finally:
+ img.close()
return save_as
diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py
index a3a9654bc..0728b0c83 100644
--- a/src/langbot/pkg/pipeline/monitoring_helper.py
+++ b/src/langbot/pkg/pipeline/monitoring_helper.py
@@ -15,6 +15,8 @@ if typing.TYPE_CHECKING:
from ..core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from .pool import get_query_execution_context
+
class MonitoringHelper:
"""Helper class for monitoring operations"""
@@ -54,6 +56,7 @@ class MonitoringHelper:
# Here we just record None, the full variables will be set when query completes
message_id = await ap.monitoring_service.record_message(
+ get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
@@ -74,6 +77,7 @@ class MonitoringHelper:
# Update session activity or create new session if it doesn't exist
# Always pass pipeline info to handle pipeline switches
session_updated = await ap.monitoring_service.update_session_activity(
+ get_query_execution_context(query),
session_id,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
@@ -81,6 +85,7 @@ class MonitoringHelper:
if not session_updated:
# Session doesn't exist, create it
await ap.monitoring_service.record_session_start(
+ get_query_execution_context(query),
session_id=session_id,
bot_id=bot_id,
bot_name=bot_name,
@@ -118,6 +123,7 @@ class MonitoringHelper:
pass
await ap.monitoring_service.update_message_status(
+ get_query_execution_context(query),
message_id=message_id,
status='success',
variables=query_variables_str,
@@ -170,6 +176,7 @@ class MonitoringHelper:
return # No response to record
await ap.monitoring_service.record_message(
+ get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
@@ -215,6 +222,7 @@ class MonitoringHelper:
# Record error message
message_id = await ap.monitoring_service.record_message(
+ get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
@@ -233,6 +241,7 @@ class MonitoringHelper:
# Record error log
await ap.monitoring_service.record_error(
+ get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
@@ -271,6 +280,7 @@ class MonitoringHelper:
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
await ap.monitoring_service.record_llm_call(
+ get_query_execution_context(query),
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py
index ef189beaf..911235d6a 100644
--- a/src/langbot/pkg/pipeline/pipelinemgr.py
+++ b/src/langbot/pkg/pipeline/pipelinemgr.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import dataclasses
import typing
import traceback
@@ -13,7 +14,11 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.events as events
from ..utils import importutil
+from ..api.http.authz import WorkspaceRequiredError
+from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
+from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
from .config_coercion import coerce_pipeline_config
+from .pool import get_query_execution_context
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -82,15 +87,39 @@ class RuntimePipeline:
enable_all_mcp_servers: bool
"""是否启用所有MCP服务器"""
+ execution_context: ExecutionContext
+
+ workspace_uuid: str
+
+ placement_generation: int
+
def __init__(
self,
ap: app.Application,
pipeline_entity: persistence_pipeline.LegacyPipeline,
stage_containers: list[StageInstContainer],
+ execution_context: ExecutionContext,
):
+ if not isinstance(execution_context, ExecutionContext):
+ raise WorkspaceRequiredError('RuntimePipeline requires an ExecutionContext')
+ if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
+ raise WorkspaceRequiredError('RuntimePipeline requires an instance and Workspace')
+ if execution_context.placement_generation <= 0:
+ raise WorkspaceRequiredError('RuntimePipeline requires a positive placement generation')
+ if pipeline_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceRequiredError('RuntimePipeline entity Workspace does not match its ExecutionContext')
+ if execution_context.pipeline_uuid not in (None, pipeline_entity.uuid):
+ raise WorkspaceRequiredError('RuntimePipeline UUID does not match its ExecutionContext')
+
self.ap = ap
self.pipeline_entity = pipeline_entity
self.stage_containers = stage_containers
+ self.execution_context = dataclasses.replace(
+ execution_context,
+ pipeline_uuid=pipeline_entity.uuid,
+ )
+ self.workspace_uuid = self.execution_context.workspace_uuid
+ self.placement_generation = self.execution_context.placement_generation
# Extract bound plugins and MCP servers from extensions_preferences
extensions_prefs = pipeline_entity.extensions_preferences or {}
@@ -120,7 +149,37 @@ class RuntimePipeline:
mcp_server_list = extensions_prefs.get('mcp_servers', [])
self.bound_mcp_servers = mcp_server_list if mcp_server_list else []
+ async def _assert_execution_active(
+ self,
+ query: pipeline_query.Query | None = None,
+ ) -> ExecutionContext:
+ """Fail closed when this runtime or query belongs to a stale placement."""
+
+ execution_context = self.execution_context if query is None else get_query_execution_context(query)
+ if (
+ execution_context.instance_uuid != self.execution_context.instance_uuid
+ or execution_context.workspace_uuid != self.workspace_uuid
+ or execution_context.placement_generation != self.placement_generation
+ or execution_context.pipeline_uuid != self.pipeline_entity.uuid
+ ):
+ raise WorkspaceInvariantError('Query execution scope does not match RuntimePipeline')
+ binding = await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceInvariantError('RuntimePipeline instance does not match the active Workspace binding')
+ return execution_context
+
async def run(self, query: pipeline_query.Query):
+ if (
+ query.instance_uuid != self.execution_context.instance_uuid
+ or query.workspace_uuid != self.workspace_uuid
+ or query.placement_generation != self.placement_generation
+ or query.pipeline_uuid != self.pipeline_entity.uuid
+ ):
+ raise WorkspaceRequiredError('Query execution scope does not match RuntimePipeline')
+ await self._assert_execution_active(query)
query.pipeline_config = self.pipeline_entity.config
# Store bound plugins and MCP servers in query for filtering
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
@@ -134,7 +193,11 @@ class RuntimePipeline:
bot_name = 'WebChat'
if query.bot_uuid:
try:
- bot = await self.ap.bot_service.get_bot(query.bot_uuid, include_secret=False)
+ bot = await self.ap.bot_service.get_bot(
+ query.workspace_uuid,
+ query.bot_uuid,
+ include_secret=False,
+ )
if bot:
bot_name = bot.get('name', 'Unknown')
except Exception:
@@ -150,6 +213,7 @@ class RuntimePipeline:
async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):
"""检查输出"""
+ await self._assert_execution_active(query)
if result.user_notice:
# 处理str类型
@@ -162,7 +226,9 @@ class RuntimePipeline:
query.message_event, platform_events.GroupMessage
):
result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id))
- if await query.adapter.is_stream_output_supported() and query.resp_messages:
+ stream_output_supported = await query.adapter.is_stream_output_supported()
+ await self._assert_execution_active(query)
+ if stream_output_supported and query.resp_messages:
await query.adapter.reply_message_chunk(
message_source=query.message_event,
bot_message=query.resp_messages[-1],
@@ -186,6 +252,7 @@ class RuntimePipeline:
query.variables['_monitoring_has_error'] = True
# Record error to monitoring system
try:
+ await self._assert_execution_active(query)
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id', '')
@@ -194,6 +261,7 @@ class RuntimePipeline:
# Update message status to error
if message_id:
await self.ap.monitoring_service.update_message_status(
+ get_query_execution_context(query),
message_id=message_id,
status='error',
level='error',
@@ -201,6 +269,7 @@ class RuntimePipeline:
# Record error log
await self.ap.monitoring_service.record_error(
+ get_query_execution_context(query),
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=self.pipeline_entity.uuid,
@@ -242,6 +311,7 @@ class RuntimePipeline:
i = stage_index
while i < len(self.stage_containers):
+ await self._assert_execution_active(query)
stage_container = self.stage_containers[i]
query.current_stage_name = stage_container.inst_name # 标记到 Query 对象里
@@ -250,6 +320,7 @@ class RuntimePipeline:
if isinstance(result, typing.Coroutine):
result = await result
+ await self._assert_execution_active(query)
if isinstance(result, pipeline_entities.StageProcessResult): # 直接返回结果
self.ap.logger.debug(
@@ -265,7 +336,14 @@ class RuntimePipeline:
elif isinstance(result, typing.AsyncGenerator): # 生成器
self.ap.logger.debug(f'Stage {stage_container.inst_name} processed query {query.query_id} gen')
- async for sub_result in result:
+ iterator = result.__aiter__()
+ while True:
+ await self._assert_execution_active(query)
+ try:
+ sub_result = await anext(iterator)
+ except StopAsyncIteration:
+ break
+ await self._assert_execution_active(query)
self.ap.logger.debug(
f'Stage {stage_container.inst_name} processed query {query.query_id} res {sub_result.result_type}'
)
@@ -283,6 +361,7 @@ class RuntimePipeline:
async def process_query(self, query: pipeline_query.Query):
"""处理请求"""
+ await self._assert_execution_active(query)
# Get monitoring metadata
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
@@ -310,6 +389,7 @@ class RuntimePipeline:
query.variables['_monitoring_message_id'] = message_id
# Notify adapter so it can map platform-specific IDs to monitoring message ID
if hasattr(query.adapter, 'on_monitoring_message_created'):
+ await self._assert_execution_active(query)
await query.adapter.on_monitoring_message_created(query, message_id)
except Exception as e:
self.ap.logger.error(f'Failed to record query start: {e}')
@@ -334,7 +414,9 @@ class RuntimePipeline:
message_chain=query.message_chain,
)
+ await self._assert_execution_active(query)
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
+ await self._assert_execution_active(query)
if event_ctx.is_prevented_default():
self.ap.logger.debug(
@@ -349,6 +431,7 @@ class RuntimePipeline:
# Record query success only if no error occurred during processing
if not query.variables.get('_monitoring_has_error', False):
try:
+ await self._assert_execution_active(query)
await monitoring_helper.MonitoringHelper.record_query_success(
ap=self.ap,
message_id=message_id,
@@ -359,6 +442,7 @@ class RuntimePipeline:
# Record bot response message
try:
+ await self._assert_execution_active(query)
await monitoring_helper.MonitoringHelper.record_query_response(
ap=self.ap,
query=query,
@@ -371,6 +455,8 @@ class RuntimePipeline:
except Exception as e:
self.ap.logger.error(f'Failed to record query response: {e}')
+ except WorkspaceError as e:
+ self.ap.logger.info(f'Dropped query {query.query_id} because its Workspace execution binding is stale: {e}')
except Exception as e:
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
@@ -380,6 +466,7 @@ class RuntimePipeline:
try:
from . import monitoring_helper
+ await self._assert_execution_active(query)
await monitoring_helper.MonitoringHelper.record_query_error(
ap=self.ap,
query=query,
@@ -395,7 +482,7 @@ class RuntimePipeline:
finally:
self.ap.logger.debug(f'Query {query.query_id} processed')
- del self.ap.query_pool.cached_queries[query.query_id]
+ await self.ap.query_pool.remove_query(query)
class PipelineManager:
@@ -409,7 +496,50 @@ class PipelineManager:
def __init__(self, ap: app.Application):
self.ap = ap
- self.pipelines = []
+ self._pipelines_by_key: dict[
+ tuple[str, str, str],
+ RuntimePipeline,
+ ] = {}
+ self._pipeline_keys_by_scope: dict[
+ tuple[str, str],
+ set[tuple[str, str, str]],
+ ] = {}
+ self._scope_generations: dict[tuple[str, str], int] = {}
+
+ @property
+ def pipelines(self) -> list[RuntimePipeline]:
+ """Compatibility view over the indexed runtime pipeline registry."""
+
+ return list(self._pipelines_by_key.values())
+
+ @pipelines.setter
+ def pipelines(self, pipelines: list[RuntimePipeline]) -> None:
+ self._pipelines_by_key = {}
+ self._pipeline_keys_by_scope = {}
+ for pipeline in pipelines:
+ context = pipeline.execution_context
+ pipeline_uuid = (
+ getattr(getattr(pipeline, 'pipeline_entity', None), 'uuid', None) or context.pipeline_uuid or ''
+ )
+ key = (
+ context.instance_uuid,
+ pipeline.workspace_uuid,
+ pipeline_uuid,
+ )
+ self._pipelines_by_key[key] = pipeline
+ self._pipeline_keys_by_scope.setdefault(key[:2], set()).add(key)
+
+ def _observe_execution_context(self, context: ExecutionContext) -> None:
+ scope = (context.instance_uuid, context.workspace_uuid)
+ previous_generation = self._scope_generations.get(scope)
+ if previous_generation is not None and context.placement_generation < previous_generation:
+ raise WorkspaceInvariantError('Pipeline runtime placement generation rolled back')
+ if previous_generation == context.placement_generation:
+ return
+ if previous_generation is not None:
+ for key in self._pipeline_keys_by_scope.pop(scope, ()):
+ self._pipelines_by_key.pop(key, None)
+ self._scope_generations[scope] = context.placement_generation
async def initialize(self):
self.stage_dict = {name: cls for name, cls in stage.preregistered_stages.items()}
@@ -419,25 +549,96 @@ class PipelineManager:
async def load_pipelines_from_db(self):
self.ap.logger.info('Loading pipelines from db...')
+ self._pipelines_by_key = {}
+ self._pipeline_keys_by_scope = {}
+ self._scope_generations = {}
+ list_bindings = getattr(self.ap.workspace_service, 'list_active_execution_bindings', None)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ if not callable(list_bindings) or not callable(tenant_uow):
+ raise RuntimeError('Cloud pipeline loading requires explicit instance discovery and tenant UoWs')
+ for binding in await list_bindings():
+ async with tenant_uow(binding.workspace_uuid):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_pipeline.LegacyPipeline)
+ .where(persistence_pipeline.LegacyPipeline.workspace_uuid == binding.workspace_uuid)
+ .order_by(persistence_pipeline.LegacyPipeline.uuid)
+ )
+ for pipeline in result.all():
+ await self.load_pipeline(
+ ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ pipeline_uuid=pipeline.uuid,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ ),
+ pipeline,
+ _binding_validated=True,
+ )
+ return
+
+ # Compatibility path for isolated manager tests and older embedders.
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
pipelines = result.all()
# load pipelines
for pipeline in pipelines:
- await self.load_pipeline(pipeline)
+ binding = await self.ap.workspace_service.get_execution_binding(pipeline.workspace_uuid)
+ await self.load_pipeline(
+ ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ pipeline_uuid=pipeline.uuid,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ ),
+ pipeline,
+ )
+
+ @staticmethod
+ def _normalize_execution_context(
+ context: ExecutionContext | RequestContext,
+ pipeline_uuid: str,
+ ) -> ExecutionContext:
+ if isinstance(context, RequestContext):
+ return ExecutionContext.from_request(context, pipeline_uuid=pipeline_uuid)
+ if not isinstance(context, ExecutionContext):
+ raise WorkspaceRequiredError('Pipeline runtime operations require an ExecutionContext')
+ if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
+ raise WorkspaceRequiredError('Pipeline runtime operations require an instance and Workspace')
+ if context.placement_generation <= 0:
+ raise WorkspaceRequiredError('Pipeline runtime operations require a positive placement generation')
+ if context.pipeline_uuid not in (None, pipeline_uuid):
+ raise WorkspaceRequiredError('Pipeline UUID does not match its ExecutionContext')
+ return dataclasses.replace(context, pipeline_uuid=pipeline_uuid)
async def load_pipeline(
self,
+ context: ExecutionContext | RequestContext,
pipeline_entity: persistence_pipeline.LegacyPipeline
| sqlalchemy.Row[persistence_pipeline.LegacyPipeline]
| dict,
+ *,
+ _binding_validated: bool = False,
):
if isinstance(pipeline_entity, sqlalchemy.Row):
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity._mapping)
elif isinstance(pipeline_entity, dict):
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
+ execution_context = self._normalize_execution_context(context, pipeline_entity.uuid)
+ if pipeline_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceRequiredError('Pipeline entity Workspace does not match its runtime context')
+ if not _binding_validated:
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ self._observe_execution_context(execution_context)
+
coerce_pipeline_config(
pipeline_entity.config,
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
@@ -454,17 +655,75 @@ class PipelineManager:
for stage_container in stage_containers:
await stage_container.inst.initialize(pipeline_entity.config)
- runtime_pipeline = RuntimePipeline(self.ap, pipeline_entity, stage_containers)
- self.pipelines.append(runtime_pipeline)
+ # Stage initialization can yield while a Workspace is being moved.
+ # Revalidate before publishing the runtime assembled above.
+ if not _binding_validated:
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ self._observe_execution_context(execution_context)
+ runtime_pipeline = RuntimePipeline(
+ self.ap,
+ pipeline_entity,
+ stage_containers,
+ execution_context,
+ )
+ key = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ pipeline_entity.uuid,
+ )
+ self._pipelines_by_key[key] = runtime_pipeline
+ self._pipeline_keys_by_scope.setdefault(key[:2], set()).add(key)
- async def get_pipeline_by_uuid(self, uuid: str) -> RuntimePipeline | None:
- for pipeline in self.pipelines:
- if pipeline.pipeline_entity.uuid == uuid:
- return pipeline
+ async def get_pipeline_by_uuid(
+ self,
+ context: ExecutionContext | RequestContext,
+ uuid: str,
+ ) -> RuntimePipeline | None:
+ execution_context = self._normalize_execution_context(context, uuid)
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ self._observe_execution_context(execution_context)
+ key = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ uuid,
+ )
+ pipeline = self._pipelines_by_key.get(key)
+ if pipeline is not None and pipeline.placement_generation == execution_context.placement_generation:
+ return pipeline
+ if not self._pipeline_keys_by_scope.get(key[:2]):
+ self._scope_generations.pop(
+ (execution_context.instance_uuid, execution_context.workspace_uuid),
+ None,
+ )
return None
- async def remove_pipeline(self, uuid: str):
- for pipeline in self.pipelines:
- if pipeline.pipeline_entity.uuid == uuid:
- self.pipelines.remove(pipeline)
- return
+ async def remove_pipeline(
+ self,
+ context: ExecutionContext | RequestContext,
+ uuid: str,
+ ) -> None:
+ execution_context = self._normalize_execution_context(context, uuid)
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ self._observe_execution_context(execution_context)
+ key = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ uuid,
+ )
+ if self._pipelines_by_key.pop(key, None) is not None:
+ scope_keys = self._pipeline_keys_by_scope.get(key[:2])
+ if scope_keys is not None:
+ scope_keys.discard(key)
+ if not scope_keys:
+ self._pipeline_keys_by_scope.pop(key[:2], None)
+ self._scope_generations.pop(key[:2], None)
+ return
diff --git a/src/langbot/pkg/pipeline/plugin_diagnostics.py b/src/langbot/pkg/pipeline/plugin_diagnostics.py
index 3e1195cf2..c019d9548 100644
--- a/src/langbot/pkg/pipeline/plugin_diagnostics.py
+++ b/src/langbot/pkg/pipeline/plugin_diagnostics.py
@@ -159,6 +159,16 @@ def _discard_query_state(query_key: int) -> None:
_QUERY_STATES.pop(query_key, None)
+def discard_query_state(query: pipeline_query.Query) -> None:
+ """Release all diagnostics retained for a query leaving the runtime pool."""
+
+ query_key = id(query)
+ state = _QUERY_STATES.get(query_key)
+ if state is not None and state.finalizer is not None:
+ state.finalizer.detach()
+ _discard_query_state(query_key)
+
+
def _discard_query_state_if_empty(query: pipeline_query.Query) -> None:
query_key = id(query)
state = _QUERY_STATES.get(query_key)
@@ -166,9 +176,7 @@ def _discard_query_state_if_empty(query: pipeline_query.Query) -> None:
return
if state.pending_by_chain_id or state.by_response_index:
return
- if state.finalizer is not None:
- state.finalizer.detach()
- _discard_query_state(query_key)
+ discard_query_state(query)
def _get_response_sources(
diff --git a/src/langbot/pkg/pipeline/pool.py b/src/langbot/pkg/pipeline/pool.py
index 55ce7fe12..bc96b054f 100644
--- a/src/langbot/pkg/pipeline/pool.py
+++ b/src/langbot/pkg/pipeline/pool.py
@@ -1,57 +1,287 @@
from __future__ import annotations
import asyncio
+import dataclasses
+import inspect
import typing
+import uuid
-import langbot_plugin.api.entities.builtin.platform.message as platform_message
-import langbot_plugin.api.entities.builtin.platform.events as platform_events
-import langbot_plugin.api.entities.builtin.provider.session as provider_session
-import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
+import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+import langbot_plugin.api.entities.builtin.platform.events as platform_events
+import langbot_plugin.api.entities.builtin.platform.message as platform_message
+import langbot_plugin.api.entities.builtin.provider.session as provider_session
+
+from ..api.http.context import ExecutionContext
+from . import plugin_diagnostics
+
+QueryCacheKey = tuple[str, str]
+LegacyQueryKey = tuple[str, int]
+QueryCounterKey = tuple[str, str, int]
+SingletonContextResolver = typing.Callable[
+ [],
+ ExecutionContext | typing.Awaitable[ExecutionContext],
+]
+
+
+class ExecutionContextRequiredError(ValueError):
+ """Raised when runtime work is created without a trusted Workspace scope."""
+
+
+class ExecutionContextMismatchError(ValueError):
+ """Raised when entity fields conflict with their trusted execution scope."""
+
+
+class QueryNotFoundError(LookupError):
+ """Raised when a query does not exist inside the requested Workspace."""
+
+
+class QueryPoolCapacityError(RuntimeError):
+ """Raised when no queued query can be discarded to admit new work."""
+
+
+def _validate_execution_context(execution_context: ExecutionContext) -> None:
+ if not isinstance(execution_context, ExecutionContext):
+ raise ExecutionContextRequiredError('A trusted ExecutionContext is required')
+ if not isinstance(execution_context.instance_uuid, str) or not execution_context.instance_uuid.strip():
+ raise ExecutionContextRequiredError('ExecutionContext.instance_uuid is required')
+ if not isinstance(execution_context.workspace_uuid, str) or not execution_context.workspace_uuid.strip():
+ raise ExecutionContextRequiredError('ExecutionContext.workspace_uuid is required')
+ if (
+ isinstance(execution_context.placement_generation, bool)
+ or not isinstance(execution_context.placement_generation, int)
+ or execution_context.placement_generation <= 0
+ ):
+ raise ExecutionContextRequiredError('ExecutionContext.placement_generation must be a positive integer')
+ for field_name in ('bot_uuid', 'pipeline_uuid', 'query_uuid'):
+ value = getattr(execution_context, field_name)
+ if value is not None and (not isinstance(value, str) or not value.strip()):
+ raise ExecutionContextRequiredError(f'ExecutionContext.{field_name} must be a non-empty string when set')
+
+
+def bind_execution_context(
+ execution_context: ExecutionContext,
+ *,
+ bot_uuid: str | None = None,
+ pipeline_uuid: str | None = None,
+ query_uuid: str | None = None,
+) -> ExecutionContext:
+ """Bind runtime entity identifiers without allowing scope substitution."""
+
+ _validate_execution_context(execution_context)
+
+ requested_fields = {
+ 'bot_uuid': bot_uuid,
+ 'pipeline_uuid': pipeline_uuid,
+ 'query_uuid': query_uuid,
+ }
+ updates: dict[str, str] = {}
+ for field_name, requested_value in requested_fields.items():
+ if requested_value is None:
+ continue
+ if not isinstance(requested_value, str) or not requested_value.strip():
+ raise ExecutionContextRequiredError(f'{field_name} must be a non-empty string')
+ current_value = getattr(execution_context, field_name)
+ if current_value is not None and current_value != requested_value:
+ raise ExecutionContextMismatchError(f'ExecutionContext.{field_name} does not match the runtime entity')
+ if current_value is None:
+ updates[field_name] = requested_value
+
+ if not updates:
+ return execution_context
+ return dataclasses.replace(execution_context, **updates)
+
+
+def get_query_execution_context(query: pipeline_query.Query) -> ExecutionContext:
+ """Return and validate the trusted context attached to a Query."""
+
+ attached_context = getattr(query, '_execution_context', None)
+ bot_uuid = getattr(query, 'bot_uuid', None)
+ pipeline_uuid = getattr(query, 'pipeline_uuid', None)
+ query_uuid = getattr(query, 'query_uuid', None)
+
+ if isinstance(attached_context, ExecutionContext):
+ return bind_execution_context(
+ attached_context,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ query_uuid=query_uuid,
+ )
+
+ raise ExecutionContextRequiredError('Query is missing its trusted ExecutionContext')
class QueryPool:
- """请求池,请求获得调度进入pipeline之前,保存在这里"""
-
- query_id_counter: int = 0
+ """Workspace-scoped queue of requests waiting for pipeline scheduling."""
+ query_id_counter: int
pool_lock: asyncio.Lock
-
queries: list[pipeline_query.Query]
-
- cached_queries: dict[int, pipeline_query.Query]
- """Cached queries, used for plugin backward api call, will be removed after the query completely processed"""
-
+ cached_queries: dict[QueryCacheKey, pipeline_query.Query]
+ legacy_query_index: dict[LegacyQueryKey, str]
+ query_count_by_scope: dict[QueryCounterKey, int]
condition: asyncio.Condition
- def __init__(self):
+ def __init__(
+ self,
+ singleton_context_resolver: SingletonContextResolver | None = None,
+ *,
+ max_queries: int = 1000,
+ max_queries_per_workspace: int = 100,
+ ):
+ if max_queries < 1:
+ raise ValueError('max_queries must be positive')
+ if max_queries_per_workspace < 1:
+ raise ValueError('max_queries_per_workspace must be positive')
+ if max_queries_per_workspace > max_queries:
+ raise ValueError('max_queries_per_workspace cannot exceed max_queries')
self.query_id_counter = 0
self.pool_lock = asyncio.Lock()
self.queries = []
self.cached_queries = {}
+ self.active_query_count_by_workspace: dict[str, int] = {}
+ self.legacy_query_index = {}
+ self.query_count_by_scope = {}
+ self.dropped_query_count_by_scope: dict[QueryCounterKey, int] = {}
self.condition = asyncio.Condition(self.pool_lock)
+ self._singleton_context_resolver = singleton_context_resolver
+ self.max_queries = max_queries
+ self.max_queries_per_workspace = max_queries_per_workspace
+
+ def _discard_queued_query_locked(
+ self,
+ *,
+ workspace_uuid: str | None = None,
+ ) -> pipeline_query.Query | None:
+ """Discard the oldest queued query from one scope and all indexes."""
+
+ for index, query in enumerate(self.queries):
+ execution_context = get_query_execution_context(query)
+ if workspace_uuid is not None and execution_context.workspace_uuid != workspace_uuid:
+ continue
+ self.queries.pop(index)
+ query_uuid = execution_context.query_uuid
+ if query_uuid is not None:
+ self.cached_queries.pop((execution_context.workspace_uuid, query_uuid), None)
+ self.legacy_query_index.pop((execution_context.workspace_uuid, query.query_id), None)
+ query_workspace_uuid = execution_context.workspace_uuid
+ remaining = self.active_query_count_by_workspace.get(query_workspace_uuid, 0) - 1
+ if remaining > 0:
+ self.active_query_count_by_workspace[query_workspace_uuid] = remaining
+ else:
+ self.active_query_count_by_workspace.pop(query_workspace_uuid, None)
+ plugin_diagnostics.discard_query_state(query)
+ counter_key = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ )
+ self.dropped_query_count_by_scope[counter_key] = self.dropped_query_count_by_scope.get(counter_key, 0) + 1
+ return query
+ return None
+
+ def _admit_query_locked(self, workspace_uuid: str) -> None:
+ workspace_query_count = self.active_query_count_by_workspace.get(workspace_uuid, 0)
+ if workspace_query_count >= self.max_queries_per_workspace:
+ if self._discard_queued_query_locked(workspace_uuid=workspace_uuid) is None:
+ raise QueryPoolCapacityError(f'Workspace query capacity reached ({self.max_queries_per_workspace})')
+
+ if len(self.cached_queries) >= self.max_queries:
+ if self._discard_queued_query_locked() is None:
+ raise QueryPoolCapacityError(f'Global query capacity reached ({self.max_queries})')
+
+ def mark_query_running_locked(self, query: pipeline_query.Query) -> None:
+ """Remove a scheduled query from the overload-discardable queue."""
+
+ if not self.pool_lock.locked():
+ raise RuntimeError('Query pool lock is required to schedule a query')
+ for index, queued_query in enumerate(self.queries):
+ if queued_query is query:
+ self.queries.pop(index)
+ return
+ raise QueryNotFoundError('Scheduled query is no longer queued')
+
+ def _make_scope_counter_room_locked(self, counter_key: QueryCounterKey) -> None:
+ """Retain recent counters without pinning every historical Workspace."""
+
+ if counter_key in self.query_count_by_scope:
+ return
+ while len(self.query_count_by_scope) >= self.max_queries:
+ stale_key = next(
+ (
+ existing_key
+ for existing_key in self.query_count_by_scope
+ if self.active_query_count_by_workspace.get(existing_key[1], 0) <= 0
+ ),
+ None,
+ )
+ if stale_key is None:
+ raise QueryPoolCapacityError('Query counter capacity reached while every Workspace is active')
+ self.query_count_by_scope.pop(stale_key, None)
+ self.dropped_query_count_by_scope.pop(stale_key, None)
+
+ async def resolve_execution_context(
+ self,
+ execution_context: ExecutionContext | None,
+ *,
+ bot_uuid: str,
+ pipeline_uuid: str | None,
+ query_uuid: str | None = None,
+ ) -> ExecutionContext:
+ """Resolve an explicit scope or the opt-in OSS singleton scope."""
+
+ if execution_context is None:
+ if self._singleton_context_resolver is None:
+ raise ExecutionContextRequiredError('ExecutionContext is required; no singleton resolver is configured')
+ resolved_context = self._singleton_context_resolver()
+ if inspect.isawaitable(resolved_context):
+ resolved_context = await resolved_context
+ execution_context = resolved_context
+
+ return bind_execution_context(
+ execution_context,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ query_uuid=query_uuid,
+ )
async def add_query(
self,
bot_uuid: str,
launcher_type: provider_session.LauncherTypes,
- launcher_id: typing.Union[int, str],
- sender_id: typing.Union[int, str],
+ launcher_id: int | str,
+ sender_id: int | str,
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
- pipeline_uuid: typing.Optional[str] = None,
+ pipeline_uuid: str | None = None,
routed_by_rule: bool = False,
- variables: typing.Optional[dict[str, typing.Any]] = None,
+ variables: dict[str, typing.Any] | None = None,
+ execution_context: ExecutionContext | None = None,
) -> pipeline_query.Query:
+ """Create a query and cache it under an opaque, Workspace-scoped key."""
+
+ query_uuid = str(uuid.uuid4())
+ execution_context = await self.resolve_execution_context(
+ execution_context,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ query_uuid=query_uuid,
+ )
+
async with self.condition:
+ self._admit_query_locked(execution_context.workspace_uuid)
query_id = self.query_id_counter
initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule}
if variables:
initial_variables.update(variables)
query = pipeline_query.Query(
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
bot_uuid=bot_uuid,
query_id=query_id,
+ query_uuid=query_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_id,
@@ -63,12 +293,133 @@ class QueryPool:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
)
+
+ # langbot-plugin 0.4.13 ignores these forward-compatible fields.
+ # Attach them explicitly until the Workspace-aware SDK is released.
+ object.__setattr__(query, 'instance_uuid', execution_context.instance_uuid)
+ object.__setattr__(query, 'workspace_uuid', execution_context.workspace_uuid)
+ object.__setattr__(
+ query,
+ 'placement_generation',
+ execution_context.placement_generation,
+ )
+ object.__setattr__(query, 'query_uuid', query_uuid)
+ object.__setattr__(query, '_execution_context', execution_context)
+
self.queries.append(query)
- self.cached_queries[query_id] = query
+ self.cached_queries[(execution_context.workspace_uuid, query_uuid)] = query
+ self.active_query_count_by_workspace[execution_context.workspace_uuid] = (
+ self.active_query_count_by_workspace.get(execution_context.workspace_uuid, 0) + 1
+ )
+ self.legacy_query_index[(execution_context.workspace_uuid, query_id)] = query_uuid
self.query_id_counter += 1
+ counter_key = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ )
+ # A Workspace has only one active placement. Drop obsolete
+ # generation counters so deployment churn cannot grow these maps.
+ for existing_key in tuple(self.query_count_by_scope):
+ if existing_key[:2] == counter_key[:2] and existing_key != counter_key:
+ self.query_count_by_scope.pop(existing_key, None)
+ self.dropped_query_count_by_scope.pop(existing_key, None)
+ self._make_scope_counter_room_locked(counter_key)
+ self.query_count_by_scope[counter_key] = self.query_count_by_scope.get(counter_key, 0) + 1
self.condition.notify_all()
return query
+ def get_query_count(self, execution_context: ExecutionContext) -> int:
+ """Return the lifetime query count for one active placement scope."""
+
+ _validate_execution_context(execution_context)
+ return self.query_count_by_scope.get(
+ (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ ),
+ 0,
+ )
+
+ def get_dropped_query_count(self, execution_context: ExecutionContext) -> int:
+ """Return overload drops for one active placement scope."""
+
+ _validate_execution_context(execution_context)
+ return self.dropped_query_count_by_scope.get(
+ (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ ),
+ 0,
+ )
+
+ async def get_query(
+ self,
+ workspace_uuid: str,
+ query_uuid: str,
+ ) -> pipeline_query.Query | None:
+ """Return a query only from the explicitly selected Workspace."""
+
+ async with self.pool_lock:
+ return self.cached_queries.get((workspace_uuid, query_uuid))
+
+ async def require_query(
+ self,
+ workspace_uuid: str,
+ query_uuid: str,
+ ) -> pipeline_query.Query:
+ """Return a scoped query or raise without checking other Workspaces."""
+
+ query = await self.get_query(workspace_uuid, query_uuid)
+ if query is None:
+ raise QueryNotFoundError(f'Query {query_uuid!r} was not found in Workspace {workspace_uuid!r}')
+ return query
+
+ async def get_query_by_legacy_id(
+ self,
+ workspace_uuid: str,
+ query_id: int,
+ ) -> pipeline_query.Query | None:
+ """Resolve a legacy integer ID within one explicit Workspace."""
+
+ async with self.pool_lock:
+ query_uuid = self.legacy_query_index.get((workspace_uuid, query_id))
+ if query_uuid is None:
+ return None
+ return self.cached_queries.get((workspace_uuid, query_uuid))
+
+ async def remove_query(self, query: pipeline_query.Query) -> bool:
+ """Remove a query and both of its Workspace-scoped indexes."""
+
+ execution_context = get_query_execution_context(query)
+ query_uuid = execution_context.query_uuid
+ if query_uuid is None:
+ raise ExecutionContextRequiredError('Query.query_uuid is required for removal')
+
+ async with self.pool_lock:
+ cache_key = (execution_context.workspace_uuid, query_uuid)
+ cached_query = self.cached_queries.get(cache_key)
+ if cached_query is not query:
+ return False
+ del self.cached_queries[cache_key]
+ remaining = self.active_query_count_by_workspace.get(execution_context.workspace_uuid, 0) - 1
+ if remaining > 0:
+ self.active_query_count_by_workspace[execution_context.workspace_uuid] = remaining
+ else:
+ self.active_query_count_by_workspace.pop(execution_context.workspace_uuid, None)
+ self.legacy_query_index.pop(
+ (execution_context.workspace_uuid, query.query_id),
+ None,
+ )
+ for index, queued_query in enumerate(self.queries):
+ if queued_query is query:
+ self.queries.pop(index)
+ break
+ plugin_diagnostics.discard_query_state(query)
+ return True
+
async def __aenter__(self):
await self.pool_lock.acquire()
return self
diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py
index b14d0a827..5b2201b2e 100644
--- a/src/langbot/pkg/pipeline/preproc/preproc.py
+++ b/src/langbot/pkg/pipeline/preproc/preproc.py
@@ -8,6 +8,7 @@ import langbot_plugin.api.entities.events as events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.events as platform_events
+from ...pipeline.pool import get_query_execution_context
@stage.stage_class('PreProcessor')
@@ -70,7 +71,10 @@ class PreProcessor(stage.PipelineStage):
if primary_uuid:
try:
- llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
+ llm_model = await self.ap.model_mgr.get_model_by_uuid(
+ get_query_execution_context(query),
+ primary_uuid,
+ )
except ValueError:
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
@@ -79,7 +83,10 @@ class PreProcessor(stage.PipelineStage):
valid_fallbacks = []
for fb_uuid in fallback_uuids:
try:
- await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
+ await self.ap.model_mgr.get_model_by_uuid(
+ get_query_execution_context(query),
+ fb_uuid,
+ )
valid_fallbacks.append(fb_uuid)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
@@ -131,6 +138,7 @@ class PreProcessor(stage.PipelineStage):
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
all_tools = await self.ap.tool_mgr.get_all_tools(
+ get_query_execution_context(query),
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
@@ -149,6 +157,7 @@ class PreProcessor(stage.PipelineStage):
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
all_tools = await self.ap.tool_mgr.get_all_tools(
+ get_query_execution_context(query),
bound_plugins,
bound_mcp_servers,
include_skill_authoring=include_skill_authoring,
@@ -279,7 +288,13 @@ class PreProcessor(stage.PipelineStage):
# relied on this injection; without it the LLM never discovers
# the skills are there and just calls native tools instead.
if selected_runner == 'local-agent' and self.ap.skill_mgr:
- pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
+ skill_execution_context = get_query_execution_context(query)
+ await self.ap.skill_mgr.ensure_loaded(skill_execution_context)
+ pipeline_data = await self.ap.pipeline_service.get_pipeline(
+ query.workspace_uuid,
+ query.pipeline_uuid,
+ include_secret=True,
+ )
extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {})
enable_all_skills = extensions_prefs.get('enable_all_skills', True)
@@ -291,6 +306,7 @@ class PreProcessor(stage.PipelineStage):
query.variables['_pipeline_bound_skills'] = bound_skills
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
+ skill_execution_context,
bound_skills=bound_skills,
)
if skill_addition:
@@ -319,13 +335,13 @@ class PreProcessor(stage.PipelineStage):
f'Skill index injected into system prompt: '
f'pipeline={query.pipeline_uuid} '
f'bound_skills={bound_skills or "all"} '
- f'loaded_skills={len(self.ap.skill_mgr.skills)}'
+ f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}'
)
else:
self.ap.logger.debug(
f'No skills available for prompt injection: '
f'pipeline={query.pipeline_uuid} '
- f'loaded_skills={len(self.ap.skill_mgr.skills)} '
+ f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))} '
f'bound_skills={bound_skills}'
)
diff --git a/src/langbot/pkg/pipeline/process/handlers/chat.py b/src/langbot/pkg/pipeline/process/handlers/chat.py
index 4e5c14ea4..ef6dd874d 100644
--- a/src/langbot/pkg/pipeline/process/handlers/chat.py
+++ b/src/langbot/pkg/pipeline/process/handlers/chat.py
@@ -19,12 +19,32 @@ from ....provider import runners
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
+from ...pool import get_query_execution_context
importutil.import_modules_in_pkg(runners)
class ChatMessageHandler(handler.MessageHandler):
+ def _response_limit(self, name: str, default: int) -> int:
+ instance_config = getattr(self.ap, 'instance_config', None)
+ data = getattr(instance_config, 'data', {})
+ if not isinstance(data, dict):
+ return default
+ value = data.get('system', {}).get('response_limits', {}).get(name, default)
+ try:
+ return max(int(value), 1)
+ except (TypeError, ValueError):
+ return default
+
+ def _check_response_size(
+ self,
+ result: provider_message.Message | provider_message.MessageChunk,
+ ) -> None:
+ content = result.content
+ if isinstance(content, str) and len(content) > self._response_limit('max_generated_chars', 1024 * 1024):
+ raise RuntimeError('Provider response exceeds the configured limit')
+
async def handle(
self,
query: pipeline_query.Query,
@@ -86,6 +106,7 @@ class ChatMessageHandler(handler.MessageHandler):
query.user_message.content = [event_ctx.event.user_message_alter]
text_length = 0
+ runner = None
try:
is_stream = await query.adapter.is_stream_output_supported()
except AttributeError:
@@ -106,6 +127,7 @@ class ChatMessageHandler(handler.MessageHandler):
chunk_count = 0 # Track streaming chunks to reduce excessive logging
async for result in runner.run(query):
+ self._check_response_size(result)
result.resp_message_id = str(resp_message_id)
if query.resp_messages:
query.resp_messages.pop()
@@ -118,6 +140,11 @@ class ChatMessageHandler(handler.MessageHandler):
query.resp_messages.append(result)
chunk_count += 1
+ if chunk_count > self._response_limit(
+ 'max_stream_chunks',
+ 100_000,
+ ):
+ raise RuntimeError('Provider stream exceeds the configured event limit')
# Only log every 10th chunk to reduce excessive logging during streaming
# This prevents memory overflow from thousands of log entries per conversation
# First chunk uses INFO level to confirm connection establishment
@@ -144,6 +171,7 @@ class ChatMessageHandler(handler.MessageHandler):
else:
async for result in runner.run(query):
+ self._check_response_size(result)
query.resp_messages.append(result)
summary = self.format_result_log(result)
@@ -158,6 +186,10 @@ class ChatMessageHandler(handler.MessageHandler):
query.session.using_conversation.messages.append(query.user_message)
query.session.using_conversation.messages.extend(query.resp_messages)
+ self.ap.sess_mgr.trim_conversation_messages(
+ query.session.using_conversation,
+ max_rounds=query.pipeline_config['ai']['local-agent'].get('max-round', 10),
+ )
except Exception as e:
error_info = f'{traceback.format_exc()}'
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
@@ -180,6 +212,13 @@ class ChatMessageHandler(handler.MessageHandler):
debug_notice=traceback.format_exc(),
)
finally:
+ if runner is not None:
+ try:
+ close_runner = getattr(runner, 'aclose', None)
+ if close_runner is not None:
+ await close_runner()
+ except Exception as ex:
+ self.ap.logger.warning(f'Failed to close request runner: {ex}')
# Telemetry reporting: collect minimal per-query execution info and send asynchronously
try:
end_ts = time.time()
@@ -198,7 +237,10 @@ class ChatMessageHandler(handler.MessageHandler):
model_name = None
try:
if runner_name == 'local-agent' and getattr(query, 'use_llm_model_uuid', None):
- m = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
+ m = await self.ap.model_mgr.get_model_by_uuid(
+ get_query_execution_context(query),
+ query.use_llm_model_uuid,
+ )
if m and getattr(m, 'model_entity', None):
model_name = getattr(m.model_entity, 'name', None)
except Exception:
diff --git a/src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py b/src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py
index 6a2a8e97d..278550290 100644
--- a/src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py
+++ b/src/langbot/pkg/pipeline/ratelimit/algos/fixedwin.py
@@ -1,9 +1,17 @@
from __future__ import annotations
import asyncio
+from collections import OrderedDict
import time
import typing
from .. import algo
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from ...pool import get_query_execution_context
+
+
+_MAX_SESSION_CONTAINERS = 10000
+_MIN_CONTAINER_TTL_SECONDS = 300
+_CLEANUP_INTERVAL_SECONDS = 60
+_MAX_EVICTION_PROBES = 64
# 固定窗口算法
@@ -13,9 +21,11 @@ class SessionContainer:
records: dict[int, int]
"""访问记录,key为每窗口长度的起始时间戳,value为访问次数"""
- def __init__(self):
+ def __init__(self, ttl_seconds: int = _MIN_CONTAINER_TTL_SECONDS):
self.wait_lock = asyncio.Lock()
self.records = {}
+ self.last_accessed = time.monotonic()
+ self.ttl_seconds = ttl_seconds
@algo.algo_class('fixwin')
@@ -28,7 +38,8 @@ class FixedWindowAlgo(algo.ReteLimitAlgo):
async def initialize(self):
self.containers_lock = asyncio.Lock()
- self.containers = {}
+ self.containers = OrderedDict()
+ self._last_cleanup = time.monotonic()
async def require_access(
self,
@@ -39,14 +50,53 @@ class FixedWindowAlgo(algo.ReteLimitAlgo):
# 加锁,找容器
container: SessionContainer = None
- session_name = f'{launcher_type}_{launcher_id}'
+ execution_context = get_query_execution_context(query)
+ session_name = ':'.join(
+ (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ str(execution_context.placement_generation),
+ str(getattr(query, 'bot_uuid', '')),
+ str(getattr(query, 'pipeline_uuid', '')),
+ str(launcher_type),
+ str(launcher_id),
+ )
+ )
async with self.containers_lock:
container = self.containers.get(session_name)
if container is None:
- container = SessionContainer()
+ window_size = query.pipeline_config['safety']['rate-limit']['window-length']
+ ttl_seconds = max(int(window_size) * 2, _MIN_CONTAINER_TTL_SECONDS)
+ now_monotonic = time.monotonic()
+ if now_monotonic - self._last_cleanup >= _CLEANUP_INTERVAL_SECONDS:
+ self._last_cleanup = now_monotonic
+ for key, candidate in tuple(self.containers.items()):
+ if (
+ not candidate.wait_lock.locked()
+ and now_monotonic - candidate.last_accessed >= candidate.ttl_seconds
+ ):
+ self.containers.pop(key, None)
+
+ if len(self.containers) >= _MAX_SESSION_CONTAINERS:
+ for _ in range(min(_MAX_EVICTION_PROBES, len(self.containers))):
+ oldest_key = next(iter(self.containers))
+ oldest = self.containers[oldest_key]
+ if oldest.wait_lock.locked():
+ self.containers.move_to_end(oldest_key)
+ continue
+ self.containers.pop(oldest_key, None)
+ break
+ if len(self.containers) >= _MAX_SESSION_CONTAINERS:
+ # Every retained session is actively waiting. Reject this
+ # admission instead of growing an attacker-controlled map.
+ return False
+ container = SessionContainer(ttl_seconds=ttl_seconds)
self.containers[session_name] = container
+ else:
+ self.containers.move_to_end(session_name)
+ container.last_accessed = time.monotonic()
# 等待锁
async with container.wait_lock:
@@ -87,6 +137,7 @@ class FixedWindowAlgo(algo.ReteLimitAlgo):
container.records[now] = count + 1
# 返回True
+ container.last_accessed = time.monotonic()
return True
async def release_access(
diff --git a/src/langbot/pkg/pipeline/resprule/rules/regexp.py b/src/langbot/pkg/pipeline/resprule/rules/regexp.py
index 41e1df8e7..b1b13066d 100644
--- a/src/langbot/pkg/pipeline/resprule/rules/regexp.py
+++ b/src/langbot/pkg/pipeline/resprule/rules/regexp.py
@@ -1,10 +1,8 @@
-import re
-
-
from .. import rule as rule_model
from .. import entities
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from ....utils.safe_regex import SafeRegexError, matches_any
@rule_model.rule_class('regexp')
@@ -16,15 +14,20 @@ class RegExpRule(rule_model.GroupRespondRule):
rule_dict: dict,
query: pipeline_query.Query,
) -> entities.RuleJudgeResult:
- regexps = rule_dict['regexp']
+ try:
+ matching = await matches_any(
+ rule_dict['regexp'],
+ message_text,
+ mode='match',
+ )
+ except SafeRegexError as exc:
+ self.ap.logger.warning(f'Group response regex rejected: {exc}')
+ matching = False
- for regexp in regexps:
- match = re.match(regexp, message_text)
-
- if match:
- return entities.RuleJudgeResult(
- matching=True,
- replacement=message_chain,
- )
+ if matching:
+ return entities.RuleJudgeResult(
+ matching=True,
+ replacement=message_chain,
+ )
return entities.RuleJudgeResult(matching=False, replacement=message_chain)
diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py
index 6e995206f..feaeea7a9 100644
--- a/src/langbot/pkg/platform/botmgr.py
+++ b/src/langbot/pkg/platform/botmgr.py
@@ -1,9 +1,14 @@
from __future__ import annotations
import asyncio
+import contextlib
+import dataclasses
+import functools
import json
import re
+import time
import traceback
+import uuid
import sqlalchemy
from ..core import app, entities as core_entities, taskmgr
@@ -12,8 +17,12 @@ from ..discover import engine
from ..entity.persistence import bot as persistence_bot
from ..entity.persistence import pipeline as persistence_pipeline
+from ..entity.persistence import workspace as persistence_workspace
from ..entity.errors import platform as platform_errors
+from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
+from ..api.http.authz import WorkspaceRequiredError
+from ..workspace.errors import WorkspaceInvariantError
from .logger import EventLogger
@@ -34,25 +43,58 @@ class RuntimeBot:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
- task_wrapper: taskmgr.TaskWrapper
+ task_wrapper: taskmgr.TaskWrapper | None
task_context: taskmgr.TaskContext
logger: EventLogger
+ execution_context: ExecutionContext
+
+ workspace_uuid: str
+
+ placement_generation: int
+
def __init__(
self,
ap: app.Application,
bot_entity: persistence_bot.Bot,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
logger: EventLogger,
+ execution_context: ExecutionContext,
):
+ if not isinstance(execution_context, ExecutionContext):
+ raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext')
+ if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
+ raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace')
+ if execution_context.placement_generation <= 0:
+ raise WorkspaceRequiredError('RuntimeBot requires a positive placement generation')
+ entity_workspace_uuid = getattr(bot_entity, 'workspace_uuid', None)
+ if entity_workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceRequiredError('RuntimeBot entity Workspace does not match its ExecutionContext')
+ if execution_context.bot_uuid not in (None, bot_entity.uuid):
+ raise WorkspaceRequiredError('RuntimeBot bot UUID does not match its ExecutionContext')
+
self.ap = ap
self.bot_entity = bot_entity
+ self.execution_context = dataclasses.replace(execution_context, bot_uuid=bot_entity.uuid)
+ self.workspace_uuid = self.execution_context.workspace_uuid
+ self.placement_generation = self.execution_context.placement_generation
self.enable = bot_entity.enable
self.adapter = adapter
self.task_context = taskmgr.TaskContext()
+ self.task_wrapper = None
self.logger = logger
+ self._shutdown_lock = asyncio.Lock()
+ self._shutdown_complete = False
+
+ async def assert_execution_active(self) -> None:
+ """Fail closed when this long-lived adapter belongs to a stale placement."""
+
+ await self.ap.workspace_service.get_execution_binding(
+ self.workspace_uuid,
+ expected_generation=self.placement_generation,
+ )
@staticmethod
def _match_operator(actual: str, operator: str, expected: str) -> bool:
@@ -135,6 +177,28 @@ class RuntimeBot:
return self.bot_entity.use_pipeline_uuid, False
+ def resolve_event_pipeline_uuid(
+ self,
+ adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
+ launcher_type: str,
+ launcher_id: str,
+ message_text: str,
+ message_element_types: list[str] | None = None,
+ ) -> tuple[str | None, bool]:
+ """Resolve a pipeline, honoring a trusted per-task adapter override."""
+
+ get_override = getattr(adapter, 'get_pipeline_uuid_override', None)
+ if callable(get_override):
+ override = get_override()
+ if override:
+ return str(override), False
+ return self.resolve_pipeline_uuid(
+ launcher_type,
+ launcher_id,
+ message_text,
+ message_element_types,
+ )
+
async def _record_discarded_message(
self,
launcher_type: provider_session.LauncherTypes,
@@ -162,6 +226,7 @@ class RuntimeBot:
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
await self.ap.monitoring_service.record_message(
+ self.execution_context,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
pipeline_id=self.PIPELINE_DISCARD,
@@ -179,11 +244,13 @@ class RuntimeBot:
# Don't overwrite pipeline info — a session may have messages from
# multiple pipelines; discarding shouldn't change the displayed pipeline.
session_updated = await self.ap.monitoring_service.update_session_activity(
+ self.execution_context,
session_id,
)
if not session_updated:
# No session yet (first message for this launcher was discarded).
await self.ap.monitoring_service.record_session_start(
+ self.execution_context,
session_id=session_id,
bot_id=self.bot_entity.uuid,
bot_name=self.bot_entity.name or self.bot_entity.uuid,
@@ -197,10 +264,29 @@ class RuntimeBot:
await self.logger.error(f'Failed to record discarded message: {e}')
async def initialize(self):
+ def tenant_scoped_listener(listener):
+ """Bind adapter callbacks to a Workspace without holding a DB transaction."""
+
+ @functools.wraps(listener)
+ async def wrapped(*args, **kwargs):
+ tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
+ cloud_runtime = (
+ getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ )
+ if cloud_runtime:
+ if not callable(tenant_scope):
+ raise RuntimeError('Cloud platform callbacks require an explicit tenant scope')
+ async with tenant_scope(self.workspace_uuid):
+ return await listener(*args, **kwargs)
+ return await listener(*args, **kwargs)
+
+ return wrapped
+
async def on_friend_message(
event: platform_events.FriendMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
+ await self.assert_execution_active()
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
@@ -215,7 +301,10 @@ class RuntimeBot:
skip_pipeline = False
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
- event, self.bot_entity.uuid, adapter.__class__.__name__
+ self.execution_context,
+ event,
+ self.bot_entity.uuid,
+ adapter.__class__.__name__,
)
# Only add to query pool if no webhook requested to skip pipeline
@@ -229,8 +318,12 @@ class RuntimeBot:
message_text = str(event.message_chain)
element_types = [comp.type for comp in event.message_chain]
- pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
- 'person', launcher_id, message_text, element_types
+ pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
+ adapter,
+ 'person',
+ launcher_id,
+ message_text,
+ element_types,
)
if pipeline_uuid == self.PIPELINE_DISCARD:
@@ -254,6 +347,7 @@ class RuntimeBot:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
+ execution_context=self.execution_context,
)
else:
await self.logger.info('Pipeline skipped for person message due to webhook response')
@@ -262,6 +356,7 @@ class RuntimeBot:
event: platform_events.GroupMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
+ await self.assert_execution_active()
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
@@ -276,7 +371,10 @@ class RuntimeBot:
skip_pipeline = False
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
- event, self.bot_entity.uuid, adapter.__class__.__name__
+ self.execution_context,
+ event,
+ self.bot_entity.uuid,
+ adapter.__class__.__name__,
)
# Only add to query pool if no webhook requested to skip pipeline
@@ -290,8 +388,12 @@ class RuntimeBot:
message_text = str(event.message_chain)
element_types = [comp.type for comp in event.message_chain]
- pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
- 'group', launcher_id, message_text, element_types
+ pipeline_uuid, routed_by_rule = self.resolve_event_pipeline_uuid(
+ adapter,
+ 'group',
+ launcher_id,
+ message_text,
+ element_types,
)
if pipeline_uuid == self.PIPELINE_DISCARD:
@@ -315,12 +417,13 @@ class RuntimeBot:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
+ execution_context=self.execution_context,
)
else:
await self.logger.info('Pipeline skipped for group message due to webhook response')
- self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
- self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
+ self.adapter.register_listener(platform_events.FriendMessage, tenant_scoped_listener(on_friend_message))
+ self.adapter.register_listener(platform_events.GroupMessage, tenant_scoped_listener(on_group_message))
# Register feedback listener (only effective on adapters that support it)
async def on_feedback(
@@ -328,13 +431,15 @@ class RuntimeBot:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
):
try:
+ await self.assert_execution_active()
# Resolve pipeline name
pipeline_name = ''
if self.bot_entity.use_pipeline_uuid:
try:
pipeline_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
- persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid
+ persistence_pipeline.LegacyPipeline.workspace_uuid == self.workspace_uuid,
+ persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid,
)
)
pipeline_row = pipeline_result.first()
@@ -344,6 +449,7 @@ class RuntimeBot:
pass
await self.ap.monitoring_service.record_feedback(
+ self.execution_context,
feedback_id=event.feedback_id,
feedback_type=event.feedback_type,
feedback_content=event.feedback_content,
@@ -364,7 +470,7 @@ class RuntimeBot:
except Exception:
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
- self.adapter.register_listener(platform_events.FeedbackEvent, on_feedback)
+ self.adapter.register_listener(platform_events.FeedbackEvent, tenant_scoped_listener(on_feedback))
async def run(self):
async def exception_wrapper():
@@ -390,12 +496,27 @@ class RuntimeBot:
core_entities.LifecycleControlScope.APPLICATION,
core_entities.LifecycleControlScope.PLATFORM,
],
+ instance_uuid=self.execution_context.instance_uuid,
+ workspace_uuid=self.execution_context.workspace_uuid,
+ placement_generation=(self.execution_context.placement_generation),
)
async def shutdown(self):
- await self.adapter.kill()
+ async with self._shutdown_lock:
+ if self._shutdown_complete:
+ return
- self.ap.task_mgr.cancel_task(self.task_wrapper.id)
+ wrapper = self.task_wrapper
+ self.task_wrapper = None
+ try:
+ await asyncio.wait_for(self.adapter.kill(), timeout=15)
+ finally:
+ if wrapper is not None:
+ self.ap.task_mgr.cancel_task(wrapper.id)
+ if wrapper.task is not asyncio.current_task():
+ with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError):
+ await asyncio.wait_for(wrapper.task, timeout=5)
+ self._shutdown_complete = True
# 控制QQ消息输入输出的类
@@ -405,7 +526,7 @@ class PlatformManager:
bots: list[RuntimeBot]
- websocket_proxy_bot: RuntimeBot
+ websocket_proxy_bots: dict[str, RuntimeBot]
adapter_components: list[engine.Component]
@@ -413,9 +534,140 @@ class PlatformManager:
def __init__(self, ap: app.Application = None):
self.ap = ap
- self.bots = []
+ self._bots_by_key: dict[tuple[str, str, str], RuntimeBot] = {}
+ self._bot_keys_by_workspace: dict[
+ str,
+ set[tuple[str, str, str]],
+ ] = {}
+ self._bot_keys_by_uuid: dict[str, set[tuple[str, str, str]]] = {}
+ self.websocket_proxy_bots = {}
self.adapter_components = []
self.adapter_dict = {}
+ self._scope_generations: dict[tuple[str, str], int] = {}
+ self._proxy_last_accessed: dict[str, float] = {}
+ self._runtime_mutation_lock = asyncio.Lock()
+
+ @staticmethod
+ def _runtime_bot_key(bot: RuntimeBot) -> tuple[str, str, str]:
+ context = getattr(bot, 'execution_context', None)
+ instance_uuid = str(getattr(context, 'instance_uuid', '__test_instance__'))
+ workspace_uuid = str(
+ getattr(bot, 'workspace_uuid', None) or getattr(context, 'workspace_uuid', '__test_workspace__')
+ )
+ bot_uuid = str(
+ getattr(getattr(bot, 'bot_entity', None), 'uuid', None)
+ or getattr(context, 'bot_uuid', None)
+ or f'__runtime_{id(bot)}'
+ )
+ return instance_uuid, workspace_uuid, bot_uuid
+
+ def _register_runtime_bot(self, bot: RuntimeBot) -> RuntimeBot | None:
+ key = self._runtime_bot_key(bot)
+ previous = self._bots_by_key.get(key)
+ self._bots_by_key[key] = bot
+ self._bot_keys_by_workspace.setdefault(key[1], set()).add(key)
+ self._bot_keys_by_uuid.setdefault(key[2], set()).add(key)
+ return previous
+
+ def _pop_runtime_bot(
+ self,
+ key: tuple[str, str, str],
+ ) -> RuntimeBot | None:
+ bot = self._bots_by_key.pop(key, None)
+ if bot is None:
+ return None
+ workspace_keys = self._bot_keys_by_workspace.get(key[1])
+ if workspace_keys is not None:
+ workspace_keys.discard(key)
+ if not workspace_keys:
+ self._bot_keys_by_workspace.pop(key[1], None)
+ uuid_keys = self._bot_keys_by_uuid.get(key[2])
+ if uuid_keys is not None:
+ uuid_keys.discard(key)
+ if not uuid_keys:
+ self._bot_keys_by_uuid.pop(key[2], None)
+ return bot
+
+ @property
+ def bots(self) -> list[RuntimeBot]:
+ """Compatibility view over the indexed platform runtime registry."""
+
+ return list(self._bots_by_key.values())
+
+ @bots.setter
+ def bots(self, bots: list[RuntimeBot]) -> None:
+ self._bots_by_key = {}
+ self._bot_keys_by_workspace = {}
+ self._bot_keys_by_uuid = {}
+ for bot in bots:
+ self._register_runtime_bot(bot)
+
+ def _max_workspace_proxies(self) -> int:
+ instance_data = getattr(
+ getattr(self.ap, 'instance_config', None),
+ 'data',
+ {},
+ )
+ value = instance_data.get('system', {}).get('websocket_retention', {}).get('max_workspace_proxies', 1024)
+ try:
+ return max(int(value), 1)
+ except (TypeError, ValueError):
+ return 1024
+
+ async def _evict_idle_websocket_proxy_unlocked(self) -> None:
+ """Make room without interrupting a live socket or in-flight query."""
+
+ if len(self.websocket_proxy_bots) < self._max_workspace_proxies():
+ return
+ from .sources.websocket_manager import WebSocketScope, ws_connection_manager
+
+ for workspace_uuid in sorted(
+ self.websocket_proxy_bots,
+ key=lambda item: self._proxy_last_accessed.get(item, 0.0),
+ ):
+ proxy_bot = self.websocket_proxy_bots[workspace_uuid]
+ listener_tasks = getattr(proxy_bot.adapter, 'inbound_listener_tasks', ())
+ if any(not task.done() for task in tuple(listener_tasks)):
+ continue
+ scope = WebSocketScope.from_context(proxy_bot.execution_context)
+ if ws_connection_manager.get_stats(scope=scope)['total_connections'] > 0:
+ continue
+ self.websocket_proxy_bots.pop(workspace_uuid, None)
+ self._proxy_last_accessed.pop(workspace_uuid, None)
+ await proxy_bot.shutdown()
+ if not self._bot_keys_by_workspace.get(workspace_uuid):
+ self._scope_generations.pop(
+ (proxy_bot.execution_context.instance_uuid, workspace_uuid),
+ None,
+ )
+ return
+ raise RuntimeError('WebSocket Workspace proxy capacity reached and every proxy is active')
+
+ async def _observe_execution_context(self, context: ExecutionContext) -> None:
+ """Shutdown superseded Workspace adapters when placement advances."""
+
+ async with self._runtime_mutation_lock:
+ await self._observe_execution_context_unlocked(context)
+
+ async def _observe_execution_context_unlocked(self, context: ExecutionContext) -> None:
+ scope = (context.instance_uuid, context.workspace_uuid)
+ previous_generation = self._scope_generations.get(scope)
+ if previous_generation is not None and context.placement_generation < previous_generation:
+ raise WorkspaceInvariantError('Platform runtime placement generation rolled back')
+ if previous_generation == context.placement_generation:
+ return
+ if previous_generation is not None:
+ proxy_bot = self.websocket_proxy_bots.pop(context.workspace_uuid, None)
+ self._proxy_last_accessed.pop(context.workspace_uuid, None)
+ if proxy_bot is not None and proxy_bot.enable:
+ await proxy_bot.shutdown()
+ for key in tuple(self._bot_keys_by_workspace.get(context.workspace_uuid, ())):
+ bot = self._pop_runtime_bot(key)
+ if bot is None:
+ continue
+ if bot.enable:
+ await bot.shutdown()
+ self._scope_generations[scope] = context.placement_generation
async def initialize(self):
# delete all bot log images
@@ -435,48 +687,248 @@ class PlatformManager:
if disabled_adapters:
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
- # initialize websocket adapter
- websocket_adapter_class = self.adapter_dict['websocket']
- websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
- websocket_adapter_inst = websocket_adapter_class(
- {},
- websocket_logger,
- ap=self.ap,
- )
-
- self.websocket_proxy_bot = RuntimeBot(
- ap=self.ap,
- bot_entity=persistence_bot.Bot(
- uuid='websocket-proxy-bot',
- name='WebSocket',
- description='',
- adapter='websocket',
- adapter_config={},
- enable=True,
- ),
- adapter=websocket_adapter_inst,
- logger=websocket_logger,
- )
- await self.websocket_proxy_bot.initialize()
-
await self.load_bots_from_db()
- def get_running_adapters(self) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
- return [bot.adapter for bot in self.bots if bot.enable]
+ # OSS may have no persisted bots. Its singleton Workspace still needs
+ # a debug WebSocket proxy. SaaS creates proxies lazily from an explicit
+ # request/runtime context instead of guessing among Workspaces.
+ if not self.websocket_proxy_bots:
+ try:
+ binding = await self.ap.workspace_service.get_execution_binding()
+ except Exception:
+ pass
+ else:
+ await self.get_websocket_proxy_bot(
+ ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ )
+ )
+
+ @property
+ def websocket_proxy_bot(self) -> RuntimeBot:
+ """Compatibility accessor that is safe only for a singleton Workspace."""
+
+ if len(self.websocket_proxy_bots) != 1:
+ raise WorkspaceRequiredError('An explicit Workspace is required for the WebSocket proxy bot')
+ return next(iter(self.websocket_proxy_bots.values()))
+
+ @websocket_proxy_bot.setter
+ def websocket_proxy_bot(self, runtime_bot: RuntimeBot) -> None:
+ """Keep isolated tests that inject one proxy bot working."""
+
+ workspace_uuid = getattr(runtime_bot, 'workspace_uuid', '__test_singleton__')
+ self.websocket_proxy_bots = {workspace_uuid: runtime_bot}
+
+ @staticmethod
+ def _normalize_execution_context(
+ context: ExecutionContext | RequestContext,
+ *,
+ bot_uuid: str | None = None,
+ pipeline_uuid: str | None = None,
+ ) -> ExecutionContext:
+ if isinstance(context, RequestContext):
+ return ExecutionContext.from_request(
+ context,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ )
+ if not isinstance(context, ExecutionContext):
+ raise WorkspaceRequiredError('Runtime operations require an ExecutionContext')
+ if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
+ raise WorkspaceRequiredError('Runtime operations require an instance and Workspace')
+ if context.placement_generation <= 0:
+ raise WorkspaceRequiredError('Runtime operations require a positive placement generation')
+ updates = {}
+ if bot_uuid is not None:
+ if context.bot_uuid not in (None, bot_uuid):
+ raise WorkspaceRequiredError('Runtime bot UUID does not match its ExecutionContext')
+ updates['bot_uuid'] = bot_uuid
+ if pipeline_uuid is not None:
+ if context.pipeline_uuid not in (None, pipeline_uuid):
+ raise WorkspaceRequiredError('Runtime pipeline UUID does not match its ExecutionContext')
+ updates['pipeline_uuid'] = pipeline_uuid
+ return dataclasses.replace(context, **updates) if updates else context
+
+ async def get_websocket_proxy_bot(
+ self,
+ context: ExecutionContext | RequestContext,
+ ) -> RuntimeBot:
+ execution_context = self._normalize_execution_context(context)
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ async with self._runtime_mutation_lock:
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ await self._observe_execution_context_unlocked(execution_context)
+ existing = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
+ if existing is not None:
+ if existing.placement_generation != execution_context.placement_generation:
+ raise WorkspaceRequiredError('WebSocket proxy placement generation is stale')
+ self._proxy_last_accessed[execution_context.workspace_uuid] = time.monotonic()
+ return existing
+
+ await self._evict_idle_websocket_proxy_unlocked()
+ binding = await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ websocket_adapter_class = self.adapter_dict['websocket']
+ websocket_logger = EventLogger(
+ name='websocket-adapter',
+ ap=self.ap,
+ execution_context=execution_context,
+ owner='websocket-proxy-bot',
+ )
+ websocket_adapter_inst = websocket_adapter_class({}, websocket_logger, ap=self.ap)
+ proxy_context = dataclasses.replace(
+ execution_context,
+ instance_uuid=binding.instance_uuid,
+ bot_uuid='websocket-proxy-bot',
+ )
+ runtime_bot = RuntimeBot(
+ ap=self.ap,
+ bot_entity=persistence_bot.Bot(
+ uuid='websocket-proxy-bot',
+ workspace_uuid=binding.workspace_uuid,
+ name='WebSocket',
+ description='',
+ adapter='websocket',
+ adapter_config={},
+ enable=True,
+ ),
+ adapter=websocket_adapter_inst,
+ logger=websocket_logger,
+ execution_context=proxy_context,
+ )
+ await runtime_bot.initialize()
+ self.websocket_proxy_bots[binding.workspace_uuid] = runtime_bot
+ self._proxy_last_accessed[binding.workspace_uuid] = time.monotonic()
+ return runtime_bot
+
+ def get_running_adapters(
+ self,
+ context: ExecutionContext | RequestContext,
+ ) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
+ execution_context = self._normalize_execution_context(context)
+ return [
+ bot.adapter
+ for bot in self.bots
+ if bot.enable
+ and bot.workspace_uuid == execution_context.workspace_uuid
+ and bot.placement_generation == execution_context.placement_generation
+ ]
async def load_bots_from_db(self):
self.ap.logger.info('Loading bots from db...')
- self.bots = []
+ async with self._runtime_mutation_lock:
+ old_bots = [*self.websocket_proxy_bots.values(), *self.bots]
+ self.websocket_proxy_bots = {}
+ self._proxy_last_accessed = {}
+ self.bots = []
+ self._scope_generations = {}
+ for bot in old_bots:
+ if not bot.enable:
+ continue
+ try:
+ await bot.shutdown()
+ except Exception as exc:
+ self.ap.logger.warning(f'Failed to stop old platform runtime during reload: {exc}')
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
+ list_bindings = getattr(
+ self.ap.workspace_service,
+ 'list_active_execution_bindings',
+ None,
+ )
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ if not callable(list_bindings) or not callable(tenant_uow):
+ raise RuntimeError('Cloud platform loading requires explicit instance discovery and tenant UoWs')
+ for binding in await list_bindings():
+ try:
+ async with tenant_uow(binding.workspace_uuid):
+ await self._load_workspace_bots(
+ binding.workspace_uuid,
+ _binding=binding,
+ )
+ except Exception as exc:
+ self.ap.logger.error(
+ f'Failed to load Workspace bots for {binding.workspace_uuid}: {exc}\n{traceback.format_exc()}'
+ )
+ return
- bots = result.all()
+ instance_uow = getattr(self.ap.persistence_mgr, 'instance_discovery_uow', None)
+ tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
+ if callable(instance_uow) and callable(tenant_scope):
+ async with instance_uow(self.ap.workspace_service.instance_uuid) as discovery:
+ workspace_uuids = list(
+ (
+ await discovery.session.scalars(
+ sqlalchemy.select(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
+ .where(
+ persistence_workspace.WorkspaceExecutionState.instance_uuid
+ == self.ap.workspace_service.instance_uuid,
+ persistence_workspace.WorkspaceExecutionState.state
+ == persistence_workspace.WorkspaceExecutionStatus.ACTIVE.value,
+ persistence_workspace.WorkspaceExecutionState.write_fenced.is_(False),
+ )
+ .order_by(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
+ )
+ ).all()
+ )
+ else:
+ # Compatibility for lightweight tests and pre-tenancy managers.
+ result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
+ workspace_uuids = sorted({bot.workspace_uuid for bot in result.all()})
- for bot in bots:
- # load all bots here, enable or disable will be handled in runtime
+ for workspace_uuid in workspace_uuids:
try:
- await self.load_bot(bot)
+ if callable(tenant_scope):
+ async with tenant_scope(workspace_uuid):
+ await self._load_workspace_bots(workspace_uuid)
+ else:
+ await self._load_workspace_bots(workspace_uuid)
+ except Exception as e:
+ self.ap.logger.error(
+ f'Failed to load Workspace bots for {workspace_uuid}: {e}\n{traceback.format_exc()}'
+ )
+
+ async def _load_workspace_bots(
+ self,
+ workspace_uuid: str,
+ *,
+ _binding=None,
+ ) -> None:
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_bot.Bot)
+ .where(persistence_bot.Bot.workspace_uuid == workspace_uuid)
+ .order_by(persistence_bot.Bot.uuid)
+ )
+ binding = _binding
+ for bot in result.all():
+ try:
+ if binding is None:
+ binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid)
+ execution_context = ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ bot_uuid=bot.uuid,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ )
+ await self.load_bot(
+ execution_context,
+ bot,
+ _binding_validated=True,
+ )
except platform_errors.AdapterNotFoundError as e:
self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
except Exception as e:
@@ -484,7 +936,10 @@ class PlatformManager:
async def load_bot(
self,
+ context: ExecutionContext | RequestContext,
bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict,
+ *,
+ _binding_validated: bool = False,
) -> RuntimeBot:
"""加载机器人"""
if isinstance(bot_entity, sqlalchemy.Row):
@@ -492,45 +947,145 @@ class PlatformManager:
elif isinstance(bot_entity, dict):
bot_entity = persistence_bot.Bot(**bot_entity)
- logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap)
+ execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
+ if bot_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
+ if not _binding_validated:
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ async with self._runtime_mutation_lock:
+ if not _binding_validated:
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ await self._observe_execution_context_unlocked(execution_context)
- if bot_entity.adapter not in self.adapter_dict:
- raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
+ logger = EventLogger(
+ name=f'platform-adapter-{bot_entity.name}',
+ ap=self.ap,
+ execution_context=execution_context,
+ owner=bot_entity.uuid,
+ )
- adapter_inst = self.adapter_dict[bot_entity.adapter](
- bot_entity.adapter_config,
- logger,
+ if bot_entity.adapter not in self.adapter_dict:
+ raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
+
+ adapter_inst = self.adapter_dict[bot_entity.adapter](
+ bot_entity.adapter_config,
+ logger,
+ )
+ if hasattr(adapter_inst, 'ap'):
+ adapter_inst.ap = self.ap
+
+ # 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
+ if hasattr(adapter_inst, 'set_bot_uuid'):
+ adapter_inst.set_bot_uuid(bot_entity.uuid)
+
+ runtime_bot = RuntimeBot(
+ ap=self.ap,
+ bot_entity=bot_entity,
+ adapter=adapter_inst,
+ logger=logger,
+ execution_context=execution_context,
+ )
+
+ await runtime_bot.initialize()
+
+ bot_key = self._runtime_bot_key(runtime_bot)
+ existing_bot = self._bots_by_key.get(bot_key)
+ if existing_bot is not None and existing_bot is not runtime_bot:
+ try:
+ if existing_bot.enable:
+ await existing_bot.shutdown()
+ except BaseException:
+ if runtime_bot.enable:
+ await runtime_bot.shutdown()
+ raise
+ self._register_runtime_bot(runtime_bot)
+
+ return runtime_bot
+
+ async def get_bot_by_uuid(
+ self,
+ context: ExecutionContext | RequestContext,
+ bot_uuid: str,
+ ) -> RuntimeBot | None:
+ execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
)
- if hasattr(adapter_inst, 'ap'):
- adapter_inst.ap = self.ap
+ await self._observe_execution_context(execution_context)
+ proxy_bot = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
+ if proxy_bot and proxy_bot.bot_entity.uuid == bot_uuid:
+ if proxy_bot.placement_generation != execution_context.placement_generation:
+ return None
+ return proxy_bot
+ bot = self._bots_by_key.get(
+ (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ bot_uuid,
+ )
+ )
+ if bot is None or bot.placement_generation != execution_context.placement_generation:
+ return None
+ return bot
- # 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
- if hasattr(adapter_inst, 'set_bot_uuid'):
- adapter_inst.set_bot_uuid(bot_entity.uuid)
+ async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
+ """Resolve an opaque public bot UUID without consulting request headers."""
- runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
-
- await runtime_bot.initialize()
-
- self.bots.append(runtime_bot)
-
- return runtime_bot
-
- async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
- if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:
- return self.websocket_proxy_bot
- for bot in self.bots:
- if bot.bot_entity.uuid == bot_uuid:
- return bot
+ try:
+ normalized = str(uuid.UUID(route_key))
+ except (ValueError, AttributeError, TypeError):
+ return None
+ keys = tuple(self._bot_keys_by_uuid.get(normalized, ()))
+ if len(keys) != 1:
+ return None
+ key = keys[0]
+ bot = self._bots_by_key.get(key)
+ if bot is None:
+ return None
+ try:
+ await self.ap.workspace_service.get_execution_binding(
+ bot.workspace_uuid,
+ expected_generation=bot.placement_generation,
+ )
+ except Exception:
+ return None
+ if self._bots_by_key.get(key) is bot:
+ return bot
return None
- async def remove_bot(self, bot_uuid: str):
- for bot in self.bots[:]:
- if bot.bot_entity.uuid == bot_uuid:
+ async def remove_bot(
+ self,
+ context: ExecutionContext | RequestContext,
+ bot_uuid: str,
+ ) -> None:
+ execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ async with self._runtime_mutation_lock:
+ await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ await self._observe_execution_context_unlocked(execution_context)
+ key = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ bot_uuid,
+ )
+ bot = self._bots_by_key.get(key)
+ if bot is not None and bot.placement_generation == execution_context.placement_generation:
if bot.enable:
await bot.shutdown()
- self.bots.remove(bot)
- return
+ self._pop_runtime_bot(key)
def get_available_adapters_info(self) -> list[dict]:
return [
@@ -551,14 +1106,24 @@ class PlatformManager:
async def run(self):
# This method will only be called when the application launching
- await self.websocket_proxy_bot.run()
+ for proxy_bot in self.websocket_proxy_bots.values():
+ await proxy_bot.run()
for bot in self.bots:
if bot.enable:
await bot.run()
async def shutdown(self):
- for bot in self.bots:
- if bot.enable:
- await bot.shutdown()
+ async with self._runtime_mutation_lock:
+ runtime_bots = [*self.websocket_proxy_bots.values(), *self.bots]
+ self.websocket_proxy_bots = {}
+ self._proxy_last_accessed = {}
+ self.bots = []
+ for bot in runtime_bots:
+ if not bot.enable:
+ continue
+ try:
+ await bot.shutdown()
+ except Exception as exc:
+ self.ap.logger.warning(f'Failed to stop platform runtime during shutdown: {exc}')
self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM)
diff --git a/src/langbot/pkg/platform/logger.py b/src/langbot/pkg/platform/logger.py
index 681648656..af6a7de76 100644
--- a/src/langbot/pkg/platform/logger.py
+++ b/src/langbot/pkg/platform/logger.py
@@ -9,6 +9,7 @@ import traceback
import uuid
from ..core import app
+from ..api.http.context import ExecutionContext
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_event_logger
@@ -54,6 +55,7 @@ class EventLog(pydantic.BaseModel):
MAX_LOG_COUNT = 200
DELETE_COUNT_PER_TIME = 50
+MAX_LOG_TEXT_CHARS = 20000
class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
@@ -65,13 +67,21 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
logs: list[EventLog]
+ execution_context: ExecutionContext
+
+ owner: str
+
def __init__(
self,
name: str,
ap: app.Application,
+ execution_context: ExecutionContext,
+ owner: str,
):
self.name = name
self.ap = ap
+ self.execution_context = execution_context
+ self.owner = owner
self.logs = []
self.seq_id_inc = 0
@@ -120,8 +130,12 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
async def _truncate_logs(self):
if len(self.logs) > MAX_LOG_COUNT:
for i in range(DELETE_COUNT_PER_TIME):
- for image_key in self.logs[i].images: # type: ignore
- await self.ap.storage_mgr.storage_provider.delete(image_key)
+ for image_key in self.logs[i].images or []:
+ await self.ap.storage_mgr.delete_scoped_object_key(
+ self.execution_context,
+ image_key,
+ expected_owner_type='bot_log',
+ )
self.logs = self.logs[DELETE_COUNT_PER_TIME:]
async def _add_log(
@@ -134,6 +148,10 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
):
try:
image_keys = []
+ text = str(text)
+ if len(text) > MAX_LOG_TEXT_CHARS:
+ marker = '\n[log truncated]'
+ text = text[: MAX_LOG_TEXT_CHARS - len(marker)] + marker
if images is None:
images = []
@@ -149,8 +167,14 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
extension = mimetypes.guess_extension(mime_type)
if extension is None:
extension = '.jpg'
- image_key = f'bot_log_images/{message_session_id}-{uuid.uuid4()}{extension}'
- await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)
+ logical_key = f'{message_session_id}-{uuid.uuid4()}{extension}'
+ image_key = await self.ap.storage_mgr.save_scoped(
+ self.execution_context,
+ owner_type='bot_log',
+ owner=self.owner,
+ key=logical_key,
+ value=img_bytes,
+ )
image_keys.append(image_key)
self.logs.append(
diff --git a/src/langbot/pkg/platform/sources/aiocqhttp.py b/src/langbot/pkg/platform/sources/aiocqhttp.py
index d3b5fc589..9bee40d6e 100644
--- a/src/langbot/pkg/platform/sources/aiocqhttp.py
+++ b/src/langbot/pkg/platform/sources/aiocqhttp.py
@@ -23,6 +23,7 @@ _GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2
_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400
_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600
_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2
+_LOOKUP_CACHE_MAX = 4096
def _normalize_base64_payload(value: str) -> str:
@@ -372,6 +373,31 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]
] = {}
self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {}
+ self._last_cache_cleanup = 0.0
+
+ def _prune_caches(self, now: float) -> None:
+ caches = (
+ self._group_name_cache,
+ self._group_name_negative_cache,
+ self._group_member_info_cache,
+ self._group_member_info_negative_cache,
+ )
+ if now - self._last_cache_cleanup < 60 and all(len(cache) <= _LOOKUP_CACHE_MAX for cache in caches):
+ return
+ self._last_cache_cleanup = now
+ for cache in caches:
+ for key, value in tuple(cache.items()):
+ expires_at = value[1] if isinstance(value, tuple) else value
+ if expires_at <= now:
+ cache.pop(key, None)
+ while len(cache) > _LOOKUP_CACHE_MAX:
+ cache.pop(next(iter(cache)), None)
+
+ def clear(self) -> None:
+ self._group_name_cache.clear()
+ self._group_name_negative_cache.clear()
+ self._group_member_info_cache.clear()
+ self._group_member_info_negative_cache.clear()
@staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
@@ -379,6 +405,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str:
now = time.monotonic()
+ self._prune_caches(now)
if group_id in self._group_name_cache:
group_name, expires_at = self._group_name_cache[group_id]
if expires_at > now:
@@ -414,6 +441,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
bot=None,
) -> dict:
now = time.monotonic()
+ self._prune_caches(now)
cache_key = (group_id, user_id)
if cache_key in self._group_member_info_cache:
member_info, expires_at = self._group_member_info_cache[cache_key]
@@ -532,6 +560,8 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
return
self.on_websocket_connection_event_cache.append(event)
+ if len(self.on_websocket_connection_event_cache) > 100:
+ self.on_websocket_connection_event_cache.pop(0)
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
@@ -697,4 +727,6 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def kill(self) -> bool:
# Current issue: existing connection will not be closed
# self.should_shutdown = True
+ self.on_websocket_connection_event_cache.clear()
+ self.event_converter.clear()
return False
diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py
index 996187f08..ec9de538e 100644
--- a/src/langbot/pkg/platform/sources/dingtalk.py
+++ b/src/langbot/pkg/platform/sources/dingtalk.py
@@ -5,6 +5,7 @@ import re
import traceback
import typing
import uuid
+import time
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -544,11 +545,59 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot=bot,
listeners={},
)
+ self._background_tasks: set[asyncio.Task] = set()
# Wire the card-action callback after super().__init__ so we can reference
# self.* — the client's handler stores this as a soft reference and reads
# it at fire time.
self.bot.card_action_callback = self._on_card_action
+ def _start_background_task(self, coro) -> bool:
+ """Start one bounded adapter-side auxiliary task."""
+
+ background_tasks = getattr(self, '_background_tasks', None)
+ if background_tasks is None:
+ background_tasks = set()
+ object.__setattr__(self, '_background_tasks', background_tasks)
+ for task in tuple(background_tasks):
+ if task.done():
+ background_tasks.discard(task)
+ if len(background_tasks) >= 100:
+ coro.close()
+ return False
+ task = asyncio.create_task(coro)
+ background_tasks.add(task)
+
+ def done(done_task: asyncio.Task) -> None:
+ background_tasks.discard(done_task)
+ if not done_task.cancelled():
+ done_task.exception()
+
+ task.add_done_callback(done)
+ return True
+
+ def _prune_card_state(self) -> None:
+ now = time.monotonic()
+ ttl_seconds = 1800
+ for card_id, state in tuple(self.card_state.items()):
+ if now - float(state.get('created_at', now)) <= ttl_seconds:
+ continue
+ self.card_state.pop(card_id, None)
+ for session_key, active_card_id in tuple(self.active_turn_card.items()):
+ if active_card_id == card_id:
+ self.active_turn_card.pop(session_key, None)
+ self.active_turn_text.pop(session_key, None)
+ while len(self.card_state) > 1000:
+ card_id = next(iter(self.card_state))
+ self.card_state.pop(card_id, None)
+ while len(self.active_turn_card) > 1000:
+ session_key = next(iter(self.active_turn_card))
+ self.active_turn_card.pop(session_key, None)
+ self.active_turn_text.pop(session_key, None)
+ card_instances = getattr(self, 'card_instance_id_dict', None)
+ if isinstance(card_instances, dict):
+ while len(card_instances) > 1000:
+ card_instances.pop(next(iter(card_instances)), None)
+
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -674,6 +723,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return is_stream
async def create_message_card(self, message_id, event):
+ self._prune_card_state()
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
legacy_template_id = self.config.get('card_template_id', '')
@@ -806,6 +856,20 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return params
async def kill(self) -> bool:
+ task_set = getattr(self, '_background_tasks', set())
+ background_tasks = list(task_set)
+ for task in background_tasks:
+ if not task.done():
+ task.cancel()
+ if background_tasks:
+ await asyncio.gather(*background_tasks, return_exceptions=True)
+ task_set.clear()
+ card_instances = getattr(self, 'card_instance_id_dict', None)
+ if isinstance(card_instances, dict):
+ card_instances.clear()
+ self.card_state.clear()
+ self.active_turn_card.clear()
+ self.active_turn_text.clear()
await self.bot.stop()
return True
@@ -931,6 +995,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
# Record form state for the click-handler.
+ self._prune_card_state()
launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source)
self.card_state[out_track_id] = {
'session_key': session_key,
@@ -947,6 +1012,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'current_input_field': str(form_data.get('_current_input_field') or ''),
'input_defs': _dingtalk_form_input_defs(form_data),
'inputs': form_data.get('inputs') or {},
+ 'created_at': time.monotonic(),
}
btns = self._build_btns(actions if should_show_actions else [], out_track_id)
@@ -1040,6 +1106,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
f'options={len(component_params.get("select_options") or [])}'
)
+ self._prune_card_state()
self.card_state[out_track_id] = {
'session_key': session_key,
'launcher_type': launcher_type.value,
@@ -1057,6 +1124,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'inputs': form_data.get('inputs') or {},
'open_space_id': open_space_id,
'is_group': is_group,
+ 'created_at': time.monotonic(),
}
parts = []
@@ -1223,6 +1291,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
f'payload_action_id={payload.get("action_id")!r} params={payload.get("params")!r}'
)
out_track_id = payload.get('out_track_id') or ''
+ self._prune_card_state()
params = payload.get('params') or {}
# ButtonGroup `sendCardRequest` events surface the click id at the
# callback top level as `actionId`; fall back to `params.action_id`
@@ -1359,7 +1428,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# output lives on a separate new card (lazy-created in
# reply_message_chunk on the synthetic event), so the form card
# stays put as a record of the user's selection.
- asyncio.create_task(
+ self._start_background_task(
self._mark_card_resolved(
out_track_id,
action_title,
diff --git a/src/langbot/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py
index edffc6e2a..69f349056 100644
--- a/src/langbot/pkg/platform/sources/discord.py
+++ b/src/langbot/pkg/platform/sources/discord.py
@@ -28,6 +28,21 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
from ..logger import EventLogger
+_MAX_DISCORD_MEDIA_BYTES = 10 * 1024 * 1024
+
+
+def _decode_discord_base64_limited(value: str) -> bytes:
+ if ',' in value:
+ value = value.split(',', 1)[1]
+ max_encoded_bytes = 4 * ((_MAX_DISCORD_MEDIA_BYTES + 2) // 3)
+ if len(value) > max_encoded_bytes:
+ raise ValueError('Discord media exceeds the size limit')
+ decoded = base64.b64decode(value)
+ if len(decoded) > _MAX_DISCORD_MEDIA_BYTES:
+ raise ValueError('Discord media exceeds the size limit')
+ return decoded
+
+
# 语音功能相关异常定义
class VoiceConnectionError(Exception):
"""语音连接基础异常"""
@@ -604,7 +619,6 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
for ele in message_chain:
if isinstance(ele, platform_message.Image):
- image_bytes = None
filename = f'{uuid.uuid4()}.png' # 默认文件名
if ele.base64:
@@ -618,60 +632,17 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in data_header:
filename = f'{uuid.uuid4()}.webp'
- # 去掉data:image/xxx;base64,前缀
- base64_data = ele.base64.split(',')[1]
- else:
- base64_data = ele.base64
- image_bytes = base64.b64decode(base64_data)
- elif ele.url:
- # 从URL下载图片
- session = httpclient.get_session()
- async with session.get(ele.url) as response:
- image_bytes = await response.read()
- # 从URL或Content-Type推断文件类型
- content_type = response.headers.get('Content-Type', '')
- if 'jpeg' in content_type or 'jpg' in content_type:
- filename = f'{uuid.uuid4()}.jpg'
- elif 'gif' in content_type:
- filename = f'{uuid.uuid4()}.gif'
- elif 'webp' in content_type:
- filename = f'{uuid.uuid4()}.webp'
- elif ele.url.lower().endswith(('.jpg', '.jpeg')):
- filename = f'{uuid.uuid4()}.jpg'
- elif ele.url.lower().endswith('.gif'):
- filename = f'{uuid.uuid4()}.gif'
- elif ele.url.lower().endswith('.webp'):
- filename = f'{uuid.uuid4()}.webp'
- elif ele.path:
- # 从文件路径读取图片
- # 确保路径没有空字节
- clean_path = ele.path.replace('\x00', '')
- clean_path = os.path.abspath(clean_path)
-
- if not os.path.exists(clean_path):
- continue # 跳过不存在的文件
-
- try:
- with open(clean_path, 'rb') as f:
- image_bytes = f.read()
- # 从文件路径获取文件名,保持原始扩展名
- original_filename = os.path.basename(clean_path)
- if original_filename and '.' in original_filename:
- # 保持原始文件名的扩展名
- ext = original_filename.split('.')[-1].lower()
- filename = f'{uuid.uuid4()}.{ext}'
- else:
- # 如果没有扩展名,尝试从文件内容检测
- if image_bytes.startswith(b'\xff\xd8\xff'):
- filename = f'{uuid.uuid4()}.jpg'
- elif image_bytes.startswith(b'GIF'):
- filename = f'{uuid.uuid4()}.gif'
- elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]:
- filename = f'{uuid.uuid4()}.webp'
- # 默认保持PNG
- except Exception as e:
- print(f'Error reading image file {clean_path}: {e}')
- continue # 跳过读取失败的文件
+ try:
+ image_bytes, mime_type = await ele.get_bytes()
+ except Exception as exc:
+ print(f'Error reading Discord image: {exc}')
+ continue
+ if 'jpeg' in mime_type or 'jpg' in mime_type:
+ filename = f'{uuid.uuid4()}.jpg'
+ elif 'gif' in mime_type:
+ filename = f'{uuid.uuid4()}.gif'
+ elif 'webp' in mime_type:
+ filename = f'{uuid.uuid4()}.webp'
if image_bytes:
files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
@@ -702,27 +673,34 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
elif 'webm' in data_header:
filename = f'{uuid.uuid4()}.webm'
- file_base64 = ele.base64.split(',')[-1]
- file_bytes = base64.b64decode(file_base64)
+ file_bytes = await asyncio.to_thread(
+ _decode_discord_base64_limited,
+ ele.base64,
+ )
elif ele.url:
session = httpclient.get_session()
async with session.get(ele.url) as response:
- file_bytes = await response.read()
+ file_bytes = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_DISCORD_MEDIA_BYTES,
+ )
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.File):
file_bytes = None
filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}'
if ele.base64:
- if ele.base64.startswith('data:'):
- file_base64 = ele.base64.split(',')[1]
- file_bytes = base64.b64decode(file_base64)
- else:
- file_bytes = base64.b64decode(ele.base64)
+ file_bytes = await asyncio.to_thread(
+ _decode_discord_base64_limited,
+ ele.base64,
+ )
elif ele.url:
session = httpclient.get_session()
async with session.get(ele.url) as response:
- file_bytes = await response.read()
+ file_bytes = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_DISCORD_MEDIA_BYTES,
+ )
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.Forward):
@@ -780,8 +758,11 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
for attachment in message.attachments:
session = httpclient.get_session(trust_env=True)
async with session.get(attachment.url) as response:
- image_data = await response.read()
- image_base64 = base64.b64encode(image_data).decode('utf-8')
+ image_data = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_DISCORD_MEDIA_BYTES,
+ )
+ image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
image_format = response.headers['Content-Type']
element_list.append(
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
@@ -970,6 +951,20 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# resume.
self._pending_forms: dict[str, dict] = {}
+ def _prune_transient_state(self) -> None:
+ now = time.time()
+ ttl_seconds = 1800
+ for message_id, state in tuple(self._stream_buffer.items()):
+ if now - float(state.get('updated_at', now)) > ttl_seconds:
+ self._stream_buffer.pop(message_id, None)
+ for session_key, state in tuple(self._pending_forms.items()):
+ if now - float(state.get('posted_at', now)) > ttl_seconds:
+ self._pending_forms.pop(session_key, None)
+ while len(self._stream_buffer) > 100:
+ self._stream_buffer.pop(next(iter(self._stream_buffer)), None)
+ while len(self._pending_forms) > 1000:
+ self._pending_forms.pop(next(iter(self._pending_forms)), None)
+
# Voice functionality methods
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
"""
@@ -1248,11 +1243,13 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
source = event.source_platform_object
if not isinstance(source, discord.Message):
return False
+ self._prune_transient_state()
self._stream_buffer[message_id] = {
'channel': source.channel,
'sent_message': None, # discord.Message set on first send
'last_content': '',
'chunk_count': 0,
+ 'updated_at': time.time(),
}
return True
@@ -1276,6 +1273,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None
ctx = self._stream_buffer.get(msg_id) if msg_id else None
+ if ctx is not None:
+ ctx['updated_at'] = time.time()
# If the stream ctx was not set up (create_message_card wasn't
# called, e.g. synthetic event), or the final chunk carries a
@@ -1344,6 +1343,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
callback synthesizes a ``_dify_form_action`` query so the runner's
``_merge_pending_form_action`` resumes the workflow.
"""
+ self._prune_transient_state()
source = message_source.source_platform_object
actions = form_data.get('actions') or []
@@ -1447,6 +1447,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
and disable the View buttons so the choice is visually locked in."""
import langbot_plugin.api.entities.builtin.provider.session as provider_session
+ self._prune_transient_state()
+
# ACK first (3-second deadline before Discord shows "interaction failed").
try:
await interaction.response.defer()
@@ -1655,5 +1657,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if self.voice_manager:
await self.voice_manager.disconnect_all()
+ self._stream_buffer.clear()
+ self._pending_forms.clear()
await self.bot.close()
return True
diff --git a/src/langbot/pkg/platform/sources/http_bot.py b/src/langbot/pkg/platform/sources/http_bot.py
index 16a891991..2cb4f19ca 100644
--- a/src/langbot/pkg/platform/sources/http_bot.py
+++ b/src/langbot/pkg/platform/sources/http_bot.py
@@ -28,6 +28,7 @@ See docs/platforms/http-bot.md for the full integration guide.
from __future__ import annotations
import asyncio
+import itertools
import json
import time
import typing
@@ -54,6 +55,7 @@ _ERR = {
'bad_signature': (401, 40101),
'duplicate': (409, 40901),
'too_large': (413, 41301),
+ 'overloaded': (503, 50301),
'internal': (500, 50001),
}
@@ -63,16 +65,27 @@ _MAX_BODY = 1 * 1024 * 1024
# Idempotency dedup window (seconds) and cap.
_IDEMPOTENCY_TTL = 600
_IDEMPOTENCY_MAX = 4096
+_IDEMPOTENCY_PRUNE_SCAN_MAX = 64
+_OUTBOUND_QUEUE_MAX = 100
+_OUTBOUND_IDLE_SECONDS = 60
+_OUTBOUND_STATE_MAX = 4096
+_OUTBOUND_PRUNE_SCAN_MAX = 64
+_INBOUND_TASK_MAX = 100
+
+
+class _OutboundStateCapacityError(RuntimeError):
+ """Raised when a new outbound session cannot be admitted safely."""
class _SessionOutbound:
"""Per-session outbound state: ordered delivery queue + sequence counter."""
def __init__(self) -> None:
- self.queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
+ self.queue: asyncio.Queue = asyncio.Queue(maxsize=_OUTBOUND_QUEUE_MAX)
self.worker: asyncio.Task | None = None
self.sequence: int = 0
self.last_was_final: bool = True # so the first reply of a turn starts at seq 1
+ self.last_active: float = time.monotonic()
class _SyncCollector:
@@ -99,6 +112,7 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
idempotency_cache: dict[str, float] = pydantic.Field(default_factory=dict, exclude=True)
# session_id -> sync collector (set while a /sync request is awaiting a turn)
sync_waiters: dict[str, '_SyncCollector'] = pydantic.Field(default_factory=dict, exclude=True)
+ inbound_tasks: set[asyncio.Task] = pydantic.Field(default_factory=set, exclude=True)
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
@@ -108,6 +122,7 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.outbound_states = {}
self.idempotency_cache = {}
self.sync_waiters = {}
+ self.inbound_tasks = set()
# -- framework hooks ------------------------------------------------------
@@ -156,10 +171,19 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await asyncio.sleep(3600)
async def kill(self):
- # Cancel any outbound workers.
+ tasks = list(self.inbound_tasks)
+ for task in tasks:
+ if not task.done():
+ task.cancel()
for state in self.outbound_states.values():
if state.worker and not state.worker.done():
state.worker.cancel()
+ tasks.append(state.worker)
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+ self.outbound_states.clear()
+ self.sync_waiters.clear()
+ self.inbound_tasks.clear()
return True
# -- inbound --------------------------------------------------------------
@@ -168,14 +192,52 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
status, code = _ERR[kind]
return quart.jsonify({'code': code, 'msg': detail or kind, 'data': None}), status
- def _prune_idempotency(self) -> None:
- now = time.time()
- if len(self.idempotency_cache) > _IDEMPOTENCY_MAX:
- self.idempotency_cache.clear()
- return
- expired = [k for k, ts in self.idempotency_cache.items() if now - ts > _IDEMPOTENCY_TTL]
- for k in expired:
- self.idempotency_cache.pop(k, None)
+ def _reserve_idempotency_key(self, key: str) -> str:
+ """Reserve a key without allowing unbounded state or full-map scans."""
+ now = time.monotonic()
+ accepted_at = self.idempotency_cache.get(key)
+ if accepted_at is not None:
+ if now - accepted_at <= _IDEMPOTENCY_TTL:
+ return 'duplicate'
+ self.idempotency_cache.pop(key, None)
+
+ if len(self.idempotency_cache) >= _IDEMPOTENCY_MAX:
+ self._prune_idempotency(now)
+ if len(self.idempotency_cache) >= _IDEMPOTENCY_MAX:
+ return 'overloaded'
+
+ self.idempotency_cache[key] = now
+ return 'accepted'
+
+ def _prune_idempotency(self, now: float | None = None) -> None:
+ """Remove at most a fixed number of oldest expired keys."""
+ current_time = time.monotonic() if now is None else now
+ oldest = itertools.islice(
+ self.idempotency_cache.items(),
+ _IDEMPOTENCY_PRUNE_SCAN_MAX,
+ )
+ for key, accepted_at in list(oldest):
+ if current_time - accepted_at <= _IDEMPOTENCY_TTL:
+ break
+ self.idempotency_cache.pop(key, None)
+
+ def _start_inbound_task(self, coro: typing.Coroutine) -> asyncio.Task | None:
+ self.inbound_tasks = {task for task in self.inbound_tasks if not task.done()}
+ if len(self.inbound_tasks) >= _INBOUND_TASK_MAX:
+ coro.close()
+ return None
+ task = asyncio.create_task(coro)
+ self.inbound_tasks.add(task)
+
+ def task_done(done_task: asyncio.Task) -> None:
+ self.inbound_tasks.discard(done_task)
+ if not done_task.cancelled():
+ # Retrieve failures so fire-and-forget callbacks never emit
+ # "Task exception was never retrieved" or retain tracebacks.
+ done_task.exception()
+
+ task.add_done_callback(task_done)
+ return task
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
"""Handle an inbound POST from the unified webhook dispatcher.
@@ -213,7 +275,7 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return None, self._err('bad_signature', f'invalid signature: {reason}')
try:
- data = json.loads(body)
+ data = await asyncio.to_thread(json.loads, body)
except (json.JSONDecodeError, ValueError):
return None, self._err('bad_request', 'body is not valid JSON')
if not isinstance(data, dict):
@@ -264,10 +326,11 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Idempotency.
idem = request.headers.get(signing.HEADER_IDEMPOTENCY)
if idem:
- self._prune_idempotency()
- if idem in self.idempotency_cache:
+ idempotency_result = self._reserve_idempotency_key(idem)
+ if idempotency_result == 'duplicate':
return self._err('duplicate', 'idempotency key already accepted')
- self.idempotency_cache[idem] = time.time()
+ if idempotency_result == 'overloaded':
+ return self._err('overloaded', 'idempotency capacity reached; retry later')
try:
event, session_id, session_type, message_id = self._build_event(data)
@@ -282,7 +345,8 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return await self._run_sync(event, listener, session_id, message_id)
# Fire-and-collect: kick the pipeline, return 202 immediately.
- asyncio.create_task(listener(event, self))
+ if self._start_inbound_task(listener(event, self)) is None:
+ return self._err('overloaded', 'too many inbound messages are already being processed')
return quart.jsonify(
{
'code': 0,
@@ -311,18 +375,40 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool:
"""Drop the matching session so the next message starts a fresh conversation."""
+ execution_context = getattr(self.logger, 'execution_context', None)
+ if (
+ execution_context is None
+ or not execution_context.instance_uuid
+ or not execution_context.workspace_uuid
+ or execution_context.placement_generation <= 0
+ or not self.bot_uuid
+ ):
+ raise RuntimeError('http_bot reset requires a trusted execution scope')
+ expected_prefix = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ self.bot_uuid,
+ launcher_type,
+ )
+
sess_mgr = self.ap.sess_mgr
before = len(sess_mgr.session_list)
sess_mgr.session_list = [
- s
- for s in sess_mgr.session_list
- if not (
- str(s.launcher_type.value if hasattr(s.launcher_type, 'value') else s.launcher_type) == launcher_type
- and str(s.launcher_id) == launcher_id
- )
+ s for s in sess_mgr.session_list if not self._matches_session_scope(s, expected_prefix, launcher_id)
]
return len(sess_mgr.session_list) < before
+ @staticmethod
+ def _matches_session_scope(session, expected_prefix: tuple[str, str, int, str, str], launcher_id: str) -> bool:
+ session_key = getattr(session, '_langbot_session_key', None)
+ return (
+ isinstance(session_key, tuple)
+ and len(session_key) == 6
+ and session_key[:5] == expected_prefix
+ and str(session_key[5]) == launcher_id
+ )
+
# -- outbound -------------------------------------------------------------
@staticmethod
@@ -339,7 +425,8 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return ''
def _next_sequence(self, session_id: str, is_final: bool) -> int:
- state = self.outbound_states.setdefault(session_id, _SessionOutbound())
+ state = self._outbound_state(session_id)
+ state.last_active = time.monotonic()
if state.last_was_final:
state.sequence = 1
else:
@@ -347,8 +434,43 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
state.last_was_final = is_final
return state.sequence
+ def _outbound_state(self, session_id: str) -> _SessionOutbound:
+ state = self.outbound_states.get(session_id)
+ if state is not None:
+ # Dicts retain insertion order. Moving active sessions to the end
+ # keeps bounded admission-time pruning focused on old entries.
+ self.outbound_states.pop(session_id)
+ self.outbound_states[session_id] = state
+ return state
+
+ if len(self.outbound_states) >= _OUTBOUND_STATE_MAX:
+ self._prune_outbound_states()
+ if len(self.outbound_states) >= _OUTBOUND_STATE_MAX:
+ raise _OutboundStateCapacityError(f'http_bot outbound session capacity reached ({_OUTBOUND_STATE_MAX})')
+
+ state = _SessionOutbound()
+ self.outbound_states[session_id] = state
+ return state
+
+ def _prune_outbound_states(self) -> None:
+ now = time.monotonic()
+ oldest = itertools.islice(
+ self.outbound_states.items(),
+ _OUTBOUND_PRUNE_SCAN_MAX,
+ )
+ for session_id, state in list(oldest):
+ if (
+ (state.worker is not None and not state.worker.done())
+ or not state.queue.empty()
+ or now - state.last_active < _OUTBOUND_IDLE_SECONDS
+ ):
+ continue
+ if self.outbound_states.get(session_id) is state:
+ self.outbound_states.pop(session_id, None)
+
async def _enqueue_callback(self, session_id: str, payload: dict) -> None:
- state = self.outbound_states.setdefault(session_id, _SessionOutbound())
+ state = self._outbound_state(session_id)
+ state.last_active = time.monotonic()
if state.worker is None or state.worker.done():
state.worker = asyncio.create_task(self._outbound_worker(session_id, state))
try:
@@ -364,13 +486,23 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def _outbound_worker(self, session_id: str, state: _SessionOutbound) -> None:
while True:
- payload = await state.queue.get()
+ try:
+ payload = await asyncio.wait_for(
+ state.queue.get(),
+ timeout=_OUTBOUND_IDLE_SECONDS,
+ )
+ except asyncio.TimeoutError:
+ if self.outbound_states.get(session_id) is state and state.queue.empty():
+ self.outbound_states.pop(session_id, None)
+ return
+ continue
try:
await self._deliver_callback(payload)
except Exception as e: # noqa: BLE001
await self.logger.error(f'http_bot callback delivery failed for {session_id}: {e}')
finally:
state.queue.task_done()
+ state.last_active = time.monotonic()
async def _deliver_callback(self, payload: dict) -> None:
callback_url = self.config.get('callback_url', '')
@@ -486,8 +618,11 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
collector = _SyncCollector()
self.sync_waiters[session_id] = collector
+ listener_task = self._start_inbound_task(listener(event, self))
+ if listener_task is None:
+ self.sync_waiters.pop(session_id, None)
+ return self._err('overloaded', 'too many inbound messages are already being processed')
try:
- asyncio.create_task(listener(event, self))
timeout = int(self.config.get('callback_timeout', 15)) * 4
try:
await asyncio.wait_for(collector.done.wait(), timeout=timeout)
@@ -495,6 +630,9 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.logger.warning(f'http_bot sync wait timed out for session {session_id}')
finally:
self.sync_waiters.pop(session_id, None)
+ state = self.outbound_states.get(session_id)
+ if state is not None and state.worker is None and state.queue.empty():
+ self.outbound_states.pop(session_id, None)
return quart.jsonify(
{
diff --git a/src/langbot/pkg/platform/sources/kook.py b/src/langbot/pkg/platform/sources/kook.py
index 5a6bade36..bb895d2e3 100644
--- a/src/langbot/pkg/platform/sources/kook.py
+++ b/src/langbot/pkg/platform/sources/kook.py
@@ -6,7 +6,6 @@ import json
import base64
import zlib
import traceback
-import time
import aiohttp
@@ -21,6 +20,39 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
+_KOOK_MAX_GATEWAY_MESSAGE_BYTES = 10 * 1024 * 1024
+
+
+def _bounded_zlib_decompress(payload: bytes) -> bytes:
+ decompressor = zlib.decompressobj()
+ decoded = decompressor.decompress(
+ payload,
+ _KOOK_MAX_GATEWAY_MESSAGE_BYTES + 1,
+ )
+ if len(decoded) > _KOOK_MAX_GATEWAY_MESSAGE_BYTES or decompressor.unconsumed_tail:
+ raise ValueError('KOOK gateway message exceeds the decompressed size limit')
+ decoded += decompressor.flush(_KOOK_MAX_GATEWAY_MESSAGE_BYTES + 1 - len(decoded))
+ if len(decoded) > _KOOK_MAX_GATEWAY_MESSAGE_BYTES or not decompressor.eof:
+ raise ValueError('KOOK gateway message exceeds the decompressed size limit')
+ return decoded
+
+
+def _decode_gateway_message(message: str | bytes) -> dict:
+ if isinstance(message, bytes):
+ try:
+ message_bytes = _bounded_zlib_decompress(message)
+ except zlib.error:
+ message_bytes = message
+ else:
+ message_bytes = message.encode('utf-8')
+ if len(message_bytes) > _KOOK_MAX_GATEWAY_MESSAGE_BYTES:
+ raise ValueError('KOOK gateway message exceeds the size limit')
+ decoded = json.loads(message_bytes)
+ if not isinstance(decoded, dict):
+ raise ValueError('KOOK gateway message must be a JSON object')
+ return decoded
+
+
class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Convert between LangBot MessageChain and KOOK message format"""
@@ -125,8 +157,8 @@ class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
session = httpclient.get_session()
async with session.get(content) as response:
if response.status == 200:
- image_bytes = await response.read()
- image_base64 = base64.b64encode(image_bytes).decode('utf-8')
+ image_bytes = await httpclient.read_limited(response)
+ image_base64 = (await asyncio.to_thread(base64.b64encode, image_bytes)).decode('utf-8')
# Detect image format
content_type = response.headers.get('Content-Type', 'image/png')
components.append(
@@ -270,10 +302,6 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
- # Debug: Track init
- with open('/tmp/kook_adapter_init.txt', 'w') as f:
- f.write(f'KOOK adapter __init__ called at {time.time()}\n')
-
# Validate required config
if 'token' not in config:
raise Exception('KOOK adapter requires "token" in config')
@@ -300,7 +328,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
session = httpclient.get_session()
async with session.get(base_url, params=params, headers=headers) as response:
if response.status == 200:
- data = await response.json()
+ data = await httpclient.read_json_limited(response)
if data.get('code') == 0:
gateway_url = data['data']['url']
return gateway_url
@@ -320,7 +348,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
session = httpclient.get_session()
async with session.get(base_url, headers=headers) as response:
if response.status == 200:
- data = await response.json()
+ data = await httpclient.read_json_limited(response)
if data.get('code') == 0:
user_info = data['data']
return user_info
@@ -409,17 +437,10 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Wait for HELLO within 6 seconds
try:
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
-
- # Handle compressed messages (same as main message loop)
- if isinstance(hello_msg, bytes):
- # Decompress if compressed
- try:
- hello_msg = zlib.decompress(hello_msg).decode('utf-8')
- except Exception:
- # Not compressed or decompression failed
- hello_msg = hello_msg.decode('utf-8')
-
- hello_data = json.loads(hello_msg)
+ hello_data = await asyncio.to_thread(
+ _decode_gateway_message,
+ hello_msg,
+ )
if hello_data.get('s') == 1: # HELLO signal
await self._handle_hello(hello_data['d'])
@@ -433,16 +454,11 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Main message loop
async for message in ws:
- if isinstance(message, bytes):
- # Decompress if compressed
- try:
- message = zlib.decompress(message).decode('utf-8')
- except Exception:
- # Not compressed or decompression failed
- message = message.decode('utf-8')
-
try:
- msg_data = json.loads(message)
+ msg_data = await asyncio.to_thread(
+ _decode_gateway_message,
+ message,
+ )
signal = msg_data.get('s')
if signal == 0: # EVENT
@@ -516,7 +532,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async with self.http_session.post(url, json=payload, headers=headers) as response:
if response.status == 200:
- result = await response.json()
+ result = await httpclient.read_json_limited(response)
if result.get('code') == 0:
await self.logger.debug(f'Message sent successfully to {target_id}')
else:
@@ -582,7 +598,7 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async with self.http_session.post(url, json=payload, headers=headers) as response:
if response.status == 200:
- result = await response.json()
+ result = await httpclient.read_json_limited(response)
if result.get('code') == 0:
await self.logger.debug('Reply sent successfully')
else:
diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py
index 96e40469c..3c3476159 100644
--- a/src/langbot/pkg/platform/sources/lark.py
+++ b/src/langbot/pkg/platform/sources/lark.py
@@ -15,7 +15,7 @@ import hashlib
from Crypto.Cipher import AES
import tempfile
import os
-import mimetypes
+import threading
from langbot.pkg.utils import httpclient
import lark_oapi.ws.exception
@@ -34,6 +34,53 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
import langbot_plugin.api.entities.builtin.provider.session as provider_session
+_MAX_LARK_MEDIA_BYTES = 10 * 1024 * 1024
+
+
+def _decode_lark_base64_limited(value: str) -> bytes:
+ if ',' in value:
+ value = value.split(',', 1)[1]
+ max_encoded_bytes = 4 * ((_MAX_LARK_MEDIA_BYTES + 2) // 3)
+ if len(value) > max_encoded_bytes:
+ raise ValueError('Lark media exceeds the size limit')
+ decoded = base64.b64decode(value)
+ if len(decoded) > _MAX_LARK_MEDIA_BYTES:
+ raise ValueError('Lark media exceeds the size limit')
+ return decoded
+
+
+def _read_lark_path_limited(path: str) -> bytes:
+ if os.path.getsize(path) > _MAX_LARK_MEDIA_BYTES:
+ raise ValueError('Lark media exceeds the size limit')
+ with open(path, 'rb') as file:
+ body = file.read(_MAX_LARK_MEDIA_BYTES + 1)
+ if len(body) > _MAX_LARK_MEDIA_BYTES:
+ raise ValueError('Lark media exceeds the size limit')
+ return body
+
+
+def _write_lark_temp_file(data: bytes) -> str:
+ with tempfile.NamedTemporaryFile(delete=False) as temp_file:
+ temp_file.write(data)
+ temp_file.flush()
+ return temp_file.name
+
+
+def _read_lark_response_file_limited(response) -> bytes:
+ content_length = response.raw.headers.get('content-length')
+ if content_length is not None:
+ try:
+ if int(content_length) > _MAX_LARK_MEDIA_BYTES:
+ raise ValueError('Lark media exceeds the size limit')
+ except (TypeError, ValueError) as exc:
+ if 'exceeds' in str(exc):
+ raise
+ body = response.file.read(_MAX_LARK_MEDIA_BYTES + 1)
+ if len(body) > _MAX_LARK_MEDIA_BYTES:
+ raise ValueError('Lark media exceeds the size limit')
+ return body
+
+
def _lark_form_component_name(prefix: str, field_name: str, index: int) -> str:
safe_name = re.sub(r'[^A-Za-z0-9_]', '_', field_name)[:8] or 'field'
digest = hashlib.sha1(field_name.encode('utf-8')).hexdigest()[:6]
@@ -299,68 +346,33 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]:
"""Upload an image to Lark and return the image_key, or None if upload fails."""
- image_bytes = None
-
- if msg.base64:
- try:
- # Remove data URL prefix if present
- base64_data = msg.base64
- if base64_data.startswith('data:'):
- base64_data = base64_data.split(',', 1)[1]
- image_bytes = base64.b64decode(base64_data)
- except Exception as e:
- print(f'Failed to decode base64 image: {e}')
- traceback.print_exc()
- return None
- elif msg.url:
- try:
- session = httpclient.get_session()
- async with session.get(msg.url) as response:
- if response.status == 200:
- image_bytes = await response.read()
- else:
- print(f'Failed to download image from {msg.url}: HTTP {response.status}')
- return None
- except Exception as e:
- print(f'Failed to download image from {msg.url}: {e}')
- traceback.print_exc()
- return None
- elif msg.path:
- try:
- with open(msg.path, 'rb') as f:
- image_bytes = f.read()
- except Exception as e:
- print(f'Failed to read image from path {msg.path}: {e}')
- traceback.print_exc()
- return None
-
- if image_bytes is None:
+ try:
+ image_bytes, _mime_type = await msg.get_bytes()
+ except Exception as exc:
+ print(f'Failed to load Lark image: {exc}')
+ traceback.print_exc()
+ return None
+ if not image_bytes:
print(
f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})'
)
return None
try:
- # Create a temporary file to store the image bytes
- import tempfile
- import os
-
- with tempfile.NamedTemporaryFile(delete=False) as temp_file:
- temp_file.write(image_bytes)
- temp_file.flush()
- temp_file_path = temp_file.name
+ temp_file_path = await asyncio.to_thread(
+ _write_lark_temp_file,
+ image_bytes,
+ )
try:
- # Create image request using the temporary file
- request = (
- CreateImageRequest.builder()
- .request_body(
- CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()
+ with open(temp_file_path, 'rb') as upload_file:
+ request = (
+ CreateImageRequest.builder()
+ .request_body(CreateImageRequestBody.builder().image_type('message').image(upload_file).build())
+ .build()
)
- .build()
- )
- response = await api_client.im.v1.image.acreate(request)
+ response = await api_client.im.v1.image.acreate(request)
if not response.success():
print(
@@ -395,23 +407,24 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
duration: Duration in milliseconds (for audio files).
"""
try:
- with tempfile.NamedTemporaryFile(delete=False) as temp_file:
- temp_file.write(file_bytes)
- temp_file_path = temp_file.name
+ if len(file_bytes) > _MAX_LARK_MEDIA_BYTES:
+ raise ValueError('Lark media exceeds the size limit')
+ temp_file_path = await asyncio.to_thread(
+ _write_lark_temp_file,
+ file_bytes,
+ )
try:
- body_builder = (
- CreateFileRequestBody.builder()
- .file_type(file_type)
- .file_name(file_name)
- .file(open(temp_file_path, 'rb'))
- )
- if duration is not None:
- body_builder = body_builder.duration(duration)
+ with open(temp_file_path, 'rb') as upload_file:
+ body_builder = (
+ CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(upload_file)
+ )
+ if duration is not None:
+ body_builder = body_builder.duration(duration)
- request = CreateFileRequest.builder().request_body(body_builder.build()).build()
+ request = CreateFileRequest.builder().request_body(body_builder.build()).build()
- response = await api_client.im.v1.file.acreate(request)
+ response = await api_client.im.v1.file.acreate(request)
if not response.success():
print(
@@ -436,10 +449,10 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
if msg.base64:
try:
- base64_str = msg.base64
- if ',' in base64_str:
- base64_str = base64_str.split(',', 1)[1]
- data = base64.b64decode(base64_str)
+ data = await asyncio.to_thread(
+ _decode_lark_base64_limited,
+ msg.base64,
+ )
except Exception:
pass
elif msg.url:
@@ -447,13 +460,18 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
session = httpclient.get_session()
async with session.get(msg.url) as resp:
if resp.status == 200:
- data = await resp.read()
+ data = await httpclient.read_limited(
+ resp,
+ max_bytes=_MAX_LARK_MEDIA_BYTES,
+ )
except Exception:
pass
elif msg.path:
try:
- with open(msg.path, 'rb') as f:
- data = f.read()
+ data = await asyncio.to_thread(
+ _read_lark_path_limited,
+ str(msg.path),
+ )
except Exception:
pass
@@ -694,8 +712,11 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
- image_bytes = response.file.read()
- image_base64 = base64.b64encode(image_bytes).decode()
+ image_bytes = await asyncio.to_thread(
+ _read_lark_response_file_limited,
+ response,
+ )
+ image_base64 = (await asyncio.to_thread(base64.b64encode, image_bytes)).decode()
image_format = response.raw.headers['content-type']
@@ -721,27 +742,18 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]'))
return platform_message.MessageChain(lb_msg_list)
- # Read audio bytes
- audio_bytes = response.file.read()
- audio_base64 = base64.b64encode(audio_bytes).decode()
+ audio_bytes = await asyncio.to_thread(
+ _read_lark_response_file_limited,
+ response,
+ )
+ audio_base64 = (await asyncio.to_thread(base64.b64encode, audio_bytes)).decode()
# Get content type from response headers
content_type = response.raw.headers.get('content-type', 'audio/mpeg')
- mime_main = content_type.split(';')[0].strip()
- ext = mimetypes.guess_extension(mime_main) or '.bin'
- temp_dir = tempfile.gettempdir()
- temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}')
-
- with open(temp_file_path, 'wb') as f:
- f.write(audio_bytes)
-
- # Create Voice message: prefer path/url + length, include base64 as optional data URI
lb_msg_list.append(
platform_message.Voice(
voice_id=file_key,
- url=f'file://{temp_file_path}',
- path=temp_file_path,
base64=f'data:{content_type};base64,{audio_base64}',
length=(duration // 1000) if duration else None,
)
@@ -770,40 +782,22 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
- file_bytes = response.file.read()
- file_base64 = base64.b64encode(file_bytes).decode()
+ file_bytes = await asyncio.to_thread(
+ _read_lark_response_file_limited,
+ response,
+ )
+ file_base64 = (await asyncio.to_thread(base64.b64encode, file_bytes)).decode()
file_format = response.raw.headers['content-type']
file_size = len(file_bytes)
- # Determine extension from content-type if possible
- content_type = response.raw.headers.get('content-type', '')
- mime_main = content_type.split(';')[0].strip() if content_type else ''
- ext = mimetypes.guess_extension(mime_main) or ''
-
- # Ensure a safe filename (avoid path components)
- safe_name = os.path.basename(file_name).replace('/', '_').replace('\\', '_')
- if ext and not safe_name.lower().endswith(ext.lower()):
- filename_with_ext = f'{safe_name}{ext}'
- else:
- filename_with_ext = safe_name
-
- temp_dir = tempfile.gettempdir()
- temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}')
-
- with open(temp_file_path, 'wb') as f:
- f.write(file_bytes)
-
- # Create File message with local path and file:// URL
lb_msg_list.append(
platform_message.File(
id=file_key,
name=file_name,
size=file_size,
- url=f'file://{temp_file_path}',
- path=temp_file_path,
- base64=f'data:{file_format};base64,{file_base64}', # not including base64 by default to save memory; can be added if needed
+ base64=f'data:{file_format};base64,{file_base64}',
)
)
@@ -1042,16 +1036,26 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# card_id → input_defs / inputs captured for the selected-action notice
card_form_input_defs: dict[str, list[dict]]
card_form_inputs: dict[str, dict]
+ card_last_accessed: dict[str, float]
+ card_cleanup_at: float
# set of card_ids that have already transitioned from "buttons visible" to "resume layout"
card_resume_transitioned: set[str]
+ inbound_event_tasks: set[asyncio.Task]
+ threadsafe_event_futures: set[typing.Any]
+ threadsafe_event_lock: typing.Any = pydantic.Field(exclude=True)
_MONITORING_MAPPING_TTL = 600 # 10 minutes
+ _MAX_INBOUND_EVENTS = 100
+ _MAX_TENANT_ACCESS_TOKENS = 1024
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
bot_uuid: str = None # 机器人UUID
app_ticket: str = None # 商店应用用到
app_access_token: str = None # 商店应用用到
app_access_token_expire_at: int = None
- tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射
+ tenant_access_tokens: dict[str, dict[str, str]] = pydantic.Field(
+ default_factory=dict,
+ exclude=True,
+ )
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
quart_app = quart.Quart(__name__)
@@ -1062,11 +1066,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.listeners[type(lb_event)](lb_event, self)
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
- asyncio.create_task(on_message(event))
+ self._schedule_inbound_event(on_message(event))
def schedule_on_app_loop(coro):
"""Run a coroutine on the application event loop from sync callbacks."""
- return asyncio.run_coroutine_threadsafe(coro, self.ap.event_loop)
+ return self._schedule_threadsafe_event(coro)
def sync_on_card_action(event):
try:
@@ -1289,7 +1293,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_form_content={},
card_form_input_defs={},
card_form_inputs={},
+ card_last_accessed={},
+ card_cleanup_at=0.0,
card_resume_transitioned=set(),
+ inbound_event_tasks=set(),
+ threadsafe_event_futures=set(),
+ threadsafe_event_lock=threading.Lock(),
+ tenant_access_tokens={},
seq=1,
listeners={},
quart_app=quart_app,
@@ -1300,6 +1310,45 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
**kwargs,
)
+ def _schedule_inbound_event(self, coro) -> None:
+ for task in tuple(self.inbound_event_tasks):
+ if task.done():
+ self.inbound_event_tasks.discard(task)
+ if len(self.inbound_event_tasks) >= self._MAX_INBOUND_EVENTS:
+ coro.close()
+ return
+ task = asyncio.create_task(coro)
+ self.inbound_event_tasks.add(task)
+
+ def done(done_task: asyncio.Task) -> None:
+ self.inbound_event_tasks.discard(done_task)
+ if not done_task.cancelled():
+ done_task.exception()
+
+ task.add_done_callback(done)
+
+ def _schedule_threadsafe_event(self, coro):
+ """Submit one bounded callback from the Lark SDK's sync boundary."""
+
+ with self.threadsafe_event_lock:
+ for future in tuple(self.threadsafe_event_futures):
+ if future.done():
+ self.threadsafe_event_futures.discard(future)
+ if len(self.threadsafe_event_futures) >= self._MAX_INBOUND_EVENTS:
+ coro.close()
+ return None
+ future = asyncio.run_coroutine_threadsafe(coro, self.ap.event_loop)
+ self.threadsafe_event_futures.add(future)
+
+ def done(done_future) -> None:
+ with self.threadsafe_event_lock:
+ self.threadsafe_event_futures.discard(done_future)
+ if not done_future.cancelled():
+ done_future.exception()
+
+ future.add_done_callback(done)
+ return future
+
def request_app_ticket(self, api_client, config):
app_id = config['app_id']
app_secret = config['app_secret']
@@ -1376,6 +1425,12 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'token': tenant_access_token,
'expire_at': int(time.time()) + expire - 300,
}
+ now = int(time.time())
+ for cached_key, cached_token in tuple(self.tenant_access_tokens.items()):
+ if int(cached_token.get('expire_at', 0)) <= now:
+ self.tenant_access_tokens.pop(cached_key, None)
+ while len(self.tenant_access_tokens) > self._MAX_TENANT_ACCESS_TOKENS:
+ self.tenant_access_tokens.pop(next(iter(self.tenant_access_tokens)), None)
def get_tenant_access_token(self, tenant_key: str):
if tenant_key is None or 'isv' != self.config.get('app_type', 'self'):
@@ -1558,6 +1613,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
user_msg_id = query.message_event.message_chain.message_id
if user_msg_id:
self.pending_monitoring_msg[user_msg_id] = monitoring_message_id
+ while len(self.pending_monitoring_msg) > CARD_ID_CACHE_SIZE:
+ self.pending_monitoring_msg.pop(next(iter(self.pending_monitoring_msg)), None)
except Exception as e:
await self.logger.debug(f'Failed to map message to monitoring message: {e}')
@@ -1570,6 +1627,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
def _next_card_sequence(self, card_id: str, suggested: int = 1) -> int:
"""Return the next strictly increasing sequence for a card update."""
+ self._touch_card(card_id)
current = self.card_sequence_dict.get(card_id, 0)
next_seq = max(current + 1, suggested)
self.card_sequence_dict[card_id] = next_seq
@@ -1577,6 +1635,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
def _register_card_for_source(self, card_id: str, *source_ids: str) -> None:
"""Register a card_id under one or more source message ids."""
+ self._touch_card(card_id)
bucket = self.card_id_to_source_ids.setdefault(card_id, set())
for sid in source_ids:
if not sid:
@@ -1596,8 +1655,24 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_form_content.pop(card_id, None)
self.card_form_input_defs.pop(card_id, None)
self.card_form_inputs.pop(card_id, None)
+ self.card_last_accessed.pop(card_id, None)
self.card_resume_transitioned.discard(card_id)
+ def _touch_card(self, card_id: str) -> None:
+ now = time.monotonic()
+ if now - self.card_cleanup_at >= 60 or len(self.card_last_accessed) >= CARD_ID_CACHE_SIZE:
+ self.card_cleanup_at = now
+ for stale_card_id, last_accessed in tuple(self.card_last_accessed.items()):
+ if now - last_accessed >= CARD_ID_CACHE_MAX_LIFETIME:
+ self._drop_card_state(stale_card_id)
+ while len(self.card_last_accessed) >= CARD_ID_CACHE_SIZE:
+ oldest_card_id = min(
+ self.card_last_accessed,
+ key=self.card_last_accessed.__getitem__,
+ )
+ self._drop_card_state(oldest_card_id)
+ self.card_last_accessed[card_id] = now
+
async def create_card_id(self, message_id):
try:
# self.logger.debug('飞书支持stream输出,创建卡片......')
@@ -1793,6 +1868,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_id_dict[message_id] = response.data.card_id
card_id = response.data.card_id
+ self._touch_card(card_id)
self.card_sequence_dict[card_id] = 0
return card_id
@@ -1864,7 +1940,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time())
self._cleanup_monitoring_mapping()
except Exception as e:
- asyncio.create_task(self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}'))
+ await self.logger.debug(f'Failed to transfer monitoring mapping in create_message_card: {e}')
return True
@@ -2872,8 +2948,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
data = await request.json
if 'encrypt' in data:
- data = self.cipher.decrypt_string(data['encrypt'])
- data = json.loads(data)
+ encrypted = data['encrypt']
+ data = await asyncio.to_thread(lambda: json.loads(self.cipher.decrypt_string(encrypted)))
type = self.get_event_type(data)
context = EventContext(data)
if 'url_verification' == type:
@@ -3143,4 +3219,38 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# 所以要设置_auto_reconnect=False,让其不重连。
self.bot._auto_reconnect = False
await self.bot._disconnect()
+ inbound_tasks = list(self.inbound_event_tasks)
+ for task in inbound_tasks:
+ if not task.done():
+ task.cancel()
+ if inbound_tasks:
+ await asyncio.gather(*inbound_tasks, return_exceptions=True)
+ self.inbound_event_tasks.clear()
+ with self.threadsafe_event_lock:
+ threadsafe_futures = list(self.threadsafe_event_futures)
+ for future in threadsafe_futures:
+ future.cancel()
+ if threadsafe_futures:
+ await asyncio.gather(
+ *(asyncio.wrap_future(future) for future in threadsafe_futures),
+ return_exceptions=True,
+ )
+ with self.threadsafe_event_lock:
+ self.threadsafe_event_futures.clear()
+ self.tenant_access_tokens.clear()
+ self.card_id_dict.clear()
+ self.pending_monitoring_msg.clear()
+ self.reply_to_monitoring_msg.clear()
+ for card_id in tuple(self.card_last_accessed):
+ self._drop_card_state(card_id)
+ self.card_last_accessed.clear()
+ self.reply_message_card_ids.clear()
+ self.card_sequence_dict.clear()
+ self.card_id_to_source_ids.clear()
+ self.card_streaming_text.clear()
+ self.card_pre_pause_text.clear()
+ self.card_form_content.clear()
+ self.card_form_input_defs.clear()
+ self.card_form_inputs.clear()
+ self.card_resume_transitioned.clear()
return False
diff --git a/src/langbot/pkg/platform/sources/legacy/gewechat.py b/src/langbot/pkg/platform/sources/legacy/gewechat.py
index 68e1bdedd..ee1f40281 100644
--- a/src/langbot/pkg/platform/sources/legacy/gewechat.py
+++ b/src/langbot/pkg/platform/sources/legacy/gewechat.py
@@ -6,7 +6,6 @@ import traceback
import time
import re
import copy
-import threading
import quart
from langbot.pkg.utils import httpclient
@@ -483,6 +482,7 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.message_converter = GewechatMessageConverter(config)
self.event_converter = GewechatEventConverter(config)
+ self.listeners = {}
@self.quart_app.route('/gewechat/callback', methods=['POST'])
async def gewechat_callback():
@@ -518,9 +518,13 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
at_targets = at_targets or []
member_info = []
if at_targets:
- member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[
- 'data'
- ]
+ member_result = await asyncio.to_thread(
+ self.bot.get_chatroom_member_detail,
+ self.config['app_id'],
+ target_id,
+ at_targets[::-1],
+ )
+ member_info = member_result['data']
# 处理消息组件
for msg in content_list:
@@ -596,7 +600,7 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
}
if handler := handler_map.get(msg['type']):
- handler(msg)
+ await asyncio.to_thread(handler, msg)
else:
await self.logger.warning(f'未处理的消息类型: {msg["type"]}')
continue
@@ -645,8 +649,9 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
json={'app_id': self.config['app_id']},
) as response:
if response.status != 200:
- raise Exception(f'获取gewechat token失败: {await response.text()}')
- self.config['token'] = (await response.json())['data']
+ error = await httpclient.read_text_limited(response)
+ raise Exception(f'获取gewechat token失败: {error}')
+ self.config['token'] = (await httpclient.read_json_limited(response))['data']
self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token'])
@@ -672,7 +677,7 @@ class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
except Exception as e:
raise Exception(f'设置 Gewechat 回调失败, token失效: {e}')
- threading.Thread(target=gewechat_login_process).start()
+ await asyncio.to_thread(gewechat_login_process)
async def shutdown_trigger_placeholder():
while True:
diff --git a/src/langbot/pkg/platform/sources/legacy/nakuru.py b/src/langbot/pkg/platform/sources/legacy/nakuru.py
index 1e34af0b9..d69aa2d94 100644
--- a/src/langbot/pkg/platform/sources/legacy/nakuru.py
+++ b/src/langbot/pkg/platform/sources/legacy/nakuru.py
@@ -311,15 +311,17 @@ class NakuruAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
import requests
- resp = requests.get(
- url='http://{}:{}/get_login_info'.format(self.cfg['host'], self.cfg['http_port']),
+ resp = await asyncio.to_thread(
+ requests.get,
+ 'http://{}:{}/get_login_info'.format(self.cfg['host'], self.cfg['http_port']),
headers={'Authorization': 'Bearer ' + self.cfg['token'] if 'token' in self.cfg else ''},
timeout=5,
proxies=None,
)
if resp.status_code == 403:
raise Exception('go-cqhttp拒绝访问,请检查配置文件中nakuru适配器的配置')
- self.bot_account_id = int(resp.json()['data']['user_id'])
+ response_data = await httpclient.parse_json_response(resp)
+ self.bot_account_id = int(response_data['data']['user_id'])
except Exception:
raise Exception('获取go-cqhttp账号信息失败, 请检查是否已启动go-cqhttp并配置正确')
await self.bot._run()
diff --git a/src/langbot/pkg/platform/sources/legacy/qqbotpy.py b/src/langbot/pkg/platform/sources/legacy/qqbotpy.py
index 3a55482c9..21ac5703e 100644
--- a/src/langbot/pkg/platform/sources/legacy/qqbotpy.py
+++ b/src/langbot/pkg/platform/sources/legacy/qqbotpy.py
@@ -5,6 +5,7 @@ import typing
import datetime
import re
import traceback
+from collections import OrderedDict
import botpy
import botpy.message as botpy_message
@@ -40,7 +41,8 @@ event_handler_mapping = {
}
-cached_message_ids = {}
+_CACHED_MESSAGE_ID_LIMIT = 10000
+cached_message_ids: OrderedDict[str, str] = OrderedDict()
"""由于QQ官方的消息id是字符串,而YiriMirai的消息id是整数,所以需要一个索引来进行转换"""
id_index = 0
@@ -53,6 +55,8 @@ def save_msg_id(message_id: str) -> int:
crt_index = id_index
id_index += 1
cached_message_ids[str(crt_index)] = message_id
+ while len(cached_message_ids) > _CACHED_MESSAGE_ID_LIMIT:
+ cached_message_ids.popitem(last=False)
return crt_index
@@ -355,6 +359,7 @@ class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.cfg = cfg
self.ap = ap
self.logger = logger
+ self.cached_official_messages = OrderedDict()
self.group_msg_seq = 1
self.c2c_msg_seq = 1
@@ -490,6 +495,8 @@ class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
],
):
self.cached_official_messages[str(message.id)] = message
+ while len(self.cached_official_messages) > 1000:
+ self.cached_official_messages.popitem(last=False)
await callback(self.event_converter.target2yiri(message), self)
for event_handler in event_handler_mapping[event_type]:
@@ -519,6 +526,8 @@ class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await (await self.bot.start(**self.cfg))
async def kill(self) -> bool:
+ self.cached_official_messages.clear()
if not self.bot.is_closed():
await self.bot.close()
return True
+ return True
diff --git a/src/langbot/pkg/platform/sources/line.py b/src/langbot/pkg/platform/sources/line.py
index 3d0f75c7d..496a5ed81 100644
--- a/src/langbot/pkg/platform/sources/line.py
+++ b/src/langbot/pkg/platform/sources/line.py
@@ -13,6 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from ..logger import EventLogger
+from ...utils import bounded_executor
from linebot.v3 import WebhookHandler
@@ -30,6 +31,14 @@ from linebot.v3.webhooks import (
from linebot.v3.webhook import WebhookParser
from linebot.v3.messaging import MessagingApiBlob
+MAX_LINE_MEDIA_BYTES = 10 * 1024 * 1024
+
+
+def _validate_line_media_content(content: bytes) -> bytes:
+ if len(content) > MAX_LINE_MEDIA_BYTES:
+ raise ValueError(f'LINE media exceeds the {MAX_LINE_MEDIA_BYTES}-byte limit')
+ return content
+
class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
@@ -63,9 +72,13 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
elif isinstance(message.message, VideoMessageContent):
pass
elif isinstance(message.message, ImageMessageContent):
- message_content = MessagingApiBlob(bot_client).get_message_content(message.message.id)
+ message_content = await asyncio.to_thread(
+ MessagingApiBlob(bot_client).get_message_content,
+ message.message.id,
+ )
+ _validate_line_media_content(message_content)
- base64_string = base64.b64encode(message_content).decode('utf-8')
+ base64_string = await asyncio.to_thread(lambda: base64.b64encode(message_content).decode('utf-8'))
# 如果需要Data URI格式(用于直接嵌入HTML等)
# 首先需要知道图片类型,LINE图片通常是JPEG
@@ -173,20 +186,22 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
for content in content_list:
if content['type'] == 'text':
- self.bot.reply_message_with_http_info(
+ await asyncio.to_thread(
+ self.bot.reply_message_with_http_info,
ReplyMessageRequest(
reply_token=message_source.source_platform_object.reply_token,
messages=[TextMessage(text=content['content'])],
- )
+ ),
)
elif content['type'] == 'image':
# LINE ImageMessage requires original_content_url and preview_image_url
image_url = content['image']
- self.bot.reply_message_with_http_info(
+ await asyncio.to_thread(
+ self.bot.reply_message_with_http_info,
ReplyMessageRequest(
reply_token=message_source.source_platform_object.reply_token,
messages=[ImageMessage(original_content_url=image_url, preview_image_url=image_url)],
- )
+ ),
)
async def is_muted(self, group_id: int) -> bool:
@@ -266,4 +281,5 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await keep_alive()
async def kill(self) -> bool:
- pass
+ await bounded_executor.run_blocking_cleanup(self.api_client.close)
+ return True
diff --git a/src/langbot/pkg/platform/sources/matrix.py b/src/langbot/pkg/platform/sources/matrix.py
index da159223f..7cf39dd86 100644
--- a/src/langbot/pkg/platform/sources/matrix.py
+++ b/src/langbot/pkg/platform/sources/matrix.py
@@ -5,6 +5,8 @@ import asyncio
import traceback
import base64
import json
+import os
+from urllib.parse import urlparse
import nio
@@ -16,6 +18,58 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
+_MAX_MATRIX_MEDIA_BYTES = 10 * 1024 * 1024
+
+
+def _decode_matrix_base64_limited(value: str) -> bytes:
+ if ';base64,' in value:
+ value = value.split(';base64,', 1)[1]
+ max_encoded_bytes = 4 * ((_MAX_MATRIX_MEDIA_BYTES + 2) // 3)
+ if len(value) > max_encoded_bytes:
+ raise ValueError('Matrix media exceeds the size limit')
+ decoded = base64.b64decode(value)
+ if len(decoded) > _MAX_MATRIX_MEDIA_BYTES:
+ raise ValueError('Matrix media exceeds the size limit')
+ return decoded
+
+
+def _read_matrix_file_limited(path: str) -> bytes:
+ if os.path.getsize(path) > _MAX_MATRIX_MEDIA_BYTES:
+ raise ValueError('Matrix media exceeds the size limit')
+ with open(path, 'rb') as file:
+ body = file.read(_MAX_MATRIX_MEDIA_BYTES + 1)
+ if len(body) > _MAX_MATRIX_MEDIA_BYTES:
+ raise ValueError('Matrix media exceeds the size limit')
+ return body
+
+
+async def _download_matrix_media_limited(
+ client: nio.AsyncClient,
+ mxc_url: str,
+) -> tuple[bytes, str]:
+ parsed = urlparse(mxc_url)
+ if parsed.scheme != 'mxc' or not parsed.netloc or not parsed.path.strip('/'):
+ raise ValueError('Invalid Matrix media URL')
+ method, path = nio.Api.download(
+ parsed.netloc,
+ parsed.path.replace('/', ''),
+ access_token=None,
+ )
+ headers = {}
+ if client.access_token:
+ headers['Authorization'] = f'Bearer {client.access_token}'
+ response = await client.send(method, path, headers=headers, timeout=30)
+ try:
+ response.raise_for_status()
+ body = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_MATRIX_MEDIA_BYTES,
+ )
+ return body, response.headers.get('Content-Type', 'application/octet-stream')
+ finally:
+ response.release()
+
+
class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]:
@@ -26,17 +80,22 @@ class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
elif isinstance(component, platform_message.Image):
image_bytes = None
if component.base64:
- b64_data = component.base64
- if ';base64,' in b64_data:
- b64_data = b64_data.split(';base64,', 1)[1]
- image_bytes = base64.b64decode(b64_data)
+ image_bytes = await asyncio.to_thread(
+ _decode_matrix_base64_limited,
+ component.base64,
+ )
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
- image_bytes = await response.read()
+ image_bytes = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_MATRIX_MEDIA_BYTES,
+ )
elif component.path:
- with open(component.path, 'rb') as f:
- image_bytes = f.read()
+ image_bytes = await asyncio.to_thread(
+ _read_matrix_file_limited,
+ str(component.path),
+ )
if image_bytes:
resp = await client.upload(image_bytes, content_type='image/png')
if isinstance(resp, nio.UploadResponse):
@@ -44,17 +103,22 @@ class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
elif isinstance(component, platform_message.File):
file_bytes = None
if component.base64:
- b64_data = component.base64
- if ';base64,' in b64_data:
- b64_data = b64_data.split(';base64,', 1)[1]
- file_bytes = base64.b64decode(b64_data)
+ file_bytes = await asyncio.to_thread(
+ _decode_matrix_base64_limited,
+ component.base64,
+ )
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
- file_bytes = await response.read()
+ file_bytes = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_MATRIX_MEDIA_BYTES,
+ )
elif component.path:
- with open(component.path, 'rb') as f:
- file_bytes = f.read()
+ file_bytes = await asyncio.to_thread(
+ _read_matrix_file_limited,
+ str(component.path),
+ )
if file_bytes:
file_name = getattr(component, 'name', None) or 'file'
resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name)
@@ -86,11 +150,12 @@ class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
elif isinstance(event, nio.RoomMessageImage):
mxc_url = event.url
if mxc_url:
- resp = await client.download(mxc_url)
- if isinstance(resp, nio.DownloadResponse):
- b64 = base64.b64encode(resp.body).decode('utf-8')
- content_type = resp.content_type or 'image/png'
- message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
+ body, content_type = await _download_matrix_media_limited(
+ client,
+ mxc_url,
+ )
+ b64 = (await asyncio.to_thread(base64.b64encode, body)).decode('utf-8')
+ message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
if event.body:
message_components.append(platform_message.Plain(text=event.body))
@@ -431,14 +496,15 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if not mxc_url:
return
try:
- resp = await self.client.download(mxc_url)
- if isinstance(resp, nio.DownloadResponse):
- b64 = base64.b64encode(resp.body).decode('utf-8')
- content_type = resp.content_type or 'image/png'
- await self.logger.info(
- f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
- images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
- )
+ body, content_type = await _download_matrix_media_limited(
+ self.client,
+ mxc_url,
+ )
+ b64 = (await asyncio.to_thread(base64.b64encode, body)).decode('utf-8')
+ await self.logger.info(
+ f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
+ images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
+ )
except Exception:
await self.logger.error(
f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}'
@@ -672,11 +738,16 @@ class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def kill(self) -> bool:
self._running = False
+ bridge_tasks = []
for bridge in self._bridges:
if bridge.login_task and not bridge.login_task.done():
bridge.login_task.cancel()
+ bridge_tasks.append(bridge.login_task)
if bridge.check_task and not bridge.check_task.done():
bridge.check_task.cancel()
+ bridge_tasks.append(bridge.check_task)
+ if bridge_tasks:
+ await asyncio.gather(*bridge_tasks, return_exceptions=True)
if self.client:
await self.client.close()
await self.logger.debug('Matrix adapter stopped')
diff --git a/src/langbot/pkg/platform/sources/officialaccount.py b/src/langbot/pkg/platform/sources/officialaccount.py
index 288991d62..1506ae8ec 100644
--- a/src/langbot/pkg/platform/sources/officialaccount.py
+++ b/src/langbot/pkg/platform/sources/officialaccount.py
@@ -164,6 +164,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
await keep_alive()
async def kill(self) -> bool:
+ self.bot.clear()
return False
async def unregister_listener(
diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.py b/src/langbot/pkg/platform/sources/openclaw_weixin.py
index 9253f90e4..4726888b1 100644
--- a/src/langbot/pkg/platform/sources/openclaw_weixin.py
+++ b/src/langbot/pkg/platform/sources/openclaw_weixin.py
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import base64
+import os
import traceback
import typing
@@ -26,6 +27,7 @@ from langbot.libs.openclaw_weixin_api.types import (
WeixinMessage,
)
from langbot.pkg.entity.persistence import bot as persistence_bot
+from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
@@ -33,6 +35,10 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
+from langbot.pkg.api.http.context import ExecutionContext
+
+_MAX_OPENCLAW_COMPONENT_BYTES = 10 * 1024 * 1024
+
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
@@ -112,7 +118,12 @@ class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageCo
elif item.type == MessageItem.IMAGE and item.image_item:
if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes:
- b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8')
+ b64 = (
+ await asyncio.to_thread(
+ base64.b64encode,
+ item.image_item._downloaded_bytes,
+ )
+ ).decode('utf-8')
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
else:
components.append(platform_message.Unknown(text='[Image]'))
@@ -278,11 +289,36 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
return
try:
ap = self.logger.ap
- await ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_bot.Bot)
- .where(persistence_bot.Bot.uuid == self._bot_uuid)
- .values(adapter_config=self.config)
- )
+ execution_context = getattr(self.logger, 'execution_context', None)
+ if not isinstance(execution_context, ExecutionContext):
+ raise RuntimeError('Weixin Bot config persistence requires an ExecutionContext')
+ if execution_context.bot_uuid != self._bot_uuid:
+ raise RuntimeError('Weixin Bot UUID does not match its ExecutionContext')
+
+ async def persist() -> None:
+ binding = await ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise RuntimeError('Weixin Bot Workspace belongs to another LangBot instance')
+
+ await ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_bot.Bot)
+ .where(persistence_bot.Bot.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_bot.Bot.uuid == self._bot_uuid)
+ .values(adapter_config=self.config)
+ )
+
+ cloud_runtime = getattr(getattr(ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ tenant_uow = getattr(ap.persistence_mgr, 'tenant_uow', None)
+ if not callable(tenant_uow):
+ raise RuntimeError('Cloud adapter persistence requires an explicit tenant UoW')
+ async with tenant_uow(execution_context.workspace_uuid):
+ await persist()
+ else:
+ await persist()
except Exception as e:
await self.logger.warning(f'Failed to persist adapter config: {e}')
@@ -374,19 +410,30 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
path_val = getattr(component, 'path', None)
if b64_val:
- return base64.b64decode(b64_val)
+ max_encoded_chars = 4 * ((_MAX_OPENCLAW_COMPONENT_BYTES + 2) // 3) + 4
+ if len(b64_val) > max_encoded_chars:
+ raise ValueError('OpenClaw media exceeds the size limit')
+ data = await asyncio.to_thread(base64.b64decode, b64_val)
+ if len(data) > _MAX_OPENCLAW_COMPONENT_BYTES:
+ raise ValueError('OpenClaw media exceeds the size limit')
+ return data
elif url_val and url_val.startswith(('http://', 'https://')):
- import aiohttp
-
- async with aiohttp.ClientSession() as session:
- async with session.get(url_val) as resp:
- if resp.status == 200:
- return await resp.read()
+ session = httpclient.get_session()
+ async with session.get(url_val) as resp:
+ if resp.status == 200:
+ return await httpclient.read_limited(resp)
elif path_val:
- import asyncio
+ if await asyncio.to_thread(os.path.getsize, path_val) > _MAX_OPENCLAW_COMPONENT_BYTES:
+ raise ValueError('OpenClaw media exceeds the size limit')
- with open(path_val, 'rb') as f:
- return await asyncio.to_thread(f.read)
+ def read_file() -> bytes:
+ with open(path_val, 'rb') as file:
+ return file.read(_MAX_OPENCLAW_COMPONENT_BYTES + 1)
+
+ data = await asyncio.to_thread(read_file)
+ if len(data) > _MAX_OPENCLAW_COMPONENT_BYTES:
+ raise ValueError('OpenClaw media exceeds the size limit')
+ return data
return None
def register_listener(
@@ -517,6 +564,8 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
"""Process a single inbound message from getUpdates."""
if msg.context_token and msg.from_user_id:
self._context_tokens[msg.from_user_id] = msg.context_token
+ while len(self._context_tokens) > 4096:
+ self._context_tokens.pop(next(iter(self._context_tokens)), None)
# Download CDN media (files, images) before converting to LangBot events
await self._download_media_items(msg)
@@ -572,6 +621,8 @@ class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAda
await self._poll_task
except asyncio.CancelledError:
pass
+ self._poll_task = None
+ self._context_tokens.clear()
await self.client.close()
await self.logger.info('OpenClaw WeChat adapter stopped')
return True
diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py
index a9fbb4000..598061f6c 100644
--- a/src/langbot/pkg/platform/sources/qqofficial.py
+++ b/src/langbot/pkg/platform/sources/qqofficial.py
@@ -241,6 +241,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# per (msg_id|event_id) within 60 min, but each reuse needs a
# fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd.
self._anchor_msg_seq: dict[str, int] = {}
+ self._background_tasks: set[asyncio.Task] = set()
# Wire button-click handler so webhook mode catches INTERACTION_CREATE.
# (ws mode is wired separately via on_event in _run_websocket so the
@@ -249,6 +250,30 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]):
await self._handle_interaction_create(event_data, interaction_id)
+ def _start_background_task(self, coro) -> bool:
+ """Start one bounded adapter-side auxiliary task."""
+
+ background_tasks = getattr(self, '_background_tasks', None)
+ if background_tasks is None:
+ background_tasks = set()
+ object.__setattr__(self, '_background_tasks', background_tasks)
+ for task in tuple(background_tasks):
+ if task.done():
+ background_tasks.discard(task)
+ if len(background_tasks) >= 100:
+ coro.close()
+ return False
+ task = asyncio.create_task(coro)
+ background_tasks.add(task)
+
+ def done(done_task: asyncio.Task) -> None:
+ background_tasks.discard(done_task)
+ if not done_task.cancelled():
+ done_task.exception()
+
+ task.add_done_callback(done)
+ return True
+
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -449,6 +474,14 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
pass
async def kill(self) -> bool:
+ task_set = getattr(self, '_background_tasks', set())
+ background_tasks = list(task_set)
+ for task in background_tasks:
+ if not task.done():
+ task.cancel()
+ if background_tasks:
+ await asyncio.gather(*background_tasks, return_exceptions=True)
+ task_set.clear()
if self._ws_task:
self._ws_task.cancel()
try:
@@ -456,6 +489,14 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
except asyncio.CancelledError:
pass
self._ws_task = None
+ await self.bot.close()
+ self._pending_forms.clear()
+ self._session_event_ids.clear()
+ self._anchor_msg_seq.clear()
+ self._stream_ctx.clear()
+ self._stream_ctx_ts.clear()
+ self._fallback_text.clear()
+ self._fallback_text_ts.clear()
return True
# --------------- 流式输出 ---------------
@@ -473,6 +514,14 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
for mid in stale_fb:
self._fallback_text.pop(mid, None)
self._fallback_text_ts.pop(mid, None)
+ while len(self._stream_ctx) > 1000:
+ oldest = min(self._stream_ctx_ts, key=self._stream_ctx_ts.__getitem__)
+ self._stream_ctx.pop(oldest, None)
+ self._stream_ctx_ts.pop(oldest, None)
+ while len(self._fallback_text) > 1000:
+ oldest = min(self._fallback_text_ts, key=self._fallback_text_ts.__getitem__)
+ self._fallback_text.pop(oldest, None)
+ self._fallback_text_ts.pop(oldest, None)
if stale_ids or stale_fb:
await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts')
@@ -508,6 +557,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# msg_seq=2 instead of being deduplicated by QQ as another seq=1 send.
if source.d_id:
self._anchor_msg_seq[source.d_id] = max(self._anchor_msg_seq.get(source.d_id, 0), 1)
+ while len(self._anchor_msg_seq) > 4096:
+ self._anchor_msg_seq.pop(next(iter(self._anchor_msg_seq)), None)
ctx = {
'user_openid': source.user_openid,
@@ -577,7 +628,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
if chunk_text:
# Chunks carry the latest full snapshot, not a text delta.
- self._fallback_text[message_id] = chunk_text
+ self._fallback_text[message_id] = chunk_text[:200000]
self._fallback_text_ts[message_id] = time.time()
if is_final:
full_text = self._fallback_text.pop(message_id, '')
@@ -590,7 +641,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 累积文本
if chunk_text:
- ctx['accumulated_text'] = chunk_text
+ ctx['accumulated_text'] = chunk_text[:200000]
# 未启动会话时,等第一个有内容的 chunk 来建立会话
if not ctx['session_started']:
@@ -668,6 +719,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
if used >= self._MAX_REPLIES_PER_ANCHOR:
return None
self._anchor_msg_seq[anchor] = used + 1
+ while len(self._anchor_msg_seq) > 4096:
+ self._anchor_msg_seq.pop(next(iter(self._anchor_msg_seq)), None)
return used + 1
async def _reply_synthetic(
@@ -791,7 +844,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
k for k, v in self._session_event_ids.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL
]
for k in stale_e:
- self._session_event_ids.pop(k, None)
+ stale_event = self._session_event_ids.pop(k, None)
+ if stale_event:
+ self._anchor_msg_seq.pop(stale_event.get('event_id'), None)
async def _handle_form_chunk(
self,
@@ -973,7 +1028,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# ACK uses the interaction id, NOT the ws event id.
interaction_id = event_data.get('id') or ''
if interaction_id:
- asyncio.create_task(self.bot.ack_interaction(interaction_id, code=0))
+ self._start_background_task(self.bot.ack_interaction(interaction_id, code=0))
resolved = (event_data.get('data') or {}).get('resolved') or {}
action_id = str(resolved.get('button_data') or resolved.get('button_id') or '').strip()
@@ -1018,6 +1073,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
}
# New anchor → fresh 5-reply budget.
self._anchor_msg_seq[cached_event_id] = 0
+ while len(self._anchor_msg_seq) > 4096:
+ self._anchor_msg_seq.pop(next(iter(self._anchor_msg_seq)), None)
if self.ap is not None and not ws_event_id:
self.ap.logger.warning(
'QQ Official: INTERACTION_CREATE lacked ws_event_id; '
diff --git a/src/langbot/pkg/platform/sources/satori.py b/src/langbot/pkg/platform/sources/satori.py
index 6bfa342ef..aff7bbf21 100644
--- a/src/langbot/pkg/platform/sources/satori.py
+++ b/src/langbot/pkg/platform/sources/satori.py
@@ -18,6 +18,9 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
+from langbot.pkg.utils import httpclient
+
+_MAX_GATEWAY_MESSAGE_BYTES = 1024 * 1024
class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@@ -63,7 +66,15 @@ class SatoriMessageConverter(abstract_platform_adapter.AbstractMessageConverter)
padding = 4 - len(raw_b64) % 4
if padding != 4:
raw_b64 += '=' * padding
- image_bytes = base64.b64decode(raw_b64)
+ max_encoded_chars = 4 * ((10 * 1024 * 1024 + 2) // 3) + 4
+ if len(raw_b64) > max_encoded_chars:
+ raise ValueError('Satori image exceeds the 10 MiB limit')
+ image_bytes = await asyncio.to_thread(
+ base64.b64decode,
+ raw_b64,
+ )
+ if len(image_bytes) > 10 * 1024 * 1024:
+ raise ValueError('Satori image exceeds the 10 MiB limit')
uploaded_url = await adapter.upload_image(image_bytes, mime_type)
if uploaded_url:
await adapter.logger.info(f'Satori 图片上传成功: {len(image_bytes)} 字节')
@@ -492,7 +503,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
raise ValueError(f'WebSocket URL必须以ws://或wss://开头: {self.endpoint}')
try:
- self.ws = await websockets.connect(self.endpoint)
+ self.ws = await websockets.connect(self.endpoint, max_size=_MAX_GATEWAY_MESSAGE_BYTES)
await asyncio.sleep(0.1)
await self.send_identify()
@@ -584,7 +595,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def handle_message(self, message: str):
"""Handle WebSocket message"""
try:
- data = json.loads(message)
+ data = await asyncio.to_thread(json.loads, message)
op = data.get('op')
body = data.get('body', {})
@@ -831,9 +842,10 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
async with self.session.request(method, url, headers=headers, json=data) as response:
if response.status == 200:
- return await response.json()
+ result = await httpclient.read_json_limited(response)
+ return result if isinstance(result, dict) else None
else:
- text = await response.text()
+ text = await httpclient.read_text_limited(response)
await self.logger.error(f'Satori API 请求失败: {response.status} - {text}')
return None
except Exception as e:
@@ -889,7 +901,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async with self.session.post(url, headers=headers, data=form_data) as response:
if response.status == 200:
- result = await response.json()
+ result = await httpclient.read_json_limited(response)
# The response should contain the URL of the uploaded file
if isinstance(result, dict) and 'url' in result:
return result['url']
@@ -899,7 +911,7 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.logger.warning(f'Satori 图片上传响应格式未知: {result}')
return None
else:
- text = await response.text()
+ text = await httpclient.read_text_limited(response)
await self.logger.error(f'Satori 图片上传失败: {response.status} - {text}')
return None
except Exception as e:
@@ -911,6 +923,8 @@ class SatoriAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.running = False
if self.heartbeat_task:
self.heartbeat_task.cancel()
+ await asyncio.gather(self.heartbeat_task, return_exceptions=True)
+ self.heartbeat_task = None
if self.ws:
try:
await self.ws.close()
diff --git a/src/langbot/pkg/platform/sources/telegram.py b/src/langbot/pkg/platform/sources/telegram.py
index 49157a9d1..7ff7109ca 100644
--- a/src/langbot/pkg/platform/sources/telegram.py
+++ b/src/langbot/pkg/platform/sources/telegram.py
@@ -10,6 +10,8 @@ import typing
import traceback
import json
import base64
+import asyncio
+import os
import time
import uuid
import pydantic
@@ -22,6 +24,31 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
+_MAX_TELEGRAM_MEDIA_BYTES = 10 * 1024 * 1024
+
+
+def _decode_telegram_base64_limited(value: str) -> bytes:
+ if ';base64,' in value:
+ value = value.split(';base64,', 1)[1]
+ max_encoded_bytes = 4 * ((_MAX_TELEGRAM_MEDIA_BYTES + 2) // 3)
+ if len(value) > max_encoded_bytes:
+ raise ValueError('Telegram media exceeds the size limit')
+ decoded = base64.b64decode(value)
+ if len(decoded) > _MAX_TELEGRAM_MEDIA_BYTES:
+ raise ValueError('Telegram media exceeds the size limit')
+ return decoded
+
+
+def _read_telegram_file_limited(path: str) -> bytes:
+ if os.path.getsize(path) > _MAX_TELEGRAM_MEDIA_BYTES:
+ raise ValueError('Telegram media exceeds the size limit')
+ with open(path, 'rb') as file:
+ body = file.read(_MAX_TELEGRAM_MEDIA_BYTES + 1)
+ if len(body) > _MAX_TELEGRAM_MEDIA_BYTES:
+ raise ValueError('Telegram media exceeds the size limit')
+ return body
+
+
def _telegram_select_field_options(form_data: dict) -> tuple[str, list[str]]:
"""Return the active select field and its option values."""
field_name = str(form_data.get('_current_input_field') or '').strip()
@@ -86,35 +113,29 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if isinstance(component, platform_message.Plain):
components.append({'type': 'text', 'text': component.text})
elif isinstance(component, platform_message.Image):
- photo_bytes = None
-
- if component.base64:
- photo_bytes = base64.b64decode(component.base64)
- elif component.url:
- session = httpclient.get_session()
- async with session.get(component.url) as response:
- photo_bytes = await response.read()
- elif component.path:
- with open(component.path, 'rb') as f:
- photo_bytes = f.read()
+ photo_bytes, _mime_type = await component.get_bytes()
components.append({'type': 'photo', 'photo': photo_bytes})
elif isinstance(component, platform_message.File):
file_bytes = None
if component.base64:
- # Strip data URI prefix if present (e.g. "data:application/pdf;base64,...")
- b64_data = component.base64
- if ';base64,' in b64_data:
- b64_data = b64_data.split(';base64,', 1)[1]
- file_bytes = base64.b64decode(b64_data)
+ file_bytes = await asyncio.to_thread(
+ _decode_telegram_base64_limited,
+ component.base64,
+ )
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
- file_bytes = await response.read()
+ file_bytes = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_TELEGRAM_MEDIA_BYTES,
+ )
elif component.path:
- with open(component.path, 'rb') as f:
- file_bytes = f.read()
+ file_bytes = await asyncio.to_thread(
+ _read_telegram_file_limited,
+ str(component.path),
+ )
file_name = getattr(component, 'name', None) or 'file'
components.append({'type': 'document', 'document': file_bytes, 'filename': file_name})
@@ -152,13 +173,17 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
file_format = ''
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
- file_bytes = await response.read()
+ file_bytes = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_TELEGRAM_MEDIA_BYTES,
+ )
file_format = 'image/jpeg'
+ encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.Image(
url=file.file_path,
- base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
+ base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
)
)
@@ -172,11 +197,15 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
file_format = message.voice.mime_type or 'audio/ogg'
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
- file_bytes = await response.read()
+ file_bytes = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_TELEGRAM_MEDIA_BYTES,
+ )
+ encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.Voice(
- base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
+ base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
length=message.voice.duration,
)
)
@@ -189,16 +218,22 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
file_name = message.document.file_name or 'document'
file_size = message.document.file_size or 0
file_format = message.document.mime_type or 'application/octet-stream'
+ if file_size > _MAX_TELEGRAM_MEDIA_BYTES:
+ raise ValueError('Telegram media exceeds the size limit')
file_bytes = None
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
- file_bytes = await response.read()
+ file_bytes = await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_TELEGRAM_MEDIA_BYTES,
+ )
+ encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.File(
name=file_name,
size=file_size,
- base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
+ base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
)
)
@@ -264,6 +299,8 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
] = {}
_FORM_ACTION_CACHE_TTL = 30 * 60
+ _MAX_FORM_ACTION_TITLES = 4096
+ _MAX_STREAM_STATES = 1000
# callback_data -> (display title, pipeline UUID, expiration time, form group id)
_form_action_titles: typing.Dict[str, tuple[str, str, float, str]] = {}
@@ -286,6 +323,8 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self._form_action_titles.update(
{callback_data: (title, pipeline_uuid, expires_at, group_id) for callback_data, title in mappings.items()}
)
+ while len(self._form_action_titles) > self._MAX_FORM_ACTION_TITLES:
+ self._form_action_titles.pop(next(iter(self._form_action_titles)), None)
def _take_form_action_context(self, callback_data: str, now: float | None = None) -> tuple[str, str] | None:
"""Consume a callback and invalidate every button from the same form."""
@@ -446,6 +485,11 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_account_id='',
listeners={},
)
+ self._form_action_titles = {}
+
+ def _cap_stream_states(self) -> None:
+ while len(self.msg_stream_id) > self._MAX_STREAM_STATES:
+ self.msg_stream_id.pop(next(iter(self.msg_stream_id)), None)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
components = await TelegramMessageConverter.yiri2target(message, self.bot)
@@ -554,6 +598,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
args = self._build_message_args(chat_id, 'Thinking...', message_thread_id)
send_msg = await self.bot.send_message(**args)
self.msg_stream_id[message_id] = ('message', send_msg.message_id, False)
+ self._cap_stream_states()
return True
@@ -845,4 +890,6 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if self.application.updater:
await self.application.updater.stop()
await self.logger.info('Telegram adapter stopped')
+ self.msg_stream_id.clear()
+ self._form_action_titles.clear()
return True
diff --git a/src/langbot/pkg/platform/sources/websocket_adapter.py b/src/langbot/pkg/platform/sources/websocket_adapter.py
index a66d4ac9a..6176e2d93 100644
--- a/src/langbot/pkg/platform/sources/websocket_adapter.py
+++ b/src/langbot/pkg/platform/sources/websocket_adapter.py
@@ -1,7 +1,9 @@
"""WebSocket适配器 - 支持双向通信的IM系统"""
import asyncio
+import contextvars
import logging
+import time
import typing
from datetime import datetime
@@ -13,9 +15,14 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from ...core import app
-from .websocket_manager import WebSocketConnection, is_valid_session_id, ws_connection_manager
+from ...core import entities as core_entities
+from .websocket_manager import WebSocketConnection, WebSocketScope, is_valid_session_id, ws_connection_manager
logger = logging.getLogger(__name__)
+_current_pipeline_uuid: contextvars.ContextVar[str | None] = contextvars.ContextVar(
+ 'websocket_pipeline_uuid',
+ default=None,
+)
class WebSocketMessage(pydantic.BaseModel):
@@ -40,21 +47,82 @@ class WebSocketSession:
stream_message_indexes: dict[str, dict[str, int]] = {}
"""流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}"""
- def __init__(self, id: str):
+ def __init__(
+ self,
+ id: str = '',
+ *,
+ max_conversations: int = 200,
+ max_messages: int = 100,
+ idle_ttl_seconds: int = 86400,
+ ):
self.id = id
self.message_lists = {}
self.stream_message_indexes = {}
+ self.message_counters: dict[str, int] = {}
+ self.last_accessed: dict[str, float] = {}
+ self.max_conversations = max(int(max_conversations), 1)
+ self.max_messages = max(int(max_messages), 1)
+ self.idle_ttl_seconds = max(int(idle_ttl_seconds), 1)
+
+ def _prune(self, now: float) -> None:
+ expired = [
+ key for key, last_accessed in self.last_accessed.items() if now - last_accessed >= self.idle_ttl_seconds
+ ]
+ for key in expired:
+ self.reset(key)
+
+ overflow = len(self.message_lists) - self.max_conversations + 1
+ if overflow <= 0:
+ return
+ oldest = sorted(self.last_accessed, key=self.last_accessed.get)
+ for key in oldest[:overflow]:
+ self.reset(key)
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
+ now = time.monotonic()
+ self._prune(now)
if pipeline_uuid not in self.message_lists:
self.message_lists[pipeline_uuid] = []
+ self.last_accessed[pipeline_uuid] = now
return self.message_lists[pipeline_uuid]
def get_stream_message_indexes(self, pipeline_uuid: str) -> dict[str, int]:
if pipeline_uuid not in self.stream_message_indexes:
self.stream_message_indexes[pipeline_uuid] = {}
+ self.last_accessed[pipeline_uuid] = time.monotonic()
return self.stream_message_indexes[pipeline_uuid]
+ def next_message_id(self, conversation_key: str) -> int:
+ next_id = self.message_counters.get(conversation_key, 0) + 1
+ self.message_counters[conversation_key] = next_id
+ return next_id
+
+ def append_message(self, conversation_key: str, message: WebSocketMessage) -> None:
+ messages = self.get_message_list(conversation_key)
+ messages.append(message)
+ overflow = len(messages) - self.max_messages
+ if overflow <= 0:
+ return
+ del messages[:overflow]
+ indexes = self.stream_message_indexes.get(conversation_key, {})
+ adjusted_indexes = {
+ response_id: index - overflow for response_id, index in indexes.items() if index >= overflow
+ }
+ indexes.clear()
+ indexes.update(adjusted_indexes)
+
+ def reset(self, conversation_key: str) -> None:
+ self.message_lists.pop(conversation_key, None)
+ self.stream_message_indexes.pop(conversation_key, None)
+ self.message_counters.pop(conversation_key, None)
+ self.last_accessed.pop(conversation_key, None)
+
+ def clear(self) -> None:
+ self.message_lists.clear()
+ self.stream_message_indexes.clear()
+ self.message_counters.clear()
+ self.last_accessed.clear()
+
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""WebSocket适配器 - 支持双向实时通信"""
@@ -70,7 +138,14 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
ap: app.Application = pydantic.Field(exclude=True)
# 主动推送消息的队列
- outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
+ outbound_message_queue: asyncio.Queue = pydantic.Field(
+ default_factory=lambda: asyncio.Queue(maxsize=100),
+ exclude=True,
+ )
+ inbound_listener_tasks: set[asyncio.Task] = pydantic.Field(
+ default_factory=set,
+ exclude=True,
+ )
"""后端主动推送消息的队列"""
# 流式输出开关
@@ -84,11 +159,26 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
**kwargs,
)
- self.websocket_person_session = WebSocketSession(id='websocketperson')
- self.websocket_group_session = WebSocketSession(id='websocketgroup')
+ application = kwargs.get('ap')
+ instance_data = getattr(getattr(application, 'instance_config', None), 'data', {})
+ retention = (
+ instance_data.get('system', {}).get('websocket_retention', {}) if isinstance(instance_data, dict) else {}
+ )
+ session_options = {
+ 'max_conversations': retention.get('max_conversations_per_workspace', 200),
+ 'max_messages': retention.get('max_messages_per_conversation', 100),
+ 'idle_ttl_seconds': retention.get('conversation_idle_ttl_seconds', 86400),
+ }
+ self.websocket_person_session = WebSocketSession(id='websocketperson', **session_options)
+ self.websocket_group_session = WebSocketSession(id='websocketgroup', **session_options)
self.bot_account_id = 'websocketbot'
- self.outbound_message_queue = asyncio.Queue()
+ try:
+ outbound_queue_size = max(int(retention.get('send_queue_size', 100)), 1)
+ except (TypeError, ValueError):
+ outbound_queue_size = 100
+ self.outbound_message_queue = asyncio.Queue(maxsize=outbound_queue_size)
+ self.inbound_listener_tasks = set()
self.stream_enabled = True
@staticmethod
@@ -113,9 +203,38 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
return None
return pipeline_uuid, session_id
- @classmethod
- async def _get_connection_from_target(cls, target_id: str):
+ def _scope(self) -> WebSocketScope:
+ """Return this adapter's immutable runtime placement."""
+
+ return WebSocketScope.from_context(self.logger.execution_context)
+
+ def get_pipeline_uuid_override(self) -> str | None:
+ """Return the connection pipeline propagated into the listener task."""
+
+ return _current_pipeline_uuid.get()
+
+ def _listener_task_done(self, task: asyncio.Task) -> None:
+ listener_tasks = getattr(self, 'inbound_listener_tasks', None)
+ if listener_tasks is not None:
+ listener_tasks.discard(task)
+ if not task.cancelled():
+ task.exception()
+
+ @staticmethod
+ def _history_message_chain(message_chain: list[dict]) -> list[dict]:
+ """Remove large transient payloads before retaining browser history."""
+
+ history = []
+ for component in message_chain:
+ copied = dict(component)
+ if copied.get('base64'):
+ copied['base64'] = ''
+ history.append(copied)
+ return history
+
+ async def _get_connection_from_target(self, target_id: str):
"""Resolve a person or group WebSocket launcher to its connection."""
+ scope = self._scope()
target_value = str(target_id)
for prefix in ('websocket_', 'websocketgroup_'):
if target_value.startswith(prefix):
@@ -123,14 +242,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
break
else:
return None
- connection = await ws_connection_manager.get_connection(target)
+ connection = await ws_connection_manager.get_connection(target, scope=scope)
if connection is not None:
return connection
- embed_target = cls._parse_embed_target(target_id)
+ embed_target = self._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
- return await ws_connection_manager.get_connection_by_session_id(session_id, pipeline_uuid)
- return await ws_connection_manager.get_connection_by_session_id(target)
+ return await ws_connection_manager.get_connection_by_session_id(
+ session_id,
+ scope=scope,
+ pipeline_uuid=pipeline_uuid,
+ )
+ return await ws_connection_manager.get_connection_by_session_id(target, scope=scope)
async def _get_message_context(self, message_source) -> tuple[str, str | None]:
"""Resolve the originating pipeline and browser session for a reply."""
@@ -142,7 +265,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
embed_target = self._parse_embed_target(sender_id)
if embed_target is not None:
return embed_target
- return typing.cast(str, self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid), None
+ raise ValueError('WebSocket reply target is not bound to this adapter scope')
async def send_message(
self,
@@ -160,22 +283,23 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if connection is not None:
pipeline_uuid = connection.pipeline_uuid
session_id = connection.session_id
+ scope = connection.scope
else:
embed_target = self._parse_embed_target(target_id)
if embed_target is not None:
pipeline_uuid, session_id = embed_target
else:
- pipeline_uuid = typing.cast(
- str,
- self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid,
- )
+ pipeline_uuid = str(target_id).strip()
+ if not pipeline_uuid:
+ raise ValueError('WebSocket target pipeline is required')
session_id = None
+ scope = self._scope()
session_type = 'group' if target_type == 'group' else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
- msg_id = len(session.get_message_list(conversation_key)) + 1
+ msg_id = session.next_message_id(conversation_key)
message_data = WebSocketMessage(
id=msg_id,
@@ -186,7 +310,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
is_final=True,
)
- session.get_message_list(conversation_key).append(message_data)
+ session.append_message(conversation_key, message_data)
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
@@ -195,6 +319,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
+ scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -216,10 +341,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
pipeline_uuid, session_id = await self._get_message_context(message_source)
+ scope = self._scope()
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
- msg_id = len(session.get_message_list(conversation_key)) + 1
+ msg_id = session.next_message_id(conversation_key)
message_data = WebSocketMessage(
id=msg_id,
@@ -230,7 +356,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
is_final=True,
)
- session.get_message_list(conversation_key).append(message_data)
+ session.append_message(conversation_key, message_data)
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
@@ -239,6 +365,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
+ scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -262,6 +389,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
pipeline_uuid, session_id = await self._get_message_context(message_source)
+ scope = self._scope()
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
conversation_key = self._conversation_key(pipeline_uuid, session_id)
message_list = session.get_message_list(conversation_key)
@@ -276,7 +404,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if existing_index is None or existing_index >= len(message_list):
# 创建新消息
- msg_id = len(message_list) + 1
+ msg_id = session.next_message_id(conversation_key)
message_data = WebSocketMessage(
id=msg_id,
role='assistant',
@@ -287,7 +415,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
)
# 立即添加到历史记录(即使is_final=False),以便后续块可以更新它
- message_list.append(message_data)
+ session.append_message(conversation_key, message_data)
+ message_list = session.get_message_list(conversation_key)
if resp_message_id:
stream_message_indexes[resp_message_id] = len(message_list) - 1
else:
@@ -316,6 +445,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': message_data.model_dump(),
},
+ scope=scope,
session_type=session_type,
session_id=session_id,
)
@@ -360,7 +490,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message = await asyncio.wait_for(self.outbound_message_queue.get(), timeout=0.1)
# 广播到所有相关连接
target_id = message.get('target_id', '')
- await ws_connection_manager.broadcast_to_pipeline(target_id, message)
+ await ws_connection_manager.broadcast_to_pipeline(
+ target_id,
+ message,
+ scope=self._scope(),
+ )
except asyncio.TimeoutError:
pass
@@ -370,9 +504,28 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def kill(self):
"""停止适配器"""
- pass
+ await ws_connection_manager.close_scope(self._scope())
+ listener_tasks = getattr(self, 'inbound_listener_tasks', set())
+ inbound_tasks = list(listener_tasks)
+ for task in inbound_tasks:
+ if not task.done():
+ task.cancel()
+ if inbound_tasks:
+ await asyncio.gather(*inbound_tasks, return_exceptions=True)
+ listener_tasks.clear()
+ self.websocket_person_session.clear()
+ self.websocket_group_session.clear()
+ while not self.outbound_message_queue.empty():
+ try:
+ self.outbound_message_queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
- async def _process_image_components(self, message_chain_obj: list):
+ async def _process_image_components(
+ self,
+ connection: WebSocketConnection,
+ message_chain_obj: list,
+ ):
"""
处理消息链中的图片、语音和文件组件,将 path 转换为 base64
@@ -387,18 +540,36 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
import base64
import mimetypes
- storage_mgr = self.ap.storage_mgr
+ attachments = [
+ component
+ for component in message_chain_obj
+ if component.get('path') and component.get('type') in ('Image', 'Voice', 'File')
+ ]
+ if not attachments:
+ return
- for component in message_chain_obj:
+ storage_mgr = self.ap.storage_mgr
+ execution_context = connection.execution_context
+ expected_prefix = storage_mgr.scoped_prefix(execution_context, owner_type='upload_image')
+
+ for component in attachments:
comp_type = component.get('type', '')
comp_path = component.get('path', '')
- if not comp_path or comp_type not in ('Image', 'Voice', 'File'):
- continue
+ if not comp_path.startswith(expected_prefix) or not storage_mgr.is_scoped_object_key(
+ comp_path,
+ expected_owner_type='upload_image',
+ ):
+ await self.logger.warning(f'Rejected {comp_type} attachment outside the WebSocket connection scope')
+ raise ValueError('Attachment key does not belong to this WebSocket connection')
try:
- file_content = await storage_mgr.storage_provider.load(comp_path)
- base64_str = base64.b64encode(file_content).decode('utf-8')
+ file_content = await storage_mgr.load_scoped_object_key(
+ execution_context,
+ comp_path,
+ expected_owner_type='upload_image',
+ )
+ base64_str = (await asyncio.to_thread(base64.b64encode, file_content)).decode('utf-8')
lowered = comp_path.lower()
if comp_type == 'Image':
@@ -416,10 +587,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
component['base64'] = f'data:{mime_type};base64,{base64_str}'
- await storage_mgr.storage_provider.delete(comp_path)
+ await storage_mgr.delete_scoped_object_key(
+ execution_context,
+ comp_path,
+ expected_owner_type='upload_image',
+ )
component['path'] = ''
except Exception as e:
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
+ raise
async def handle_websocket_message(
self,
@@ -451,23 +627,23 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message_chain_obj = message_data.get('message', [])
- await self._process_image_components(message_chain_obj)
+ await self._process_image_components(connection, message_chain_obj)
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
- message_id = len(use_session.get_message_list(conversation_key)) + 1
+ message_id = use_session.next_message_id(conversation_key)
# 保存用户消息
user_message = WebSocketMessage(
id=message_id,
role='user',
content=str(message_chain),
- message_chain=message_chain_obj,
+ message_chain=self._history_message_chain(message_chain_obj),
timestamp=datetime.now().isoformat(),
connection_id=connection.connection_id,
is_final=True, # 用户消息始终是完整的,非流式
)
- use_session.get_message_list(conversation_key).append(user_message)
+ use_session.append_message(conversation_key, user_message)
await ws_connection_manager.broadcast_to_pipeline(
pipeline_uuid,
@@ -476,6 +652,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
'session_type': session_type,
'data': user_message.model_dump(),
},
+ scope=connection.scope,
session_type=session_type,
session_id=connection.session_id,
)
@@ -506,11 +683,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
- # 设置流水线UUID (proxy bot always needs it for reply_message routing)
- self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
- if owner_bot is not None:
- owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
-
# 异步触发事件处理
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
listeners = (
@@ -525,7 +697,38 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
owner_bot.adapter.set_ws_adapter(self)
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
if event.__class__ in listeners:
- asyncio.create_task(listeners[event.__class__](event, callback_adapter))
+ listener_tasks = getattr(self, 'inbound_listener_tasks', None)
+ if listener_tasks is None:
+ listener_tasks = set()
+ object.__setattr__(self, 'inbound_listener_tasks', listener_tasks)
+ for task in tuple(listener_tasks):
+ if task.done():
+ listener_tasks.discard(task)
+ if len(listener_tasks) >= 100:
+ await self.logger.warning('WebSocket inbound listener capacity reached; dropping message')
+ return
+ token = _current_pipeline_uuid.set(pipeline_uuid)
+ try:
+ task_manager = getattr(self.ap, 'task_mgr', None)
+ if task_manager is None or not isinstance(getattr(task_manager, 'tasks', None), list):
+ listener_task = asyncio.create_task(listeners[event.__class__](event, callback_adapter))
+ else:
+ listener_task = task_manager.create_task(
+ listeners[event.__class__](event, callback_adapter),
+ kind='websocket-message',
+ name=f'websocket-message-{connection.connection_id}',
+ scopes=[
+ core_entities.LifecycleControlScope.APPLICATION,
+ core_entities.LifecycleControlScope.PLATFORM,
+ ],
+ instance_uuid=connection.instance_uuid,
+ workspace_uuid=connection.workspace_uuid,
+ placement_generation=connection.placement_generation,
+ ).task
+ listener_tasks.add(listener_task)
+ listener_task.add_done_callback(self._listener_task_done)
+ finally:
+ _current_pipeline_uuid.reset(token)
def get_websocket_messages(
self,
@@ -547,10 +750,14 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
"""Reset one pipeline/client conversation."""
conversation_key = self._conversation_key(pipeline_uuid, session_id)
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
- if conversation_key in session.message_lists:
- session.message_lists[conversation_key] = []
- if conversation_key in session.stream_message_indexes:
- session.stream_message_indexes[conversation_key] = {}
+ if isinstance(session, WebSocketSession):
+ session.reset(conversation_key)
+ else:
+ # Compatibility for lightweight adapter doubles.
+ if conversation_key in session.message_lists:
+ session.message_lists[conversation_key] = []
+ if conversation_key in session.stream_message_indexes:
+ session.stream_message_indexes[conversation_key] = {}
if session_id:
launcher_id = (
@@ -558,11 +765,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
if session_type == 'group'
else f'websocket_{pipeline_uuid}:{session_id}'
)
+ scope = self._scope()
self.ap.sess_mgr.session_list = [
candidate_session
for candidate_session in self.ap.sess_mgr.session_list
if not (
- str(
+ getattr(candidate_session, 'instance_uuid', None) == scope.instance_uuid
+ and getattr(candidate_session, 'workspace_uuid', None) == scope.workspace_uuid
+ and getattr(candidate_session, 'placement_generation', None) == scope.placement_generation
+ and str(
candidate_session.launcher_type.value
if hasattr(candidate_session.launcher_type, 'value')
else candidate_session.launcher_type
diff --git a/src/langbot/pkg/platform/sources/websocket_manager.py b/src/langbot/pkg/platform/sources/websocket_manager.py
index c0c4d1807..99ef98bba 100644
--- a/src/langbot/pkg/platform/sources/websocket_manager.py
+++ b/src/langbot/pkg/platform/sources/websocket_manager.py
@@ -1,6 +1,7 @@
"""WebSocket连接管理器 - 管理多个并发WebSocket连接"""
import asyncio
+import dataclasses
import logging
import typing
import uuid
@@ -8,8 +9,34 @@ from datetime import datetime
import pydantic
+from ...api.http.context import ExecutionContext
+
logger = logging.getLogger(__name__)
_SESSION_FILTER_UNSET = object()
+_DEFAULT_SEND_QUEUE_SIZE = 100
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class WebSocketScope:
+ """Trusted runtime placement carried by every WebSocket connection."""
+
+ instance_uuid: str
+ workspace_uuid: str
+ placement_generation: int
+
+ def __post_init__(self) -> None:
+ if not self.instance_uuid.strip() or not self.workspace_uuid.strip():
+ raise ValueError('WebSocket scope requires an instance and Workspace')
+ if self.placement_generation <= 0:
+ raise ValueError('WebSocket scope requires a positive placement generation')
+
+ @classmethod
+ def from_context(cls, context: typing.Any) -> 'WebSocketScope':
+ return cls(
+ instance_uuid=str(getattr(context, 'instance_uuid', '')),
+ workspace_uuid=str(getattr(context, 'workspace_uuid', '')),
+ placement_generation=int(getattr(context, 'placement_generation', 0)),
+ )
def is_valid_session_id(value: str) -> bool:
@@ -29,6 +56,15 @@ class WebSocketConnection(pydantic.BaseModel):
connection_id: str = pydantic.Field(default_factory=lambda: str(uuid.uuid4()))
"""连接唯一ID"""
+ instance_uuid: str
+ """Owning LangBot instance."""
+
+ workspace_uuid: str
+ """Owning Workspace."""
+
+ placement_generation: int
+ """Workspace placement generation captured at connect time."""
+
pipeline_uuid: str
"""关联的流水线UUID"""
@@ -47,7 +83,10 @@ class WebSocketConnection(pydantic.BaseModel):
last_active: datetime = pydantic.Field(default_factory=datetime.now)
"""最后活跃时间"""
- send_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
+ send_queue: asyncio.Queue = pydantic.Field(
+ default_factory=lambda: asyncio.Queue(maxsize=_DEFAULT_SEND_QUEUE_SIZE),
+ exclude=True,
+ )
"""发送消息队列"""
is_active: bool = True
@@ -56,6 +95,25 @@ class WebSocketConnection(pydantic.BaseModel):
metadata: dict = pydantic.Field(default_factory=dict)
"""连接元数据(可存储额外信息)"""
+ @property
+ def scope(self) -> WebSocketScope:
+ return WebSocketScope(
+ instance_uuid=self.instance_uuid,
+ workspace_uuid=self.workspace_uuid,
+ placement_generation=self.placement_generation,
+ )
+
+ @property
+ def execution_context(self) -> ExecutionContext:
+ """Return the storage/runtime context captured for this connection."""
+
+ return ExecutionContext(
+ instance_uuid=self.instance_uuid,
+ workspace_uuid=self.workspace_uuid,
+ placement_generation=self.placement_generation,
+ pipeline_uuid=self.pipeline_uuid,
+ )
+
class WebSocketConnectionManager:
"""WebSocket连接管理器 - 支持多连接并发"""
@@ -64,11 +122,11 @@ class WebSocketConnectionManager:
self.connections: dict[str, WebSocketConnection] = {}
"""所有活跃连接 {connection_id: connection}"""
- self.pipeline_connections: dict[str, set[str]] = {}
- """流水线到连接的映射 {pipeline_uuid: {connection_id, ...}}"""
+ self.pipeline_connections: dict[tuple[str, str, int, str], set[str]] = {}
+ """Scoped pipeline to connection mapping."""
- self.session_connections: dict[str, set[str]] = {}
- """会话类型到连接的映射 {session_type: {connection_id, ...}}"""
+ self.session_connections: dict[tuple[str, str, int, str], set[str]] = {}
+ """Scoped session-type to connection mapping."""
self._lock = asyncio.Lock()
"""线程锁,保护并发访问"""
@@ -76,40 +134,96 @@ class WebSocketConnectionManager:
async def add_connection(
self,
websocket: typing.Any,
+ scope: WebSocketScope,
pipeline_uuid: str,
session_type: str,
metadata: dict | None = None,
session_id: str | None = None,
+ send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE,
+ max_connections: int = 1024,
+ max_connections_per_workspace: int = 32,
) -> WebSocketConnection:
"""Register a WebSocket connection and its optional embed session."""
+ try:
+ send_queue_size = max(int(send_queue_size), 1)
+ except (TypeError, ValueError):
+ send_queue_size = _DEFAULT_SEND_QUEUE_SIZE
+ max_connections = max(int(max_connections), 1)
+ max_connections_per_workspace = max(
+ min(int(max_connections_per_workspace), max_connections),
+ 1,
+ )
async with self._lock:
+ if len(self.connections) >= max_connections:
+ raise RuntimeError(f'WebSocket connection capacity reached ({max_connections})')
+ workspace_connection_count = sum(
+ 1
+ for connection in self.connections.values()
+ if connection.instance_uuid == scope.instance_uuid
+ and connection.workspace_uuid == scope.workspace_uuid
+ and connection.placement_generation == scope.placement_generation
+ )
+ if workspace_connection_count >= max_connections_per_workspace:
+ raise RuntimeError(f'Workspace WebSocket connection capacity reached ({max_connections_per_workspace})')
connection = WebSocketConnection(
+ instance_uuid=scope.instance_uuid,
+ workspace_uuid=scope.workspace_uuid,
+ placement_generation=scope.placement_generation,
pipeline_uuid=pipeline_uuid,
session_type=session_type,
session_id=session_id,
websocket=websocket,
metadata=metadata or {},
+ send_queue=asyncio.Queue(maxsize=send_queue_size),
)
self.connections[connection.connection_id] = connection
# 更新流水线映射
- if pipeline_uuid not in self.pipeline_connections:
- self.pipeline_connections[pipeline_uuid] = set()
- self.pipeline_connections[pipeline_uuid].add(connection.connection_id)
+ pipeline_key = self._pipeline_key(scope, pipeline_uuid)
+ if pipeline_key not in self.pipeline_connections:
+ self.pipeline_connections[pipeline_key] = set()
+ self.pipeline_connections[pipeline_key].add(connection.connection_id)
# 更新会话类型映射
- if session_type not in self.session_connections:
- self.session_connections[session_type] = set()
- self.session_connections[session_type].add(connection.connection_id)
+ session_key = self._session_key(scope, session_type)
+ if session_key not in self.session_connections:
+ self.session_connections[session_key] = set()
+ self.session_connections[session_key].add(connection.connection_id)
logger.debug(
f'WebSocket connection established: {connection.connection_id} '
- f'(pipeline={pipeline_uuid}, session_type={session_type})'
+ f'(workspace={scope.workspace_uuid}, generation={scope.placement_generation}, '
+ f'pipeline={pipeline_uuid}, session_type={session_type})'
)
return connection
+ async def close_scope(self, scope: WebSocketScope) -> None:
+ """Close and forget every live connection for one runtime placement."""
+
+ async with self._lock:
+ connection_ids = [
+ connection_id for connection_id, connection in self.connections.items() if connection.scope == scope
+ ]
+ for connection_id in connection_ids:
+ connection = self.connections.get(connection_id)
+ if connection is None:
+ continue
+ close = getattr(connection.websocket, 'close', None)
+ if close is not None:
+ try:
+ result = close()
+ if asyncio.iscoroutine(result):
+ await result
+ except Exception:
+ logger.debug(
+ 'Failed to close WebSocket connection %s',
+ connection_id,
+ exc_info=True,
+ )
+ await self.remove_connection(connection_id)
+
async def remove_connection(self, connection_id: str):
"""移除WebSocket连接"""
async with self._lock:
@@ -120,54 +234,103 @@ class WebSocketConnectionManager:
connection.is_active = False
# 从流水线映射中移除
- if connection.pipeline_uuid in self.pipeline_connections:
- self.pipeline_connections[connection.pipeline_uuid].discard(connection_id)
- if not self.pipeline_connections[connection.pipeline_uuid]:
- del self.pipeline_connections[connection.pipeline_uuid]
+ pipeline_key = self._pipeline_key(connection.scope, connection.pipeline_uuid)
+ if pipeline_key in self.pipeline_connections:
+ self.pipeline_connections[pipeline_key].discard(connection_id)
+ if not self.pipeline_connections[pipeline_key]:
+ del self.pipeline_connections[pipeline_key]
# 从会话类型映射中移除
- if connection.session_type in self.session_connections:
- self.session_connections[connection.session_type].discard(connection_id)
- if not self.session_connections[connection.session_type]:
- del self.session_connections[connection.session_type]
+ session_key = self._session_key(connection.scope, connection.session_type)
+ if session_key in self.session_connections:
+ self.session_connections[session_key].discard(connection_id)
+ if not self.session_connections[session_key]:
+ del self.session_connections[session_key]
del self.connections[connection_id]
logger.debug(f'WebSocket connection disconnected: {connection_id}')
- async def get_connection(self, connection_id: str) -> WebSocketConnection | None:
- """Get a connection by its transport identifier."""
- return self.connections.get(connection_id)
+ @staticmethod
+ def _pipeline_key(scope: WebSocketScope, pipeline_uuid: str) -> tuple[str, str, int, str]:
+ return (
+ scope.instance_uuid,
+ scope.workspace_uuid,
+ scope.placement_generation,
+ pipeline_uuid,
+ )
+
+ @staticmethod
+ def _session_key(scope: WebSocketScope, session_type: str) -> tuple[str, str, int, str]:
+ return (
+ scope.instance_uuid,
+ scope.workspace_uuid,
+ scope.placement_generation,
+ session_type,
+ )
+
+ async def get_connection(
+ self,
+ connection_id: str,
+ *,
+ scope: WebSocketScope,
+ ) -> WebSocketConnection | None:
+ """Get a connection only when it belongs to the expected placement."""
+
+ connection = self.connections.get(connection_id)
+ if connection is None or connection.scope != scope:
+ return None
+ return connection
async def get_connection_by_session_id(
self,
session_id: str,
+ *,
+ scope: WebSocketScope,
pipeline_uuid: str | None = None,
) -> WebSocketConnection | None:
"""Get an active embed connection by its stable browser session identifier."""
- for connection in self.connections.values():
+ candidates: typing.Iterable[WebSocketConnection]
+ if pipeline_uuid is not None:
+ candidates = await self.get_connections_by_pipeline(pipeline_uuid, scope=scope)
+ else:
+ candidates = self.connections.values()
+ for connection in candidates:
if (
connection.session_id == session_id
and connection.is_active
+ and connection.scope == scope
and (pipeline_uuid is None or connection.pipeline_uuid == pipeline_uuid)
):
return connection
return None
- async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
+ async def get_connections_by_pipeline(
+ self,
+ pipeline_uuid: str,
+ *,
+ scope: WebSocketScope,
+ ) -> list[WebSocketConnection]:
"""获取指定流水线的所有连接"""
- connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
+ connection_ids = self.pipeline_connections.get(self._pipeline_key(scope, pipeline_uuid), set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
- async def get_connections_by_session_type(self, session_type: str) -> list[WebSocketConnection]:
+ async def get_connections_by_session_type(
+ self,
+ session_type: str,
+ *,
+ scope: WebSocketScope,
+ ) -> list[WebSocketConnection]:
"""获取指定会话类型的所有连接"""
- connection_ids = self.session_connections.get(session_type, set())
+ connection_ids = self.session_connections.get(self._session_key(scope, session_type), set())
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
async def broadcast_to_pipeline(
self,
pipeline_uuid: str,
message: dict,
+ *,
+ scope: WebSocketScope,
session_type: str | None = None,
session_id: typing.Any = _SESSION_FILTER_UNSET,
):
@@ -180,7 +343,7 @@ class WebSocketConnectionManager:
session_id: Embed conversation filter. Omit it to broadcast across
conversations; pass ``None`` to target non-embed connections.
"""
- connections = await self.get_connections_by_pipeline(pipeline_uuid)
+ connections = await self.get_connections_by_pipeline(pipeline_uuid, scope=scope)
if session_type is not None:
connections = [conn for conn in connections if conn.session_type == session_type]
@@ -196,13 +359,26 @@ class WebSocketConnectionManager:
async def send_to_connection(self, connection_id: str, message: dict):
"""向指定连接发送消息"""
- connection = await self.get_connection(connection_id)
+ connection = self.connections.get(connection_id)
if not connection or not connection.is_active:
logger.warning(f'Attempt to send message to invalid connection: {connection_id}')
return
try:
- await connection.send_queue.put(message)
+ try:
+ connection.send_queue.put_nowait(message)
+ except asyncio.QueueFull:
+ # A slow or disconnected browser must not backpressure every
+ # other connection or retain an unbounded response stream.
+ try:
+ connection.send_queue.get_nowait()
+ except asyncio.QueueEmpty:
+ pass
+ connection.send_queue.put_nowait(message)
+ logger.warning(
+ 'WebSocket send queue full; dropped oldest message for connection %s',
+ connection_id,
+ )
connection.last_active = datetime.now()
except Exception as e:
logger.error(f'Failed to send message to connection {connection_id}: {e}')
@@ -210,17 +386,24 @@ class WebSocketConnectionManager:
async def update_activity(self, connection_id: str):
"""更新连接活跃时间"""
- connection = await self.get_connection(connection_id)
+ connection = self.connections.get(connection_id)
if connection:
connection.last_active = datetime.now()
- def get_stats(self) -> dict:
- """获取连接统计信息"""
+ def get_stats(self, *, scope: WebSocketScope) -> dict:
+ """Return connection statistics for one trusted placement."""
+
+ scoped_connections = [connection for connection in self.connections.values() if connection.scope == scope]
+ pipelines: dict[str, int] = {}
+ session_types: dict[str, int] = {}
+ for connection in scoped_connections:
+ pipelines[connection.pipeline_uuid] = pipelines.get(connection.pipeline_uuid, 0) + 1
+ session_types[connection.session_type] = session_types.get(connection.session_type, 0) + 1
return {
- 'total_connections': len(self.connections),
- 'pipelines': len(self.pipeline_connections),
- 'connections_by_pipeline': {k: len(v) for k, v in self.pipeline_connections.items()},
- 'connections_by_session_type': {k: len(v) for k, v in self.session_connections.items()},
+ 'total_connections': len(scoped_connections),
+ 'pipelines': len(pipelines),
+ 'connections_by_pipeline': pipelines,
+ 'connections_by_session_type': session_types,
}
diff --git a/src/langbot/pkg/platform/sources/wechatpad.py b/src/langbot/pkg/platform/sources/wechatpad.py
index 1c9c5ee27..9b3e40d7d 100644
--- a/src/langbot/pkg/platform/sources/wechatpad.py
+++ b/src/langbot/pkg/platform/sources/wechatpad.py
@@ -1,8 +1,6 @@
import requests
import websocket
import json
-import time
-import httpx
from langbot.libs.wechatpad_api.client import WeChatPadClient
@@ -17,6 +15,7 @@ import threading
import quart
from langbot.pkg.platform.logger import EventLogger
+from langbot.pkg.utils import bounded_executor, httpclient
import xml.etree.ElementTree as ET
from typing import Optional, Tuple
from functools import partial
@@ -27,6 +26,8 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
+_MAX_GATEWAY_MESSAGE_CHARS = 1024 * 1024
+
class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
@@ -53,12 +54,11 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
content_list.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.Image):
if component.url:
- async with httpx.AsyncClient() as client:
- response = await client.get(component.url)
-
- if response.status_code == 200:
- file_bytes = response.content
- base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
+ session = httpclient.get_session()
+ async with session.get(component.url) as response:
+ if response.status == 200:
+ file_bytes = await httpclient.read_limited(response)
+ base64_str = (await asyncio.to_thread(base64.b64encode, file_bytes)).decode('utf-8')
else:
raise Exception('获取文件失败')
# pass
@@ -156,9 +156,19 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
cdnthumburl = img_tag.get('cdnthumburl')
# cdnmidimgurl = img_tag.get('cdnmidimgurl')
- image_data = self.bot.cdn_download(aeskey=aeskey, file_type=1, file_url=cdnthumburl)
+ image_data = await asyncio.to_thread(
+ self.bot.cdn_download,
+ aeskey=aeskey,
+ file_type=1,
+ file_url=cdnthumburl,
+ )
if image_data['Data']['FileData'] == '':
- image_data = self.bot.cdn_download(aeskey=aeskey, file_type=2, file_url=cdnthumburl)
+ image_data = await asyncio.to_thread(
+ self.bot.cdn_download,
+ aeskey=aeskey,
+ file_type=2,
+ file_url=cdnthumburl,
+ )
base64_str = image_data['Data']['FileData']
# self.logger.info(f"data:image/png;base64,{base64_str}")
@@ -186,7 +196,12 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
if voicemsg is not None:
bufid = voicemsg.get('bufid')
length = voicemsg.get('voicelength')
- voice_data = self.bot.get_msg_voice(buf_id=str(bufid), length=int(length), msgid=str(new_msg_id))
+ voice_data = await asyncio.to_thread(
+ self.bot.get_msg_voice,
+ buf_id=str(bufid),
+ length=int(length),
+ msgid=str(new_msg_id),
+ )
audio_base64 = voice_data['Data']['Base64']
# 验证语音数据有效性
@@ -319,7 +334,12 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
# print(aeskey,cdnthumburl)
- file_data = self.bot.cdn_download(aeskey=aeskey, file_type=5, file_url=cdnthumburl)
+ file_data = await asyncio.to_thread(
+ self.bot.cdn_download,
+ aeskey=aeskey,
+ file_type=5,
+ file_url=cdnthumburl,
+ )
file_base64 = file_data['Data']['FileData']
# print(file_data)
@@ -538,6 +558,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
+ _MAX_CALLBACK_FUTURES = 100
def __init__(self, config: dict, logger: EventLogger):
quart_app = quart.Quart(__name__)
@@ -556,6 +577,12 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
name='WeChatPad',
bot=bot,
)
+ self._event_loop: asyncio.AbstractEventLoop | None = None
+ self._ws_app: websocket.WebSocketApp | None = None
+ self._ws_thread: threading.Thread | None = None
+ self._stop_event = threading.Event()
+ self._callback_futures: set = set()
+ self._callback_futures_lock = threading.Lock()
async def ws_message(self, data):
"""处理接收到的消息"""
@@ -565,7 +592,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
except Exception:
await self.logger.error(f'Error in wechatpad callback: {traceback.format_exc()}')
- if event.__class__ in self.listeners:
+ if event is not None and event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return 'ok'
@@ -580,9 +607,8 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
at_targets = at_targets or []
member_info = []
if at_targets:
- member_info = self.bot.get_chatroom_member_detail(
- target_id,
- )['Data']['member_data']['chatroom_member_list']
+ member_result = await asyncio.to_thread(self.bot.get_chatroom_member_detail, target_id)
+ member_info = member_result['Data']['member_data']['chatroom_member_list']
# 处理消息组件
for msg in content_list:
@@ -623,11 +649,35 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
}
if handler := handler_map.get(msg['type']):
- handler(msg)
+ await asyncio.to_thread(handler, msg)
else:
- self.logger.warning(f'未处理的消息类型: {msg["type"]}')
+ await self.logger.warning(f'未处理的消息类型: {msg["type"]}')
continue
+ def _schedule_ws_message(self, data: dict) -> None:
+ loop = self._event_loop
+ if loop is None or loop.is_closed() or self._stop_event.is_set():
+ return
+ with self._callback_futures_lock:
+ if len(self._callback_futures) >= self._MAX_CALLBACK_FUTURES:
+ return
+ future = asyncio.run_coroutine_threadsafe(self.ws_message(data), loop)
+ self._callback_futures.add(future)
+
+ def done(completed) -> None:
+ with self._callback_futures_lock:
+ self._callback_futures.discard(completed)
+ if completed.cancelled():
+ return
+ try:
+ completed.result()
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ logging.getLogger(__name__).exception('WeChatPad callback failed')
+
+ future.add_done_callback(done)
+
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
"""主动发送消息"""
return await self._handle_message(message, target_id)
@@ -665,86 +715,113 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
pass
async def run_async(self):
+ self._event_loop = asyncio.get_running_loop()
+ self._stop_event.clear()
if not self.config['admin_key'] and not self.config['token']:
raise RuntimeError('无wechatpad管理密匙,请填入配置文件后重启')
else:
if self.config['token']:
self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'])
- data = self.bot.get_login_status()
+ data = await asyncio.to_thread(self.bot.get_login_status)
if data['Code'] == 300 and data['Text'] == '你已退出微信':
- response = requests.post(
+ response = await asyncio.to_thread(
+ requests.post,
f'{self.config["wechatpad_url"]}/admin/GenAuthKey1?key={self.config["admin_key"]}',
json={'Count': 1, 'Days': 365},
+ timeout=10,
)
if response.status_code != 200:
- raise Exception(f'获取token失败: {response.text}')
- self.config['token'] = response.json()['Data'][0]
+ body = await httpclient.response_text(response)
+ raise Exception(f'获取token失败: {body}')
+ response_data = await httpclient.parse_json_response(response)
+ self.config['token'] = response_data['Data'][0]
elif not self.config['token']:
- response = requests.post(
+ response = await asyncio.to_thread(
+ requests.post,
f'{self.config["wechatpad_url"]}/admin/GenAuthKey1?key={self.config["admin_key"]}',
json={'Count': 1, 'Days': 365},
+ timeout=10,
)
if response.status_code != 200:
- raise Exception(f'获取token失败: {response.text}')
- self.config['token'] = response.json()['Data'][0]
+ body = await httpclient.response_text(response)
+ raise Exception(f'获取token失败: {body}')
+ response_data = await httpclient.parse_json_response(response)
+ self.config['token'] = response_data['Data'][0]
self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'], logger=self.logger)
- await self.logger.info(self.config['token'])
- thread_1 = threading.Event()
-
- def wechat_login_process():
- # 不登录,这些先注释掉,避免登陆态尝试拉qrcode。
- # login_data =self.bot.get_login_qr()
-
- # url = login_data['Data']["QrCodeUrl"]
-
- profile = self.bot.get_profile()
- # self.logger.info(profile)
-
- self.bot_account_id = profile['Data']['userInfo']['nickName']['str']
- self.config['wxid'] = profile['Data']['userInfo']['userName']['str']
- thread_1.set()
-
- # asyncio.create_task(wechat_login_process)
- threading.Thread(target=wechat_login_process).start()
+ profile = await asyncio.to_thread(self.bot.get_profile)
+ self.bot_account_id = profile['Data']['userInfo']['nickName']['str']
+ self.config['wxid'] = profile['Data']['userInfo']['userName']['str']
def connect_websocket_sync() -> None:
- thread_1.wait()
uri = f'{self.config["wechatpad_ws"]}/GetSyncMsg?key={self.config["token"]}'
- print(f'Connecting to WebSocket: {uri}')
def on_message(ws, message):
try:
+ if len(message) > _MAX_GATEWAY_MESSAGE_CHARS:
+ logging.getLogger(__name__).warning('WeChatPad WebSocket message exceeds the size limit')
+ return
data = json.loads(message)
- # 这里需要确保ws_message是同步的,或者使用asyncio.run调用异步方法
- asyncio.run(self.ws_message(data))
+ self._schedule_ws_message(data)
except json.JSONDecodeError:
- self.logger.error(f'Non-JSON message: {message[:100]}...')
+ logging.getLogger(__name__).warning('WeChatPad received a non-JSON message')
def on_error(ws, error):
- self.logger.error(f'WebSocket error: {str(error)[:200]}')
+ logging.getLogger(__name__).warning('WeChatPad WebSocket error: %s', str(error)[:200])
def on_close(ws, close_status_code, close_msg):
- self.logger.info('WebSocket closed, reconnecting...')
- time.sleep(5)
- connect_websocket_sync() # 自动重连
+ logging.getLogger(__name__).info('WeChatPad WebSocket closed')
def on_open(ws):
- self.logger.info('WebSocket connected successfully!')
+ logging.getLogger(__name__).info('WeChatPad WebSocket connected')
- ws = websocket.WebSocketApp(
- uri, on_message=on_message, on_error=on_error, on_close=on_close, on_open=on_open
- )
- ws.run_forever(ping_interval=60, ping_timeout=20)
+ while not self._stop_event.is_set():
+ ws = websocket.WebSocketApp(
+ uri,
+ on_message=on_message,
+ on_error=on_error,
+ on_close=on_close,
+ on_open=on_open,
+ )
+ self._ws_app = ws
+ ws.run_forever(ping_interval=60, ping_timeout=20)
+ self._ws_app = None
+ if not self._stop_event.wait(5):
+ logging.getLogger(__name__).info('Reconnecting WeChatPad WebSocket')
- # 直接调用同步版本(会阻塞)
- # connect_websocket_sync()
-
- # 这行代码会在WebSocket连接断开后才会执行
- thread = threading.Thread(target=connect_websocket_sync, name='WebSocketClientThread', daemon=True)
- thread.start()
- self.logger.info('WebSocket client thread started')
+ self._ws_thread = threading.Thread(
+ target=connect_websocket_sync,
+ name='WebSocketClientThread',
+ daemon=True,
+ )
+ self._ws_thread.start()
+ await self.logger.info('WebSocket client thread started')
+ while not self._stop_event.is_set() and self._ws_thread.is_alive():
+ await asyncio.sleep(1)
+ if not self._stop_event.is_set():
+ raise RuntimeError('WeChatPad WebSocket client thread exited unexpectedly')
async def kill(self) -> bool:
- pass
+ self._stop_event.set()
+ ws = self._ws_app
+ if ws is not None:
+ await bounded_executor.run_blocking_cleanup(ws.close)
+
+ with self._callback_futures_lock:
+ futures = list(self._callback_futures)
+ for future in futures:
+ future.cancel()
+ if futures:
+ await asyncio.gather(
+ *(asyncio.wrap_future(future) for future in futures),
+ return_exceptions=True,
+ )
+
+ thread = self._ws_thread
+ if thread is not None and thread.is_alive():
+ await bounded_executor.run_blocking_cleanup(thread.join, 5)
+ self._ws_thread = None
+ self._ws_app = None
+ self._event_loop = None
+ return True
diff --git a/src/langbot/pkg/platform/sources/wecom.py b/src/langbot/pkg/platform/sources/wecom.py
index 5df4d2d49..93aaf1f92 100644
--- a/src/langbot/pkg/platform/sources/wecom.py
+++ b/src/langbot/pkg/platform/sources/wecom.py
@@ -321,6 +321,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await keep_alive()
async def kill(self) -> bool:
+ await self.bot.close()
return False
async def unregister_listener(
diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py
index d0febad9e..0990773ad 100644
--- a/src/langbot/pkg/platform/sources/wecombot.py
+++ b/src/langbot/pkg/platform/sources/wecombot.py
@@ -516,6 +516,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# do work. Lazy-create on first call.
object.__setattr__(self, '_synthetic_buffers', {})
buffers: dict[str, str] = self._synthetic_buffers
+ if buf_key not in buffers and len(buffers) >= 100:
+ buffers.pop(next(iter(buffers)), None)
if content and not form_data:
previous = buffers.get(buf_key, '')
if previous and content.startswith(previous):
@@ -524,6 +526,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
buffers[buf_key] = previous
else:
buffers[buf_key] = previous + content
+ if len(buffers[buf_key]) > 200000:
+ buffers[buf_key] = buffers[buf_key][-200000:]
if not is_final:
return {'stream': True, 'synthetic': True, 'buffered': True}
@@ -613,7 +617,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'chat_id': chat_id,
'stream_id': '',
'req_id': '',
+ 'created_at': time.monotonic(),
}
+ prune = getattr(self.bot, '_prune_pending_forms', None)
+ if callable(prune):
+ prune()
return payload
async def send_message(self, target_type, target_id, message):
@@ -745,10 +753,13 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await keep_alive()
async def kill(self) -> bool:
+ if hasattr(self, '_synthetic_buffers'):
+ self._synthetic_buffers.clear()
_ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode:
await self.bot.disconnect()
return True
+ await self.bot.close()
return False
async def unregister_listener(
diff --git a/src/langbot/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py
index ea7d8ef5c..830255a42 100644
--- a/src/langbot/pkg/platform/sources/wecomcs.py
+++ b/src/langbot/pkg/platform/sources/wecomcs.py
@@ -254,6 +254,8 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await keep_alive()
async def kill(self) -> bool:
+ self.bot.clear()
+ await self.bot.close()
return False
async def is_muted(self, group_id: int) -> bool:
diff --git a/src/langbot/pkg/platform/webhook_pusher.py b/src/langbot/pkg/platform/webhook_pusher.py
index f3cf39b27..83f5cee11 100644
--- a/src/langbot/pkg/platform/webhook_pusher.py
+++ b/src/langbot/pkg/platform/webhook_pusher.py
@@ -5,6 +5,7 @@ import logging
import aiohttp
from langbot.pkg.utils import httpclient
+from langbot.pkg.api.http.context import ExecutionContext
import uuid
from typing import TYPE_CHECKING
@@ -14,6 +15,10 @@ if TYPE_CHECKING:
import langbot_plugin.api.entities.builtin.platform.events as platform_events
+_DEFAULT_MAX_INFLIGHT_WEBHOOK_REQUESTS = 16
+_HARD_MAX_INFLIGHT_WEBHOOK_REQUESTS = 128
+
+
class WebhookPusher:
"""Push bot events to configured webhooks"""
@@ -23,15 +28,71 @@ class WebhookPusher:
def __init__(self, ap: app.Application):
self.ap = ap
self.logger = self.ap.logger
+ self._delivery_lock = asyncio.Lock()
+ self._inflight_requests = 0
- async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> bool:
+ def _max_inflight_requests(self) -> int:
+ config = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
+ try:
+ value = int(
+ config.get('webhooks', {}).get(
+ 'max_inflight_requests',
+ _DEFAULT_MAX_INFLIGHT_WEBHOOK_REQUESTS,
+ )
+ )
+ except (AttributeError, TypeError, ValueError):
+ value = _DEFAULT_MAX_INFLIGHT_WEBHOOK_REQUESTS
+ return min(max(value, 1), _HARD_MAX_INFLIGHT_WEBHOOK_REQUESTS)
+
+ async def _reserve_delivery_slots(self, requested: int) -> int:
+ async with self._delivery_lock:
+ available = max(self._max_inflight_requests() - self._inflight_requests, 0)
+ admitted = min(max(requested, 0), available)
+ self._inflight_requests += admitted
+ return admitted
+
+ async def _release_delivery_slots(self, released: int) -> None:
+ async with self._delivery_lock:
+ self._inflight_requests = max(self._inflight_requests - released, 0)
+
+ async def _push_to_webhooks(self, webhooks: list[dict], payload: dict) -> list[object]:
+ """Dispatch only requests admitted by the instance-wide hard bound."""
+
+ admitted = await self._reserve_delivery_slots(len(webhooks))
+ if admitted < len(webhooks):
+ self.logger.warning(
+ 'Webhook delivery capacity reached; skipped %d of %d destinations',
+ len(webhooks) - admitted,
+ len(webhooks),
+ )
+ if admitted == 0:
+ return []
+
+ tasks = [asyncio.create_task(self._push_to_webhook(webhook['url'], payload)) for webhook in webhooks[:admitted]]
+ try:
+ return await asyncio.gather(*tasks, return_exceptions=True)
+ except asyncio.CancelledError:
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ raise
+ finally:
+ await self._release_delivery_slots(admitted)
+
+ async def push_person_message(
+ self,
+ execution_context: ExecutionContext,
+ event: platform_events.FriendMessage,
+ bot_uuid: str,
+ adapter_name: str,
+ ) -> bool:
"""Push person message event to webhooks
Returns:
bool: True if any webhook responded with skip_pipeline=true, False otherwise
"""
try:
- webhooks = await self.ap.webhook_service.get_enabled_webhooks()
+ webhooks = await self.ap.webhook_service.get_enabled_webhooks(execution_context)
if not webhooks:
return False
@@ -51,9 +112,7 @@ class WebhookPusher:
},
}
- # Push to all webhooks asynchronously
- tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]
- results = await asyncio.gather(*tasks, return_exceptions=True)
+ results = await self._push_to_webhooks(webhooks, payload)
# Check if any webhook responded with skip_pipeline=true
for result in results:
@@ -67,14 +126,20 @@ class WebhookPusher:
self.logger.error(f'Failed to push person message to webhooks: {e}')
return False
- async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> bool:
+ async def push_group_message(
+ self,
+ execution_context: ExecutionContext,
+ event: platform_events.GroupMessage,
+ bot_uuid: str,
+ adapter_name: str,
+ ) -> bool:
"""Push group message event to webhooks
Returns:
bool: True if any webhook responded with skip_pipeline=true, False otherwise
"""
try:
- webhooks = await self.ap.webhook_service.get_enabled_webhooks()
+ webhooks = await self.ap.webhook_service.get_enabled_webhooks(execution_context)
if not webhooks:
return False
@@ -98,9 +163,7 @@ class WebhookPusher:
},
}
- # Push to all webhooks asynchronously
- tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]
- results = await asyncio.gather(*tasks, return_exceptions=True)
+ results = await self._push_to_webhooks(webhooks, payload)
# Check if any webhook responded with skip_pipeline=true
for result in results:
@@ -134,7 +197,8 @@ class WebhookPusher:
else:
self.logger.debug(f'Successfully pushed to webhook {url}')
try:
- return await response.json()
+ result = await httpclient.read_json_limited(response)
+ return result if isinstance(result, dict) else None
except Exception as json_error:
self.logger.debug(f'Failed to parse JSON response from webhook {url}: {json_error}')
return None
diff --git a/src/langbot/pkg/plugin/archive.py b/src/langbot/pkg/plugin/archive.py
new file mode 100644
index 000000000..f3a8511a4
--- /dev/null
+++ b/src/langbot/pkg/plugin/archive.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import io
+import zipfile
+
+import yaml
+
+
+_PLUGIN_ARCHIVE_MAX_ENTRIES = 512
+_PLUGIN_ARCHIVE_MAX_ENTRY_BYTES = 16 * 1024 * 1024
+_PLUGIN_ARCHIVE_MAX_TOTAL_BYTES = 64 * 1024 * 1024
+_PLUGIN_ARCHIVE_MAX_COMPRESSION_RATIO = 100
+_PLUGIN_METADATA_MAX_BYTES = 1024 * 1024
+_PLUGIN_REQUIREMENTS_MAX_ENTRIES = 1000
+
+
+def _read_plugin_archive_member(
+ archive: zipfile.ZipFile,
+ member: zipfile.ZipInfo,
+ *,
+ max_bytes: int = _PLUGIN_METADATA_MAX_BYTES,
+) -> bytes:
+ if member.file_size > max_bytes:
+ raise ValueError(f'Plugin metadata file exceeds the {max_bytes}-byte limit: {member.filename}')
+ with archive.open(member, 'r') as source:
+ content = source.read(max_bytes + 1)
+ if len(content) > max_bytes or len(content) != member.file_size:
+ raise ValueError(f'Plugin metadata file has an invalid size: {member.filename}')
+ return content
+
+
+def inspect_plugin_archive_metadata(
+ file_bytes: bytes,
+ *,
+ require_manifest: bool = True,
+) -> tuple[dict, list[str], list[str]]:
+ """Validate archive size metadata and read only bounded preview fields."""
+
+ with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive:
+ members = archive.infolist()
+ if len(members) > _PLUGIN_ARCHIVE_MAX_ENTRIES:
+ raise ValueError('Plugin archive contains too many entries')
+
+ total_uncompressed = 0
+ files: dict[str, zipfile.ZipInfo] = {}
+ names: list[str] = []
+ for member in members:
+ if member.is_dir():
+ continue
+ if member.flag_bits & 0x1:
+ raise ValueError('Encrypted plugin archives are not supported')
+ if member.file_size > _PLUGIN_ARCHIVE_MAX_ENTRY_BYTES:
+ raise ValueError(f'Plugin archive entry exceeds the size limit: {member.filename}')
+ if (
+ member.file_size
+ and member.file_size > max(member.compress_size, 1) * _PLUGIN_ARCHIVE_MAX_COMPRESSION_RATIO
+ ):
+ raise ValueError(f'Plugin archive entry exceeds the compression-ratio limit: {member.filename}')
+ total_uncompressed += member.file_size
+ if total_uncompressed > _PLUGIN_ARCHIVE_MAX_TOTAL_BYTES:
+ raise ValueError('Plugin archive exceeds the uncompressed size limit')
+ normalized = member.filename.replace('\\', '/').strip('/')
+ names.append(member.filename)
+ files.setdefault(normalized.lower(), member)
+
+ manifest_member = files.get('manifest.yaml') or files.get('manifest.yml')
+ if manifest_member is None:
+ if require_manifest:
+ raise ValueError('manifest.yaml is required')
+ manifest = {}
+ else:
+ manifest = yaml.safe_load(_read_plugin_archive_member(archive, manifest_member).decode('utf-8')) or {}
+ if not isinstance(manifest, dict):
+ raise ValueError('Plugin manifest must be an object')
+
+ requirements: list[str] = []
+ requirements_member = next(
+ (
+ member
+ for normalized, member in files.items()
+ if normalized == 'requirements.txt' or normalized.endswith('/requirements.txt')
+ ),
+ None,
+ )
+ if requirements_member is not None:
+ content = _read_plugin_archive_member(
+ archive,
+ requirements_member,
+ ).decode(
+ 'utf-8',
+ errors='ignore',
+ )
+ requirements = [
+ line.strip()[:1000]
+ for line in content.splitlines()
+ if line.strip() and not line.strip().startswith('#')
+ ][:_PLUGIN_REQUIREMENTS_MAX_ENTRIES]
+ return manifest, requirements, names
diff --git a/src/langbot/pkg/plugin/connector.py b/src/langbot/pkg/plugin/connector.py
index 8d2c8597c..b82b79427 100644
--- a/src/langbot/pkg/plugin/connector.py
+++ b/src/langbot/pkg/plugin/connector.py
@@ -3,22 +3,29 @@ from __future__ import annotations
import asyncio
import contextlib
-import io
+import contextvars
+import hashlib
+import json
import time
-import zipfile
+import uuid
from typing import Any
import typing
import os
+import secrets
import sys
import httpx
import sqlalchemy
-import yaml
-from async_lru import alru_cache
+from urllib.parse import urljoin, urlparse
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
from ..core import app
from . import handler
-from ..utils import platform
+from .archive import inspect_plugin_archive_metadata
+from .github import (
+ validate_github_plugin_install_info,
+ validate_github_release_asset_url,
+)
+from ..utils import constants, httpclient, platform
from ..utils.managed_runtime import ManagedRuntimeConnector
from langbot_plugin.runtime.io.controllers.stdio import (
client as stdio_client_controller,
@@ -33,20 +40,116 @@ from langbot_plugin.api.entities.builtin.command import (
errors as command_errors,
)
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
+from langbot_plugin.runtime.security import (
+ PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
+ PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER,
+ validate_runtime_secret,
+)
+from langbot_plugin.entities.io.context import (
+ InstallationBinding,
+ PluginInstallationDesiredState,
+ PluginWorkerPolicy,
+ RuntimeIdentity,
+)
from ..core import taskmgr
+from ..entity.persistence import bstorage as persistence_bstorage
from ..entity.persistence import plugin as persistence_plugin
+from ..api.http.context import ExecutionContext
+from ..api.http.service.tenant import TenantContext, require_workspace_uuid
+from ..workspace.errors import WorkspaceNotFoundError
+_PLUGIN_ARTIFACT_OWNER_TYPE = 'plugin_artifact'
+_PLUGIN_ARTIFACT_KEY = 'package.lbpkg'
+_PLUGIN_ARTIFACT_STORAGE_MARKER = 'tenant_binary_storage_v1'
+_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES = 10 * 1024 * 1024
+_MARKETPLACE_METADATA_MAX_BYTES = 1024 * 1024
+_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES = 64 * 1024 * 1024
+_MARKETPLACE_SKILL_DOWNLOAD_MAX_BYTES = 10 * 1024 * 1024
+_GITHUB_PLUGIN_DOWNLOAD_MAX_REDIRECTS = 5
+_GITHUB_ASSET_HOSTS = frozenset(
+ {
+ 'api.github.com',
+ 'github.com',
+ 'objects.githubusercontent.com',
+ 'release-assets.githubusercontent.com',
+ }
+)
+_HTTP_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
_CONNECT_TIMEOUT_SEC = 30.0
_HEARTBEAT_INTERVAL_SEC = 20.0
_HEARTBEAT_FAILURE_THRESHOLD = 3
_RECONNECT_MAX_DELAY_SEC = 60.0
+async def _read_httpx_response_limited(
+ response: httpx.Response,
+ *,
+ max_bytes: int,
+) -> bytes:
+ content_length = response.headers.get('content-length')
+ if content_length is not None:
+ try:
+ declared_size = int(content_length)
+ except ValueError:
+ declared_size = None
+ if declared_size is not None and declared_size > max_bytes:
+ raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
+
+ body = bytearray()
+ async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
+ body.extend(chunk)
+ if len(body) > max_bytes:
+ raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
+ return bytes(body)
+
+
+async def _marketplace_get(
+ client: httpx.AsyncClient,
+ url: str,
+ *,
+ max_bytes: int,
+ allow_not_found: bool = False,
+) -> tuple[int, bytes]:
+ async with client.stream('GET', url) as response:
+ if allow_not_found and response.status_code == 404:
+ return response.status_code, b''
+ response.raise_for_status()
+ return response.status_code, await _read_httpx_response_limited(
+ response,
+ max_bytes=max_bytes,
+ )
+
+
+def _decode_json_object(body: bytes, *, subject: str) -> dict[str, Any]:
+ try:
+ payload = json.loads(body)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise ValueError(f'{subject} returned invalid JSON') from exc
+ if not isinstance(payload, dict):
+ raise ValueError(f'{subject} returned a non-object response')
+ return payload
+
+
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
+class PluginInstallationFailedError(RuntimeError):
+ """Stable Runtime desired-state failure for one plugin installation."""
+
+ def __init__(
+ self,
+ installation_uuid: str,
+ error_code: str,
+ message: str,
+ ) -> None:
+ self.installation_uuid = installation_uuid
+ self.error_code = error_code
+ self.runtime_message = message
+ super().__init__(f'Plugin installation {installation_uuid} failed [{error_code}]: {message}')
+
+
class PluginRuntimeConnector(ManagedRuntimeConnector):
"""Plugin runtime connector"""
@@ -77,11 +180,32 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
super().__init__(ap)
self.runtime_disconnect_callback = runtime_disconnect_callback
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
+ self.runtime_profile: typing.Literal['oss_dev', 'shared'] = (
+ 'shared' if getattr(getattr(ap, 'deployment', None), 'mode', 'oss') == 'cloud' else 'oss_dev'
+ )
+ self.runtime_identity: RuntimeIdentity | None = None
+ self._runtime_id = self._build_runtime_id()
+ self.worker_policy: PluginWorkerPolicy | None = None
+ self._execution_context: contextvars.ContextVar[ExecutionContext | None] = contextvars.ContextVar(
+ f'{self.__class__.__name__}_{id(self)}_execution_context',
+ default=None,
+ )
+ self._known_desired_states: dict[str, PluginInstallationDesiredState] = {}
+ self._workspace_installations: dict[str, set[str]] = {}
+ self._installation_failures: dict[str, dict[str, str]] = {}
+ self._state_lock = asyncio.Lock()
+ self._control_token = str(os.environ.get(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV) or '').strip()
self._transport_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
self._generation = 0
self._connected = asyncio.Event()
+ @staticmethod
+ def _build_runtime_id() -> str:
+ """Return the durable identity of this instance's shared Runtime."""
+
+ return f'{constants.instance_id}:plugin-runtime'
+
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
@@ -92,10 +216,580 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is None:
return False
- # Unit-level and explicitly injected handlers don't own a transport.
+ # Unit-level and explicitly injected handlers do not own a transport.
# A managed transport must also have completed its handshake.
return self._transport_task is None or self._connected.is_set()
+ def _load_worker_policy(self) -> PluginWorkerPolicy:
+ """Validate the instance policy without consulting plugin manifests."""
+
+ worker = self.ap.instance_config.data.get('plugin', {}).get('worker')
+ if not isinstance(worker, dict):
+ raise ValueError('plugin.worker must be configured')
+ policy_data = {
+ 'max_cpus': worker.get('max_cpus'),
+ 'max_memory_mb': worker.get('max_memory_mb'),
+ 'max_pids': worker.get('max_pids'),
+ 'max_open_files': worker.get('max_open_files'),
+ 'max_file_size_mb': worker.get('max_file_size_mb'),
+ 'require_hard_limits': worker.get('require_hard_limits', False),
+ }
+ for field_name in (
+ 'max_workers',
+ 'max_total_cpus',
+ 'max_total_memory_mb',
+ 'max_installations',
+ 'max_concurrent_restarts',
+ 'restart_failure_threshold',
+ 'restart_failure_window_seconds',
+ 'restart_circuit_open_seconds',
+ ):
+ if field_name in PluginWorkerPolicy.model_fields and field_name in worker:
+ policy_data[field_name] = worker.get(field_name)
+ return PluginWorkerPolicy.model_validate(policy_data)
+
+ def _control_headers(self, *, allow_generate: bool) -> dict[str, str]:
+ if not self._control_token and allow_generate:
+ self._control_token = secrets.token_urlsafe(48)
+ try:
+ self._control_token = validate_runtime_secret(
+ self._control_token,
+ name=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
+ )
+ except ValueError as exc:
+ raise PluginRuntimeNotConnectedError(
+ f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
+ 'for an external Plugin Runtime'
+ ) from exc
+ return {PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER: self._control_token}
+
+ @staticmethod
+ def _execution_from_binding(binding: Any) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid=str(binding.instance_uuid),
+ workspace_uuid=str(binding.workspace_uuid),
+ placement_generation=int(binding.placement_generation),
+ )
+
+ @staticmethod
+ def _binding_from_setting(
+ execution_context: ExecutionContext,
+ setting: persistence_plugin.PluginSetting,
+ ) -> InstallationBinding:
+ return InstallationBinding(
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ installation_uuid=setting.installation_uuid,
+ runtime_revision=setting.runtime_revision,
+ artifact_digest=setting.artifact_digest,
+ )
+
+ def _legacy_oss_bridge_binding(self, execution_context: ExecutionContext) -> InstallationBinding:
+ seed = f'langbot:oss-plugin-bridge:{execution_context.instance_uuid}:{execution_context.workspace_uuid}'
+ return InstallationBinding(
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ installation_uuid=str(uuid.uuid5(uuid.NAMESPACE_URL, seed)),
+ runtime_revision=1,
+ artifact_digest=hashlib.sha256(seed.encode()).hexdigest(),
+ )
+
+ @staticmethod
+ def _artifact_unique_key(execution_context: ExecutionContext, artifact_digest: str) -> str:
+ # StorageMgr imports the Application graph that wires this connector;
+ # keep the dependency lazy so either module remains independently
+ # importable by tools and focused tests.
+ from ..storage.mgr import StorageMgr
+
+ return StorageMgr.canonical_binary_storage_key(
+ execution_context,
+ owner_type=_PLUGIN_ARTIFACT_OWNER_TYPE,
+ owner=artifact_digest,
+ key=_PLUGIN_ARTIFACT_KEY,
+ )
+
+ async def _store_artifact_package(
+ self,
+ execution_context: ExecutionContext,
+ artifact_digest: str,
+ artifact_package: bytes,
+ ) -> None:
+ """Persist one verified lbpkg in the existing tenant-scoped blob table."""
+
+ if hashlib.sha256(artifact_package).hexdigest() != artifact_digest:
+ raise ValueError('Plugin package digest does not match its desired state')
+ unique_key = self._artifact_unique_key(execution_context, artifact_digest)
+
+ async def store(execute) -> None:
+ result = await execute(
+ sqlalchemy.select(persistence_bstorage.BinaryStorage.value)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
+ )
+ existing_value = result.scalar_one_or_none()
+ if existing_value is not None:
+ if hashlib.sha256(bytes(existing_value)).hexdigest() != artifact_digest:
+ raise ValueError('Persisted plugin package failed digest verification')
+ return
+ await execute(
+ sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
+ workspace_uuid=execution_context.workspace_uuid,
+ unique_key=unique_key,
+ key=_PLUGIN_ARTIFACT_KEY,
+ owner_type=_PLUGIN_ARTIFACT_OWNER_TYPE,
+ owner=artifact_digest,
+ value=artifact_package,
+ )
+ )
+
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(execution_context.workspace_uuid) as uow:
+ await store(uow.execute)
+ else:
+ await store(self.ap.persistence_mgr.execute_async)
+
+ async def _load_artifact_package(
+ self,
+ execution_context: ExecutionContext,
+ artifact_digest: str,
+ ) -> bytes | None:
+ unique_key = self._artifact_unique_key(execution_context, artifact_digest)
+ statement = (
+ sqlalchemy.select(persistence_bstorage.BinaryStorage.value)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
+ )
+
+ async def load(execute) -> bytes | None:
+ result = await execute(statement)
+ stored_value = result.scalar_one_or_none()
+ if stored_value is None:
+ return None
+ artifact_package = bytes(stored_value)
+ if hashlib.sha256(artifact_package).hexdigest() != artifact_digest:
+ raise ValueError('Persisted plugin package failed digest verification')
+ return artifact_package
+
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(execution_context.workspace_uuid) as uow:
+ return await load(uow.execute)
+ return await load(self.ap.persistence_mgr.execute_async)
+
+ async def _delete_artifact_if_unreferenced(
+ self,
+ execution_context: ExecutionContext,
+ artifact_digest: str,
+ *,
+ execute=None,
+ ) -> None:
+ """Delete a tenant copy only after its last desired-state reference."""
+
+ async def cleanup(run) -> None:
+ result = await run(
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.artifact_digest == artifact_digest)
+ )
+ if int(result.scalar_one()) != 0:
+ return
+ await run(
+ sqlalchemy.delete(persistence_bstorage.BinaryStorage)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.owner_type == _PLUGIN_ARTIFACT_OWNER_TYPE)
+ .where(persistence_bstorage.BinaryStorage.owner == artifact_digest)
+ )
+
+ if execute is not None:
+ await cleanup(execute)
+ return
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(execution_context.workspace_uuid) as uow:
+ await cleanup(uow.execute)
+ else:
+ await cleanup(self.ap.persistence_mgr.execute_async)
+
+ async def _load_workspace_settings(
+ self,
+ execution_context: ExecutionContext,
+ ) -> list[persistence_plugin.PluginSetting]:
+ statement = (
+ sqlalchemy.select(*persistence_plugin.PluginSetting.__table__.c)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
+ .order_by(
+ persistence_plugin.PluginSetting.priority.desc(),
+ persistence_plugin.PluginSetting.created_at.asc(),
+ )
+ )
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(execution_context.workspace_uuid) as uow:
+ result = await uow.execute(statement)
+ return [persistence_plugin.PluginSetting(**dict(row)) for row in result.mappings().all()]
+ result = await self.ap.persistence_mgr.execute_async(statement)
+ return [persistence_plugin.PluginSetting(**dict(row)) for row in result.mappings().all()]
+
+ async def _load_workspace_desired_states(
+ self,
+ execution_context: ExecutionContext,
+ ) -> tuple[PluginInstallationDesiredState, ...]:
+ settings = await self._load_workspace_settings(execution_context)
+ desired_states: list[PluginInstallationDesiredState] = []
+ for setting in settings:
+ binding = self._binding_from_setting(execution_context, setting)
+ runtime_handler = getattr(self, 'handler', None)
+ if runtime_handler is not None:
+ runtime_handler.register_installation_binding(
+ binding,
+ plugin_author=setting.plugin_author,
+ plugin_name=setting.plugin_name,
+ )
+ desired_states.append(
+ PluginInstallationDesiredState(
+ binding=binding,
+ enabled=setting.enabled,
+ )
+ )
+ return tuple(desired_states)
+
+ async def _apply_desired_state(
+ self,
+ desired: PluginInstallationDesiredState,
+ *,
+ artifact_package: bytes | None = None,
+ ) -> dict[str, Any]:
+ runtime_handler = self._runtime_handler()
+ result = await runtime_handler.apply_plugin_installation(
+ desired.binding,
+ artifact_package=artifact_package,
+ enabled=desired.enabled,
+ )
+ self._raise_apply_failure(desired, result)
+ if result.get('state') != 'artifact_missing':
+ self._installation_failures.pop(desired.binding.installation_uuid, None)
+ return result
+ execution_context = self._execution_from_binding(desired.binding)
+ persisted_package = await self._load_artifact_package(
+ execution_context,
+ desired.binding.artifact_digest,
+ )
+ if persisted_package is None:
+ if self.runtime_profile == 'oss_dev':
+ self.ap.logger.warning(
+ 'Keeping legacy OSS plugin installation %s on data/plugins; no durable lbpkg is available',
+ desired.binding.installation_uuid,
+ )
+ return result
+ raise RuntimeError(
+ f'Durable plugin artifact {desired.binding.artifact_digest} is missing for '
+ f'installation {desired.binding.installation_uuid}'
+ )
+ repaired = await runtime_handler.apply_plugin_installation(
+ desired.binding,
+ artifact_package=persisted_package,
+ enabled=desired.enabled,
+ )
+ self._raise_apply_failure(desired, repaired)
+ if repaired.get('state') == 'artifact_missing':
+ raise RuntimeError(
+ f'Runtime rejected durable plugin artifact for installation {desired.binding.installation_uuid}'
+ )
+ self._installation_failures.pop(desired.binding.installation_uuid, None)
+ return repaired
+
+ @staticmethod
+ def _runtime_failure_fields(
+ installation_uuid: str,
+ value: Any,
+ ) -> tuple[str, str, str]:
+ if not isinstance(value, dict):
+ return (
+ installation_uuid,
+ 'runtime_installation_failed',
+ ('Plugin Runtime returned a malformed installation failure'),
+ )
+ reported_uuid = str(value.get('installation_uuid') or installation_uuid).strip()
+ if reported_uuid != installation_uuid:
+ return (
+ installation_uuid,
+ 'runtime_installation_failed',
+ ('Plugin Runtime returned a mismatched installation failure'),
+ )
+ error_code = str(value.get('error_code') or 'runtime_installation_failed').strip()
+ if (
+ not error_code
+ or len(error_code) > 64
+ or not error_code.isascii()
+ or any(not (character.isalnum() or character in {'_', '-'}) for character in error_code)
+ ):
+ error_code = 'runtime_installation_failed'
+ message = 'Plugin Runtime failed to apply the desired installation'
+ candidate = str(value.get('message') or '').replace('\r', ' ').replace('\n', ' ').strip()
+ if candidate:
+ message = candidate[:512]
+ return installation_uuid, error_code, message
+
+ def _raise_apply_failure(
+ self,
+ desired: PluginInstallationDesiredState,
+ result: dict[str, Any],
+ ) -> None:
+ if result.get('state') != 'failed':
+ return
+ installation_uuid, error_code, message = self._runtime_failure_fields(
+ desired.binding.installation_uuid,
+ result,
+ )
+ failure = {
+ 'installation_uuid': installation_uuid,
+ 'error_code': error_code,
+ 'message': message,
+ }
+ self._installation_failures[installation_uuid] = failure
+ self.ap.logger.error(
+ 'Plugin installation %s failed during apply [%s]: %s',
+ installation_uuid,
+ error_code,
+ message,
+ )
+ raise PluginInstallationFailedError(
+ installation_uuid,
+ error_code,
+ message,
+ )
+
+ def _record_reconcile_failures(
+ self,
+ desired_states: dict[str, PluginInstallationDesiredState],
+ result: dict[str, Any],
+ ) -> None:
+ reported = result.get('failed_installations', [])
+ if not isinstance(reported, list):
+ self.ap.logger.error('Plugin Runtime returned malformed failed_installations during reconcile')
+ reported = []
+
+ failures: dict[str, dict[str, str]] = {}
+ for value in reported:
+ requested_uuid = str(value.get('installation_uuid') or '').strip() if isinstance(value, dict) else ''
+ if requested_uuid not in desired_states:
+ self.ap.logger.error(
+ 'Plugin Runtime reported failure for unknown installation %s',
+ requested_uuid or '',
+ )
+ continue
+ installation_uuid, error_code, message = self._runtime_failure_fields(
+ requested_uuid,
+ value,
+ )
+ failure = {
+ 'installation_uuid': installation_uuid,
+ 'error_code': error_code,
+ 'message': message,
+ }
+ failures[installation_uuid] = failure
+ if self._installation_failures.get(installation_uuid) != failure:
+ self.ap.logger.error(
+ 'Plugin installation %s failed during reconcile [%s]: %s',
+ installation_uuid,
+ error_code,
+ message,
+ )
+
+ for installation_uuid in tuple(self._installation_failures):
+ if installation_uuid not in failures:
+ self._installation_failures.pop(installation_uuid, None)
+ self._installation_failures.update(failures)
+
+ async def _repair_reconcile_missing_artifacts(
+ self,
+ desired_states: dict[str, PluginInstallationDesiredState],
+ result: dict[str, Any],
+ ) -> None:
+ for installation_uuid in result.get('missing_artifacts', []):
+ desired = desired_states.get(str(installation_uuid))
+ if desired is None:
+ raise RuntimeError(f'Runtime reported an unknown missing installation {installation_uuid}')
+ execution_context = self._execution_from_binding(desired.binding)
+ persisted_package = await self._load_artifact_package(
+ execution_context,
+ desired.binding.artifact_digest,
+ )
+ if persisted_package is None:
+ if self.runtime_profile == 'oss_dev':
+ self.ap.logger.warning(
+ 'Keeping legacy OSS plugin installation %s on data/plugins; no durable lbpkg is available',
+ desired.binding.installation_uuid,
+ )
+ continue
+ raise RuntimeError(
+ f'Durable plugin artifact {desired.binding.artifact_digest} is missing for '
+ f'installation {desired.binding.installation_uuid}'
+ )
+ try:
+ await self._apply_desired_state(
+ desired,
+ artifact_package=persisted_package,
+ )
+ except PluginInstallationFailedError as exc:
+ failures = result.setdefault('failed_installations', [])
+ if not any(
+ isinstance(item, dict) and item.get('installation_uuid') == exc.installation_uuid
+ for item in failures
+ ):
+ failures.append(
+ {
+ 'installation_uuid': exc.installation_uuid,
+ 'error_code': exc.error_code,
+ 'message': exc.runtime_message,
+ }
+ )
+
+ async def _prepare_connected_runtime(self) -> None:
+ """Handshake follow-up: pin OSS compatibility, then replay authority."""
+
+ runtime_handler = self._runtime_handler()
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ if workspace_service is None:
+ raise RuntimeError('Plugin Runtime requires the Workspace projection service')
+
+ if self.runtime_profile == 'shared':
+ list_bindings = getattr(workspace_service, 'list_active_execution_bindings', None)
+ if not callable(list_bindings):
+ raise RuntimeError('Shared plugin Runtime requires instance-scoped Workspace discovery')
+ projected_bindings = await list_bindings()
+ await self.reconcile_projected_workspaces(
+ self._execution_from_binding(binding) for binding in projected_bindings
+ )
+ return
+
+ if self.runtime_profile == 'oss_dev':
+ local_binding = await workspace_service.get_local_execution_binding()
+ execution_context = self._execution_from_binding(local_binding)
+ bridge = self._legacy_oss_bridge_binding(execution_context)
+ # One fully-bound action releases the SDK's deliberately retained
+ # pre-v4 data/plugins/debug compatibility path. Shared mode never
+ # creates this bridge.
+ with runtime_handler.installation_scope(bridge):
+ await runtime_handler.list_plugins()
+ desired_states = await self._load_workspace_desired_states(execution_context)
+ self._workspace_installations[execution_context.workspace_uuid] = {
+ state.binding.installation_uuid for state in desired_states
+ }
+ self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
+
+ result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
+ await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
+ self._record_reconcile_failures(self._known_desired_states, result)
+
+ async def reconcile_projected_workspaces(
+ self,
+ contexts: typing.Iterable[ExecutionContext],
+ ) -> dict[str, Any]:
+ """Replay a closed control plane's complete projected Workspace set.
+
+ PostgreSQL RLS intentionally prevents Core from globally scanning
+ Workspaces. The caller enumerates authoritative projections; Core then
+ reads each Workspace inside a tenant UoW and performs one instance-wide
+ Runtime reconcile.
+ """
+
+ runtime_handler = self._runtime_handler()
+ async with self._state_lock:
+ all_states: dict[str, PluginInstallationDesiredState] = {}
+ workspace_installations: dict[str, set[str]] = {}
+ for context in contexts:
+ execution_context = await self._validate_execution_context(context)
+ states = await self._load_workspace_desired_states(execution_context)
+ installation_ids = {state.binding.installation_uuid for state in states}
+ if installation_ids:
+ # A newly registered Cloud Workspace normally has no
+ # plugins. Avoid retaining an empty set for every account.
+ workspace_installations[execution_context.workspace_uuid] = installation_ids
+ for state in states:
+ if state.binding.installation_uuid in all_states:
+ raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
+ all_states[state.binding.installation_uuid] = state
+ result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
+ await self._repair_reconcile_missing_artifacts(all_states, result)
+ self._record_reconcile_failures(all_states, result)
+ for installation_uuid, previous in tuple(self._known_desired_states.items()):
+ if installation_uuid not in all_states:
+ runtime_handler.unregister_installation_binding(previous.binding)
+ self._known_desired_states = all_states
+ self._workspace_installations = workspace_installations
+ return result
+
+ async def _validate_execution_context(self, context: TenantContext) -> ExecutionContext:
+ workspace_uuid = require_workspace_uuid(context)
+ instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
+ generation = getattr(context, 'placement_generation', None)
+ if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
+ raise WorkspaceNotFoundError('Plugin resource not found')
+ binding = await self.ap.workspace_service.get_execution_binding(
+ workspace_uuid,
+ expected_generation=generation,
+ )
+ if binding.instance_uuid != instance_uuid:
+ raise WorkspaceNotFoundError('Plugin resource not found')
+ return ExecutionContext(
+ instance_uuid=instance_uuid,
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ trigger_principal=getattr(context, 'principal', None),
+ entitlement_revision=getattr(context, 'entitlement_revision', 0),
+ )
+
+ async def _synchronize_workspace(self, execution_context: ExecutionContext) -> None:
+ if not self.is_enable_plugin or not hasattr(self, 'handler'):
+ return
+ runtime_handler = self._runtime_handler()
+ desired_states = await self._load_workspace_desired_states(execution_context)
+ desired_by_uuid = {state.binding.installation_uuid: state for state in desired_states}
+ async with self._state_lock:
+ previous_ids = set(self._workspace_installations.get(execution_context.workspace_uuid, set()))
+ for installation_uuid in previous_ids - set(desired_by_uuid):
+ previous = self._known_desired_states.get(installation_uuid)
+ if previous is not None:
+ await runtime_handler.remove_plugin_installation(previous.binding)
+ runtime_handler.unregister_installation_binding(previous.binding)
+ self._known_desired_states.pop(installation_uuid, None)
+ self._installation_failures.pop(installation_uuid, None)
+
+ for installation_uuid, desired in desired_by_uuid.items():
+ if self._known_desired_states.get(installation_uuid) == desired:
+ continue
+ try:
+ await self._apply_desired_state(desired)
+ except PluginInstallationFailedError:
+ # The failure is retained per installation. Continue
+ # restoring the remaining desired state in this Workspace.
+ pass
+ self._known_desired_states[installation_uuid] = desired
+ if desired_by_uuid:
+ self._workspace_installations[execution_context.workspace_uuid] = set(desired_by_uuid)
+ else:
+ self._workspace_installations.pop(
+ execution_context.workspace_uuid,
+ None,
+ )
+
+ async def _current_execution_context(self) -> ExecutionContext:
+ current = self._execution_context.get()
+ if current is not None:
+ return current
+ if self.runtime_profile != 'oss_dev':
+ raise WorkspaceNotFoundError('Plugin resource not found')
+ binding = await self.ap.workspace_service.get_local_execution_binding()
+ current = self._execution_from_binding(binding)
+ self._execution_context.set(current)
+ await self._synchronize_workspace(current)
+ return current
+
async def heartbeat_loop(self):
failures = 0
while not self._closing:
@@ -118,6 +812,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self.is_enable_plugin:
self.ap.logger.info('Plugin system is disabled.')
return
+ self.runtime_identity = RuntimeIdentity(
+ instance_uuid=constants.instance_id,
+ runtime_id=self._runtime_id,
+ )
+ self.worker_policy = self._load_worker_policy()
async with self._lifecycle_lock:
if self._closing:
@@ -158,18 +857,31 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
await notify_disconnect()
return False
- runtime_handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
+ runtime_handler = handler.RuntimeConnectionHandler(
+ connection,
+ disconnect_callback,
+ self.ap,
+ )
self.handler = runtime_handler
self.handler_task = asyncio.create_task(runtime_handler.run())
try:
await runtime_handler.ping()
+ if self.runtime_identity is None or self.worker_policy is None: # pragma: no cover
+ raise RuntimeError('Plugin Runtime identity or worker policy was not loaded')
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
+ await runtime_handler.set_runtime_config(
+ runtime_identity=self.runtime_identity,
+ worker_policy=self.worker_policy,
+ runtime_profile=self.runtime_profile,
+ cloud_service_url=space_url or None,
+ )
if space_url:
- await runtime_handler.set_runtime_config(cloud_service_url=space_url)
+ self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
+ await self._prepare_connected_runtime()
if generation == self._generation and not self._closing:
connection_ready = True
self._connected.set()
- self.ap.logger.info('Connected to plugin runtime.')
+ self.ap.logger.info('Connected to instance-scoped plugin runtime.')
await self.handler_task
except asyncio.CancelledError:
raise
@@ -184,38 +896,58 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
del self.handler
await notify_disconnect()
- task_coro: typing.Coroutine
+ task_coro: typing.Coroutine[Any, Any, Any]
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
+ self.ap.logger.info('use websocket to connect to plugin runtime')
+ control_headers = self._control_headers(allow_generate=False)
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
'runtime_ws_url',
'ws://langbot_plugin_runtime:5400/control/ws',
)
- async def connection_failed(ctrl, exc=None):
- error = exc or RuntimeError('WebSocket connection failed')
- connect_errors.append(error)
+ async def connection_failed(
+ ctrl: ws_client_controller.WebSocketClientController,
+ exc: Exception | None = None,
+ ) -> None:
+ del ctrl
+ connect_errors.append(exc or RuntimeError('WebSocket connection failed'))
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
+ additional_headers=control_headers,
)
task_coro = self.ctrl.run(new_connection_callback)
elif platform.get_platform() == 'win32':
- await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
+ # Windows cannot use the stdio subprocess transport, so launch
+ # a managed runtime and authenticate its WebSocket controller.
+ self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
+ control_headers = self._control_headers(allow_generate=True)
+ await self._start_runtime_subprocess(
+ '-m',
+ 'langbot_plugin.cli.__init__',
+ 'rt',
+ env_overrides={PLUGIN_RUNTIME_CONTROL_TOKEN_ENV: self._control_token},
+ )
ws_url = 'ws://localhost:5400/control/ws'
- async def connection_failed(ctrl, exc=None):
- error = exc or RuntimeError('WebSocket connection failed')
- connect_errors.append(error)
+ async def connection_failed(
+ ctrl: ws_client_controller.WebSocketClientController,
+ exc: Exception | None = None,
+ ) -> None:
+ del ctrl
+ connect_errors.append(exc or RuntimeError('WebSocket connection failed'))
self._connected.set()
self.ctrl = ws_client_controller.WebSocketClientController(
ws_url=ws_url,
make_connection_failed_callback=connection_failed,
+ additional_headers=control_headers,
)
task_coro = self.ctrl.run(new_connection_callback)
else:
+ self.ap.logger.info('use stdio to connect to plugin runtime')
self.ctrl = stdio_client_controller.StdioClientController(
command=sys.executable,
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
@@ -307,6 +1039,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
async def ping_plugin_runtime(self):
return await self._runtime_handler().ping()
+ async def require_workspace_context(self, context: TenantContext) -> ExecutionContext:
+ """Validate and select one Workspace for this asyncio request task."""
+
+ execution_context = await self._validate_execution_context(context)
+ self._execution_context.set(execution_context)
+ if not self.is_enable_plugin:
+ return execution_context
+ if not hasattr(self, 'handler'):
+ raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
+ await self._synchronize_workspace(execution_context)
+ return execution_context
+
def _inspect_plugin_package(
self,
file_bytes: bytes,
@@ -317,34 +1061,109 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_name = None
try:
- with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
- try:
- manifest = yaml.safe_load(zf.read('manifest.yaml').decode('utf-8', errors='ignore')) or {}
- metadata = manifest.get('metadata', {})
- plugin_author = metadata.get('author')
- plugin_name = metadata.get('name')
- except Exception:
- pass
-
- if task_context is not None:
- for name in zf.namelist():
- if name.endswith('requirements.txt'):
- content = zf.read(name).decode('utf-8', errors='ignore')
- deps = [
- line.strip()
- for line in content.splitlines()
- if line.strip() and not line.strip().startswith('#')
- ]
- task_context.metadata['deps_total'] = len(deps)
- task_context.metadata['deps_list'] = deps
- break
+ manifest, dependencies, archive_names = inspect_plugin_archive_metadata(
+ file_bytes,
+ require_manifest=False,
+ )
+ metadata = manifest.get('metadata', {})
+ if isinstance(metadata, dict):
+ plugin_author = metadata.get('author')
+ plugin_name = metadata.get('name')
+ has_requirements = any(
+ name.replace('\\', '/').lower().endswith('requirements.txt') for name in archive_names
+ )
+ if task_context is not None and has_requirements:
+ task_context.metadata['deps_total'] = len(dependencies)
+ task_context.metadata['deps_list'] = dependencies
except Exception:
pass
return plugin_author, plugin_name
+ async def _operation_bindings(
+ self,
+ *,
+ include_plugins: list[str] | None = None,
+ include_disabled: bool = False,
+ ) -> list[InstallationBinding]:
+ execution_context = await self._current_execution_context()
+ settings = await self._load_workspace_settings(execution_context)
+ bindings: list[InstallationBinding] = (
+ [self._legacy_oss_bridge_binding(execution_context)] if self.runtime_profile == 'oss_dev' else []
+ )
+ for setting in settings:
+ plugin_id = f'{setting.plugin_author}/{setting.plugin_name}'
+ if not include_disabled and not setting.enabled:
+ continue
+ if include_plugins is not None and plugin_id not in include_plugins:
+ continue
+ if self.runtime_profile == 'oss_dev' and (
+ not isinstance(setting.install_info, dict)
+ or setting.install_info.get('_artifact_storage') != _PLUGIN_ARTIFACT_STORAGE_MARKER
+ ):
+ continue
+ bindings.append(self._binding_from_setting(execution_context, setting))
+ return bindings
+
+ async def _setting_for_plugin(
+ self,
+ plugin_author: str,
+ plugin_name: str,
+ *,
+ require_enabled: bool = False,
+ ) -> tuple[ExecutionContext, persistence_plugin.PluginSetting]:
+ execution_context = await self._current_execution_context()
+ settings = await self._load_workspace_settings(execution_context)
+ for setting in settings:
+ if setting.plugin_author == plugin_author and setting.plugin_name == plugin_name:
+ if require_enabled and not setting.enabled:
+ raise ValueError(f'Plugin {plugin_author}/{plugin_name} is disabled')
+ return execution_context, setting
+ raise ValueError(f'Plugin {plugin_author}/{plugin_name} is not installed in this Workspace')
+
+ async def _target_binding(
+ self,
+ plugin_author: str,
+ plugin_name: str,
+ *,
+ require_enabled: bool = True,
+ ) -> InstallationBinding:
+ execution_context, setting = await self._setting_for_plugin(
+ plugin_author,
+ plugin_name,
+ require_enabled=require_enabled,
+ )
+ if self.runtime_profile == 'oss_dev' and (
+ not isinstance(setting.install_info, dict)
+ or setting.install_info.get('_artifact_storage') != _PLUGIN_ARTIFACT_STORAGE_MARKER
+ ):
+ return self._legacy_oss_bridge_binding(execution_context)
+ return self._binding_from_setting(execution_context, setting)
+
+ async def _target_binding_for_component(
+ self,
+ component_name: str,
+ *,
+ component_kind: typing.Literal['tool', 'command'],
+ include_plugins: list[str] | None,
+ ) -> InstallationBinding:
+ runtime_handler = self._runtime_handler()
+ for binding in await self._operation_bindings(include_plugins=include_plugins):
+ with runtime_handler.installation_scope(binding):
+ components = (
+ await runtime_handler.list_tools(include_plugins=include_plugins)
+ if component_kind == 'tool'
+ else await runtime_handler.list_commands(include_plugins=include_plugins)
+ )
+ for component in components:
+ manifest = ComponentManifest.model_validate(component)
+ if manifest.metadata.name == component_name:
+ return binding
+ raise ValueError(f'Plugin {component_kind} {component_name!r} was not found in this Workspace')
+
async def _install_mcp_from_marketplace(
self,
+ execution_context: ExecutionContext,
mcp_data: dict[str, Any],
task_context: taskmgr.TaskContext | None = None,
):
@@ -357,9 +1176,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
for ``http``/``sse`` it preserves ``url``/``headers``/``timeout``/
``ssereadtimeout``.
"""
- from ..entity.persistence import mcp as persistence_mcp
- import uuid
-
mode = mcp_data.get('mode') or 'stdio'
extra_args = mcp_data.get('extra_args') or {}
# The MCP transport selection was simplified to two modes: 'stdio'
@@ -377,18 +1193,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
# Use __ instead of / to avoid URL routing issues with slashes
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
- # Check if MCP server already exists
- existing = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name)
- )
- if existing.scalar_one_or_none():
+ existing = await self.ap.mcp_service.get_mcp_server_by_name(execution_context, name)
+ if existing is not None:
self.ap.logger.info(f'MCP server {name} already exists, skipping installation')
return
- # Create MCP server record
- server_uuid = str(uuid.uuid4())
server_data = {
- 'uuid': server_uuid,
'name': name,
'enable': True,
'mode': mode,
@@ -396,23 +1206,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
'readme': readme,
}
- await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
-
- # Start the MCP server
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.uuid == server_uuid)
- )
- server_entity = result.first()
- if server_entity:
- server_config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server_entity)
- if self.ap.tool_mgr.mcp_tool_loader:
- mcp_task = asyncio.create_task(self.ap.tool_mgr.mcp_tool_loader.host_mcp_server(server_config))
- self.ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks.append(mcp_task)
+ await self.ap.mcp_service.create_mcp_server(execution_context, server_data)
self.ap.logger.info(f'Installed MCP server {name} from marketplace')
async def _install_skill_from_zip(
self,
+ execution_context: ExecutionContext,
file_bytes: bytes,
filename: str,
task_context: taskmgr.TaskContext | None = None,
@@ -426,6 +1226,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
# Install from ZIP using skill service
result = await skill_service.install_from_zip_upload(
+ execution_context,
file_bytes=file_bytes,
filename=filename + '.zip',
)
@@ -484,194 +1285,442 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
message = f'{message} Last runtime error: {last_error}'
raise RuntimeError(message)
+ async def _persist_installation_package(
+ self,
+ execution_context: ExecutionContext,
+ *,
+ plugin_author: str,
+ plugin_name: str,
+ install_source: PluginInstallSource,
+ install_info: dict[str, Any],
+ artifact_digest: str,
+ ) -> tuple[InstallationBinding, str | None, bool]:
+ safe_install_info = {
+ key: value
+ for key, value in install_info.items()
+ if key not in {'plugin_file', 'plugin_file_key'}
+ and key != '_artifact_storage'
+ and isinstance(value, (str, int, float, bool, list, dict, type(None)))
+ }
+ safe_install_info['_artifact_storage'] = _PLUGIN_ARTIFACT_STORAGE_MARKER
+ statement = (
+ sqlalchemy.select(
+ persistence_plugin.PluginSetting.installation_uuid,
+ persistence_plugin.PluginSetting.runtime_revision,
+ persistence_plugin.PluginSetting.artifact_digest,
+ persistence_plugin.PluginSetting.install_info,
+ )
+ .where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
+ .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ )
+
+ async def persist(execute):
+ result = await execute(statement)
+ setting = result.first()
+ if setting is None:
+ installation_uuid = str(uuid.uuid4())
+ runtime_revision = 1
+ previous_digest = None
+ previous_was_durable = False
+ await execute(
+ sqlalchemy.insert(persistence_plugin.PluginSetting).values(
+ workspace_uuid=execution_context.workspace_uuid,
+ plugin_author=plugin_author,
+ plugin_name=plugin_name,
+ installation_uuid=installation_uuid,
+ artifact_digest=artifact_digest,
+ runtime_revision=runtime_revision,
+ install_source=install_source.value,
+ install_info=safe_install_info,
+ enabled=True,
+ priority=0,
+ config={},
+ )
+ )
+ else:
+ installation_uuid = setting.installation_uuid
+ runtime_revision = setting.runtime_revision + 1
+ previous_digest = setting.artifact_digest
+ previous_was_durable = (
+ isinstance(setting.install_info, dict)
+ and setting.install_info.get('_artifact_storage') == _PLUGIN_ARTIFACT_STORAGE_MARKER
+ )
+ await execute(
+ sqlalchemy.update(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
+ .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ .values(
+ artifact_digest=artifact_digest,
+ runtime_revision=runtime_revision,
+ install_source=install_source.value,
+ install_info=safe_install_info,
+ enabled=True,
+ )
+ )
+ return (
+ InstallationBinding(
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ installation_uuid=installation_uuid,
+ runtime_revision=runtime_revision,
+ artifact_digest=artifact_digest,
+ ),
+ previous_digest,
+ previous_was_durable,
+ )
+
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(execution_context.workspace_uuid) as uow:
+ return await persist(uow.execute)
+ return await persist(self.ap.persistence_mgr.execute_async)
+
+ async def _download_github_package(
+ self,
+ install_info: dict[str, Any],
+ task_context: taskmgr.TaskContext | None,
+ ) -> bytes:
+ normalized = validate_github_plugin_install_info(install_info)
+ owner = normalized['owner']
+ repo = normalized['repo']
+ release_tag = normalized['release_tag']
+
+ async with httpx.AsyncClient(
+ trust_env=False,
+ follow_redirects=False,
+ timeout=httpx.Timeout(60, connect=10),
+ event_hooks=httpclient.httpx_response_limit_hooks(_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES),
+ ) as client:
+ asset_id: int | None = None
+ if 'asset_id' in normalized:
+ release_id = normalized['release_id']
+ asset_id = normalized['asset_id']
+ metadata_url = f'https://api.github.com/repos/{owner}/{repo}/releases/{release_id}'
+ response = await client.get(
+ metadata_url,
+ headers={
+ 'Accept': 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'User-Agent': 'LangBot-Plugin-Installer',
+ },
+ )
+ if response.status_code in _HTTP_REDIRECT_STATUSES:
+ raise ValueError('GitHub release metadata unexpectedly redirected')
+ response.raise_for_status()
+ release = await httpclient.parse_json_response(response)
+ if not isinstance(release, dict):
+ raise ValueError('GitHub release metadata is invalid')
+ if release.get('id') != release_id or str(release.get('tag_name') or '') != release_tag:
+ raise ValueError('GitHub release metadata does not match the requested release')
+ assets = release.get('assets')
+ if not isinstance(assets, list):
+ raise ValueError('GitHub release has no asset metadata')
+ asset = next(
+ (
+ candidate
+ for candidate in assets
+ if isinstance(candidate, dict) and candidate.get('id') == asset_id
+ ),
+ None,
+ )
+ if asset is None:
+ raise ValueError('GitHub release asset does not belong to the requested release')
+ declared_size = asset.get('size')
+ if isinstance(declared_size, int) and declared_size > _GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES:
+ raise ValueError('GitHub plugin package exceeds the 10 MiB download limit')
+ if asset.get('state') not in {None, 'uploaded'}:
+ raise ValueError('GitHub release asset is not ready for download')
+ asset_url = f'https://api.github.com/repos/{owner}/{repo}/releases/assets/{asset_id}'
+ else:
+ asset_url = normalized['asset_url']
+
+ downloaded = 0
+ chunks: list[bytes] = []
+ start_time = time.time()
+ current_url = asset_url
+ if task_context is not None:
+ task_context.set_current_action('downloading plugin package')
+ task_context.metadata.update({'download_total': 0, 'download_current': 0, 'download_speed': 0})
+
+ for redirect_count in range(_GITHUB_PLUGIN_DOWNLOAD_MAX_REDIRECTS + 1):
+ self._validate_github_download_hop(
+ current_url,
+ owner=owner,
+ repo=repo,
+ release_tag=release_tag,
+ asset_id=asset_id,
+ )
+ parsed = urlparse(current_url)
+ request_headers = {
+ 'Accept-Encoding': 'identity',
+ 'User-Agent': 'LangBot-Plugin-Installer',
+ }
+ if (parsed.hostname or '').lower() == 'api.github.com':
+ request_headers.update(
+ {
+ 'Accept': 'application/octet-stream',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ }
+ )
+
+ async with client.stream('GET', current_url, headers=request_headers) as response:
+ if response.status_code in _HTTP_REDIRECT_STATUSES:
+ location = response.headers.get('location')
+ if not location:
+ raise ValueError('GitHub release asset redirect is missing a location')
+ if redirect_count >= _GITHUB_PLUGIN_DOWNLOAD_MAX_REDIRECTS:
+ raise ValueError('GitHub release asset exceeded the redirect limit')
+ next_url = urljoin(current_url, location)
+ self._validate_github_download_hop(
+ next_url,
+ owner=owner,
+ repo=repo,
+ release_tag=release_tag,
+ asset_id=asset_id,
+ )
+ current_url = next_url
+ continue
+
+ response.raise_for_status()
+ content_length_header = response.headers.get('content-length')
+ try:
+ content_length = int(content_length_header) if content_length_header is not None else 0
+ except ValueError as exc:
+ raise ValueError('GitHub release asset has an invalid content length') from exc
+ if content_length < 0:
+ raise ValueError('GitHub release asset has an invalid content length')
+ if content_length > _GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES:
+ raise ValueError('GitHub plugin package exceeds the 10 MiB download limit')
+ if task_context is not None:
+ task_context.metadata['download_total'] = content_length
+
+ async for chunk in response.aiter_bytes(chunk_size=8192):
+ downloaded += len(chunk)
+ if downloaded > _GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES:
+ raise ValueError('GitHub plugin package exceeds the 10 MiB download limit')
+ chunks.append(chunk)
+ if task_context is not None:
+ elapsed = time.time() - start_time
+ task_context.metadata.update(
+ {
+ 'download_current': downloaded,
+ 'download_speed': downloaded / elapsed if elapsed > 0 else 0,
+ }
+ )
+ return b''.join(chunks)
+
+ raise ValueError('GitHub release asset exceeded the redirect limit')
+
+ @staticmethod
+ def _validate_github_download_hop(
+ url: str,
+ *,
+ owner: str,
+ repo: str,
+ release_tag: str,
+ asset_id: int | None,
+ ) -> None:
+ """Reject redirects away from the small set of GitHub asset hosts."""
+
+ parsed = urlparse(str(url or '').strip())
+ try:
+ port = parsed.port
+ except ValueError as exc:
+ raise ValueError('GitHub release asset URL has an invalid port') from exc
+ hostname = (parsed.hostname or '').lower()
+ if (
+ parsed.scheme != 'https'
+ or hostname not in _GITHUB_ASSET_HOSTS
+ or parsed.username is not None
+ or parsed.password is not None
+ or port not in {None, 443}
+ or parsed.fragment
+ ):
+ raise ValueError('GitHub release asset redirected to an untrusted host')
+ if hostname == 'api.github.com':
+ expected_path = f'/repos/{owner}/{repo}/releases/assets/{asset_id}'
+ if asset_id is None or parsed.path != expected_path or parsed.query:
+ raise ValueError('GitHub release asset API URL is not trusted')
+ elif hostname == 'github.com':
+ validate_github_release_asset_url(
+ url,
+ owner=owner,
+ repo=repo,
+ release_tag=release_tag,
+ )
+ elif not parsed.path or parsed.path == '/':
+ raise ValueError('GitHub release asset redirect has no object path')
+
+ async def _download_marketplace_package(
+ self,
+ execution_context: ExecutionContext,
+ plugin_author: str,
+ plugin_name: str,
+ task_context: taskmgr.TaskContext | None,
+ ) -> tuple[bytes | None, str | None]:
+ """Return a plugin package, or install an MCP/skill and return none."""
+
+ space_url = self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app').rstrip('/')
+ async with httpx.AsyncClient(
+ trust_env=True,
+ timeout=15,
+ event_hooks=httpclient.httpx_response_limit_hooks(_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES),
+ ) as client:
+ mcp_status, mcp_body = await _marketplace_get(
+ client,
+ f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}',
+ max_bytes=_MARKETPLACE_METADATA_MAX_BYTES,
+ allow_not_found=True,
+ )
+ if mcp_status == 200:
+ mcp_payload = _decode_json_object(mcp_body, subject='Marketplace MCP metadata')
+ mcp_data = mcp_payload.get('data', {}).get('mcp', {})
+ if not isinstance(mcp_data, dict):
+ raise ValueError(f'MCP {plugin_author}/{plugin_name} metadata is invalid')
+ if not mcp_data.get('mode'):
+ raise ValueError(f'MCP {plugin_author}/{plugin_name} has no mode')
+ await self._install_mcp_from_marketplace(execution_context, mcp_data, task_context)
+ try:
+ async with client.stream(
+ 'POST',
+ f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}/install',
+ ):
+ pass
+ except Exception as report_err:
+ self.ap.logger.debug(f'Failed to report MCP install: {report_err}')
+ return None, None
+
+ skill_status, _skill_body = await _marketplace_get(
+ client,
+ f'{space_url}/api/v1/marketplace/skills/{plugin_author}/{plugin_name}',
+ max_bytes=_MARKETPLACE_METADATA_MAX_BYTES,
+ allow_not_found=True,
+ )
+ if skill_status == 200:
+ _download_status, skill_package = await _marketplace_get(
+ client,
+ f'{space_url}/api/v1/marketplace/skills/download/{plugin_author}/{plugin_name}',
+ max_bytes=_MARKETPLACE_SKILL_DOWNLOAD_MAX_BYTES,
+ )
+ await self._install_skill_from_zip(
+ execution_context,
+ skill_package,
+ f'{plugin_author}-{plugin_name}',
+ task_context,
+ )
+ return None, None
+
+ _versions_status, versions_body = await _marketplace_get(
+ client,
+ f'{space_url}/api/v1/marketplace/plugins/{plugin_author}/{plugin_name}/versions',
+ max_bytes=_MARKETPLACE_METADATA_MAX_BYTES,
+ )
+ versions_payload = _decode_json_object(
+ versions_body,
+ subject='Marketplace plugin versions',
+ )
+ versions = versions_payload.get('data', {}).get('versions', [])
+ if (
+ not isinstance(versions, list)
+ or not versions
+ or not isinstance(versions[0], dict)
+ or not versions[0].get('version')
+ ):
+ raise ValueError(f'Plugin {plugin_author}/{plugin_name} has no versions')
+ latest_version = str(versions[0]['version'])
+ _download_status, plugin_package = await _marketplace_get(
+ client,
+ f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
+ max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
+ )
+ return plugin_package, latest_version
+
async def install_plugin(
self,
install_source: PluginInstallSource,
install_info: dict[str, Any],
task_context: taskmgr.TaskContext | None = None,
- ):
- plugin_author = install_info.get('plugin_author')
- plugin_name = install_info.get('plugin_name')
+ ) -> None:
+ runtime_handler = self._runtime_handler()
+ execution_context = await self._current_execution_context()
+ plugin_author = str(install_info.get('plugin_author') or '')
+ plugin_name = str(install_info.get('plugin_name') or '')
+ file_bytes: bytes | None
if install_source == PluginInstallSource.MARKETPLACE:
- # Handle marketplace plugin/mcp/skill installation
- plugin_author = install_info.get('plugin_author', '')
- plugin_name = install_info.get('plugin_name', '')
- space_url = (
- self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app').rstrip('/')
+ file_bytes, version = await self._download_marketplace_package(
+ execution_context,
+ plugin_author,
+ plugin_name,
+ task_context,
)
-
- # Try MCP endpoint first
- async with httpx.AsyncClient(trust_env=True, timeout=15) as client:
- mcp_resp = await client.get(f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}')
- if mcp_resp.status_code == 200:
- mcp_data = mcp_resp.json().get('data', {}).get('mcp', {})
- if mcp_data.get('mode'):
- # It's an MCP - create server locally
- self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}')
- if task_context:
- task_context.set_current_action('installing mcp server')
- await self._install_mcp_from_marketplace(mcp_data, task_context)
- # Best-effort install report (bumps marketplace install_count).
- try:
- await client.post(
- f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}/install'
- )
- except Exception as report_err:
- self.ap.logger.debug(f'Failed to report MCP install: {report_err}')
- return
- else:
- raise Exception(f'MCP {plugin_author}/{plugin_name} has no mode')
- elif mcp_resp.status_code == 404:
- # Try skill endpoint - download ZIP and install
- self.ap.logger.info(f'Trying skill endpoint for: {plugin_author}/{plugin_name}')
- if task_context:
- task_context.set_current_action('checking skill marketplace')
-
- # Get skill detail to find version
- skill_resp = await client.get(
- f'{space_url}/api/v1/marketplace/skills/{plugin_author}/{plugin_name}'
- )
- if skill_resp.status_code == 200:
- self.ap.logger.info(f'Installing skill from marketplace: {plugin_author}/{plugin_name}')
- if task_context:
- task_context.set_current_action('installing skill from marketplace')
-
- # Download the skill ZIP (no version needed - uses latest)
- if task_context:
- task_context.set_current_action('downloading skill package')
-
- download_resp = await client.get(
- f'{space_url}/api/v1/marketplace/skills/download/{plugin_author}/{plugin_name}'
- )
- if download_resp.status_code != 200:
- raise Exception(
- f'Failed to download skill {plugin_author}/{plugin_name}: {download_resp.status_code}'
- )
-
- file_bytes = download_resp.content
- file_size = len(file_bytes)
- self.ap.logger.info(f'Downloaded skill ZIP ({file_size} bytes)')
-
- # Install skill from ZIP using skill service
- await self._install_skill_from_zip(file_bytes, f'{plugin_author}-{plugin_name}', task_context)
- return
- elif skill_resp.status_code == 404:
- # Try plugin endpoint - get versions and download
- self.ap.logger.info(f'Trying plugin endpoint for: {plugin_author}/{plugin_name}')
- if task_context:
- task_context.set_current_action('checking plugin marketplace')
-
- # Get plugin versions to find latest
- versions_resp = await client.get(
- f'{space_url}/api/v1/marketplace/plugins/{plugin_author}/{plugin_name}/versions'
- )
- if versions_resp.status_code == 200:
- versions_data = versions_resp.json().get('data', {}).get('versions', [])
- if versions_data:
- latest_version = versions_data[0].get('version', '')
- if latest_version:
- self.ap.logger.info(
- f'Installing plugin from marketplace: {plugin_author}/{plugin_name} v{latest_version}'
- )
- if task_context:
- task_context.set_current_action('downloading plugin package')
-
- download_resp = await client.get(
- f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}'
- )
- if download_resp.status_code != 200:
- raise Exception(
- f'Failed to download plugin {plugin_author}/{plugin_name}: {download_resp.status_code}'
- )
-
- file_bytes = download_resp.content
- self._inspect_plugin_package(file_bytes, task_context)
- file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
- install_info['plugin_file_key'] = file_key
- self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
- # Continue to install via runtime
- else:
- raise Exception(f'No version found for plugin {plugin_author}/{plugin_name}')
- else:
- raise Exception(f'Plugin {plugin_author}/{plugin_name} has no versions')
- else:
- raise Exception(f'Plugin {plugin_author}/{plugin_name} not found in marketplace')
- else:
- skill_resp.raise_for_status()
- raise Exception(f'Failed to get skill {plugin_author}/{plugin_name}')
- else:
- mcp_resp.raise_for_status()
- raise Exception(f'Failed to get MCP {plugin_author}/{plugin_name}')
-
- if install_source == PluginInstallSource.LOCAL:
- # transfer file before install
- file_bytes = install_info['plugin_file']
- plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
- if task_context is not None and plugin_author and plugin_name:
- task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
- file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
- install_info['plugin_file_key'] = file_key
- del install_info['plugin_file']
- self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
+ if file_bytes is None:
+ return
+ install_info = {**install_info, 'plugin_version': version}
+ elif install_source == PluginInstallSource.LOCAL:
+ candidate = install_info.get('plugin_file')
+ if not isinstance(candidate, bytes):
+ raise ValueError('Local plugin package is missing')
+ file_bytes = candidate
elif install_source == PluginInstallSource.GITHUB:
- # download and transfer file with streaming progress
+ file_bytes = await self._download_github_package(
+ install_info,
+ task_context,
+ )
+ install_info = validate_github_plugin_install_info(install_info)
+ else:
+ raise ValueError(f'Unsupported plugin install source: {install_source.value}')
+
+ manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
+ if not manifest_author or not manifest_name:
+ raise ValueError('Plugin package manifest identity is missing')
+ if plugin_author and plugin_author != manifest_author:
+ raise ValueError('Plugin package author does not match the requested plugin')
+ if plugin_name and plugin_name != manifest_name:
+ raise ValueError('Plugin package name does not match the requested plugin')
+ plugin_author, plugin_name = manifest_author, manifest_name
+ if task_context is not None:
+ task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
+
+ artifact_digest = hashlib.sha256(file_bytes).hexdigest()
+ await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
+ try:
+ binding, previous_digest, previous_was_durable = await self._persist_installation_package(
+ execution_context,
+ plugin_author=plugin_author,
+ plugin_name=plugin_name,
+ install_source=install_source,
+ install_info=install_info,
+ artifact_digest=artifact_digest,
+ )
+ except Exception:
+ await self._delete_artifact_if_unreferenced(execution_context, artifact_digest)
+ raise
+ runtime_handler.register_installation_binding(
+ binding,
+ plugin_author=plugin_author,
+ plugin_name=plugin_name,
+ )
+ await self._apply_desired_state(
+ PluginInstallationDesiredState(binding=binding, enabled=True),
+ artifact_package=file_bytes,
+ )
+ desired = PluginInstallationDesiredState(binding=binding, enabled=True)
+ self._known_desired_states[binding.installation_uuid] = desired
+ self._workspace_installations.setdefault(binding.workspace_uuid, set()).add(binding.installation_uuid)
+ if previous_digest is not None and previous_digest != artifact_digest:
+ await self._delete_artifact_if_unreferenced(execution_context, previous_digest)
+ if previous_digest is not None and not previous_was_durable and self.runtime_profile == 'oss_dev':
+ bridge = self._legacy_oss_bridge_binding(execution_context)
try:
- async with httpx.AsyncClient(
- trust_env=True,
- follow_redirects=True,
- timeout=60,
- ) as client:
- async with client.stream('GET', install_info['asset_url']) as response:
- response.raise_for_status()
- total = int(response.headers.get('content-length', 0))
- downloaded = 0
- chunks: list[bytes] = []
- start_time = time.time()
-
- if task_context is not None:
- task_context.set_current_action('downloading plugin package')
- task_context.metadata['download_total'] = total
- task_context.metadata['download_current'] = 0
- task_context.metadata['download_speed'] = 0
-
- async for chunk in response.aiter_bytes(chunk_size=8192):
- chunks.append(chunk)
- downloaded += len(chunk)
-
- if task_context is not None:
- elapsed = time.time() - start_time
- task_context.metadata['download_current'] = downloaded
- task_context.metadata['download_total'] = total
- task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
-
- file_bytes = b''.join(chunks)
- plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
- if task_context is not None and plugin_author and plugin_name:
- task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
- file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
- install_info['plugin_file_key'] = file_key
- self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
- except Exception as e:
- self.ap.logger.error(f'Failed to download file from GitHub: {e}')
- raise Exception(f'Failed to download file from GitHub: {e}')
-
- async for ret in self._runtime_handler().install_plugin(install_source.value, install_info):
- current_action = ret.get('current_action', None)
- if current_action is not None:
- if task_context is not None:
- task_context.set_current_action(current_action)
-
- trace = ret.get('trace', None)
- if trace is not None:
- if task_context is not None:
- task_context.trace(trace)
-
- # Forward structured metadata from runtime
- metadata = ret.get('metadata', None)
- if metadata is not None and task_context is not None:
- task_context.metadata.update(metadata)
-
+ with runtime_handler.installation_scope(bridge):
+ async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name):
+ pass
+ except Exception as exc:
+ self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
async def upgrade_plugin(
@@ -680,16 +1729,17 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_name: str,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
- async for ret in self._runtime_handler().upgrade_plugin(plugin_author, plugin_name):
- current_action = ret.get('current_action', None)
- if current_action is not None:
- if task_context is not None:
- task_context.set_current_action(current_action)
-
- trace = ret.get('trace', None)
- if trace is not None:
- if task_context is not None:
- task_context.trace(trace)
+ _execution_context, setting = await self._setting_for_plugin(plugin_author, plugin_name)
+ if setting.install_source != PluginInstallSource.MARKETPLACE.value:
+ raise ValueError(f'Plugin {plugin_author}/{plugin_name} is not installed from marketplace')
+ if task_context is not None:
+ task_context.set_current_action('checking for latest version')
+ await self.install_plugin(
+ PluginInstallSource.MARKETPLACE,
+ {'plugin_author': plugin_author, 'plugin_name': plugin_name},
+ task_context=task_context,
+ )
+ return {}
async def delete_plugin(
self,
@@ -698,22 +1748,57 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
delete_data: bool = False,
task_context: taskmgr.TaskContext | None = None,
) -> dict[str, Any]:
- async for ret in self._runtime_handler().delete_plugin(plugin_author, plugin_name):
- current_action = ret.get('current_action', None)
- if current_action is not None:
- if task_context is not None:
- task_context.set_current_action(current_action)
+ runtime_handler = self._runtime_handler()
+ execution_context, setting = await self._setting_for_plugin(plugin_author, plugin_name)
+ binding = self._binding_from_setting(execution_context, setting)
+ is_legacy_oss = self.runtime_profile == 'oss_dev' and (
+ not isinstance(setting.install_info, dict)
+ or setting.install_info.get('_artifact_storage') != _PLUGIN_ARTIFACT_STORAGE_MARKER
+ )
+ if is_legacy_oss:
+ bridge = self._legacy_oss_bridge_binding(execution_context)
+ with runtime_handler.installation_scope(bridge):
+ async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name):
+ pass
+ await runtime_handler.remove_plugin_installation(binding)
+ runtime_handler.unregister_installation_binding(binding)
- trace = ret.get('trace', None)
- if trace is not None:
- if task_context is not None:
- task_context.trace(trace)
+ async def delete(execute):
+ await execute(
+ sqlalchemy.delete(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
+ .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ )
+ await self._delete_artifact_if_unreferenced(
+ execution_context,
+ setting.artifact_digest,
+ execute=execute,
+ )
+ if delete_data:
+ await execute(
+ sqlalchemy.delete(persistence_bstorage.BinaryStorage)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.owner_type == 'plugin')
+ .where(persistence_bstorage.BinaryStorage.owner == f'{plugin_author}/{plugin_name}')
+ )
- # Clean up plugin settings and binary storage if requested
- if delete_data:
- if task_context is not None:
- task_context.trace('Cleaning up plugin configuration and storage...')
- await self._runtime_handler().cleanup_plugin_data(plugin_author, plugin_name)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(execution_context.workspace_uuid) as uow:
+ await delete(uow.execute)
+ else:
+ await delete(self.ap.persistence_mgr.execute_async)
+
+ self._known_desired_states.pop(binding.installation_uuid, None)
+ workspace_installations = self._workspace_installations.get(binding.workspace_uuid)
+ if workspace_installations is not None:
+ workspace_installations.discard(binding.installation_uuid)
+ if not workspace_installations:
+ self._workspace_installations.pop(binding.workspace_uuid, None)
+ if task_context is not None:
+ task_context.set_current_action('plugin removed')
+ return {}
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
"""List plugins, optionally filtered by component kinds.
@@ -727,7 +1812,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self.is_enable_plugin or not self._runtime_available():
return []
- plugins = await self._runtime_handler().list_plugins()
+ runtime_handler = self._runtime_handler()
+ plugins: list[dict[str, Any]] = []
+ seen_plugin_ids: set[str] = set()
+ for binding in await self._operation_bindings():
+ with runtime_handler.installation_scope(binding):
+ scoped_plugins = await runtime_handler.list_plugins()
+ for plugin in scoped_plugins:
+ metadata = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {})
+ plugin_id = f'{metadata.get("author", "")}/{metadata.get("name", "")}'
+ if plugin_id not in seen_plugin_ids:
+ seen_plugin_ids.add(plugin_id)
+ plugins.append(plugin)
# Filter plugins by component kinds if specified
if component_kinds is not None:
@@ -749,35 +1845,9 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
plugin_timestamps = {}
if plugins:
- # Build list of (author, name) tuples for all plugins
- plugin_ids = []
- for plugin in plugins:
- author = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('author', '')
- name = plugin.get('manifest', {}).get('manifest', {}).get('metadata', {}).get('name', '')
- if author and name:
- plugin_ids.append((author, name))
-
- # Fetch all timestamps in a single query using OR conditions
- if plugin_ids:
- conditions = [
- sqlalchemy.and_(
- persistence_plugin.PluginSetting.plugin_author == author,
- persistence_plugin.PluginSetting.plugin_name == name,
- )
- for author, name in plugin_ids
- ]
-
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(
- persistence_plugin.PluginSetting.plugin_author,
- persistence_plugin.PluginSetting.plugin_name,
- persistence_plugin.PluginSetting.created_at,
- ).where(sqlalchemy.or_(*conditions))
- )
-
- for row in result:
- plugin_id = f'{row.plugin_author}/{row.plugin_name}'
- plugin_timestamps[plugin_id] = row.created_at
+ execution_context = await self._current_execution_context()
+ for setting in await self._load_workspace_settings(execution_context):
+ plugin_timestamps[f'{setting.plugin_author}/{setting.plugin_name}'] = setting.created_at
# Sort: debug plugins first (descending), then by created_at (descending)
def sort_key(plugin):
@@ -799,18 +1869,72 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return plugins
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
- return await self._runtime_handler().get_plugin_info(author, plugin_name)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.get_plugin_info(author, plugin_name)
async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:
- return await self._runtime_handler().set_plugin_config(plugin_author, plugin_name, config)
+ runtime_handler = self._runtime_handler()
+ execution_context, setting = await self._setting_for_plugin(plugin_author, plugin_name)
+ next_revision = setting.runtime_revision + 1
+ statement = (
+ sqlalchemy.update(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
+ .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ .where(persistence_plugin.PluginSetting.runtime_revision == setting.runtime_revision)
+ .values(config=config, runtime_revision=next_revision)
+ )
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if callable(tenant_uow):
+ async with tenant_uow(execution_context.workspace_uuid) as uow:
+ result = await uow.execute(statement)
+ else:
+ result = await self.ap.persistence_mgr.execute_async(statement)
+ if result.rowcount != 1:
+ raise RuntimeError('Plugin configuration changed concurrently')
+ binding = InstallationBinding(
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ installation_uuid=setting.installation_uuid,
+ runtime_revision=next_revision,
+ artifact_digest=setting.artifact_digest,
+ )
+ runtime_handler.register_installation_binding(
+ binding,
+ plugin_author=plugin_author,
+ plugin_name=plugin_name,
+ )
+ desired = PluginInstallationDesiredState(
+ binding=binding,
+ enabled=setting.enabled,
+ )
+ is_legacy_oss = self.runtime_profile == 'oss_dev' and (
+ not isinstance(setting.install_info, dict)
+ or setting.install_info.get('_artifact_storage') != _PLUGIN_ARTIFACT_STORAGE_MARKER
+ )
+ if is_legacy_oss:
+ bridge = self._legacy_oss_bridge_binding(execution_context)
+ with runtime_handler.installation_scope(bridge):
+ await runtime_handler.set_plugin_config(plugin_author, plugin_name, config)
+ else:
+ await self._apply_desired_state(desired)
+ self._known_desired_states[binding.installation_uuid] = desired
+ return {}
- @alru_cache(ttl=5 * 60) # 5 minutes
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
- return await self._runtime_handler().get_plugin_icon(plugin_author, plugin_name)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.get_plugin_icon(plugin_author, plugin_name)
- @alru_cache(ttl=5 * 60) # 5 minutes
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
- return await self._runtime_handler().get_plugin_readme(plugin_author, plugin_name, language)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.get_plugin_readme(plugin_author, plugin_name, language)
async def get_plugin_logs(
self,
@@ -819,12 +1943,16 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
limit: int = 200,
level: str | None = None,
) -> list[dict[str, Any]]:
- # Not cached: logs are live and change constantly.
- return await self._runtime_handler().get_plugin_logs(plugin_author, plugin_name, limit, level)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.get_plugin_logs(plugin_author, plugin_name, limit, level)
- @alru_cache(ttl=5 * 60)
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
- return await self._runtime_handler().get_plugin_assets(plugin_author, plugin_name, filepath)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.get_plugin_assets(plugin_author, plugin_name, filepath)
async def handle_page_api(
self,
@@ -835,9 +1963,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
method: str,
body: Any = None,
) -> dict[str, Any]:
- return await self._runtime_handler().handle_page_api(
- plugin_author, plugin_name, page_id, endpoint, method, body
- )
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
@@ -850,6 +1979,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
event: events.BaseEventModel,
bound_plugins: list[str] | None = None,
) -> context.EventContext:
+ query = getattr(event, 'query', None)
+ if query is not None:
+ from ..pipeline.pool import get_query_execution_context
+
+ await self.require_workspace_context(get_query_execution_context(query))
event_ctx = context.EventContext.from_event(event)
if not self.is_enable_plugin or not self._runtime_available():
@@ -857,15 +1991,20 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
event_ctx._response_sources = []
return event_ctx
- # Pass include_plugins to runtime for filtering
- event_ctx_result = await self._runtime_handler().emit_event(
- event_ctx.model_dump(serialize_as_any=False), include_plugins=bound_plugins
- )
-
- event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])
- event_ctx._emitted_plugins = event_ctx_result.get('emitted_plugins', [])
- if 'response_sources' in event_ctx_result:
- event_ctx._response_sources = event_ctx_result['response_sources']
+ runtime_handler = self._runtime_handler()
+ emitted_plugins: list[Any] = []
+ response_sources: list[dict[str, Any]] = []
+ for binding in await self._operation_bindings(include_plugins=bound_plugins):
+ with runtime_handler.installation_scope(binding):
+ result = await runtime_handler.emit_event(
+ event_ctx.model_dump(serialize_as_any=False),
+ include_plugins=bound_plugins,
+ )
+ event_ctx = context.EventContext.model_validate(result['event_context'])
+ emitted_plugins.extend(result.get('emitted_plugins', []))
+ response_sources.extend(result.get('response_sources', []))
+ event_ctx._emitted_plugins = emitted_plugins
+ event_ctx._response_sources = response_sources
return event_ctx
@@ -874,7 +2013,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self.is_enable_plugin or not self._runtime_available():
return
try:
- await self._runtime_handler().notify_plugin_diagnostic(diagnostic)
+ runtime_handler = self._runtime_handler()
+ plugin_ref = diagnostic.get('plugin') if isinstance(diagnostic, dict) else None
+ if isinstance(plugin_ref, dict):
+ author = plugin_ref.get('author') or plugin_ref.get('plugin_author')
+ name = plugin_ref.get('name') or plugin_ref.get('plugin_name')
+ if author and name:
+ binding = await self._target_binding(str(author), str(name))
+ with runtime_handler.installation_scope(binding):
+ await runtime_handler.notify_plugin_diagnostic(diagnostic)
+ return
+ for binding in await self._operation_bindings():
+ with runtime_handler.installation_scope(binding):
+ await runtime_handler.notify_plugin_diagnostic(diagnostic)
except Exception as e:
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
@@ -882,11 +2033,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self.is_enable_plugin or not self._runtime_available():
return []
- # Pass include_plugins to runtime for filtering
- list_tools_data = await self._runtime_handler().list_tools(include_plugins=bound_plugins)
-
- tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data]
-
+ runtime_handler = self._runtime_handler()
+ tools: list[ComponentManifest] = []
+ seen: set[tuple[str, str]] = set()
+ for binding in await self._operation_bindings(include_plugins=bound_plugins):
+ with runtime_handler.installation_scope(binding):
+ scoped = await runtime_handler.list_tools(include_plugins=bound_plugins)
+ for raw_tool in scoped:
+ tool = ComponentManifest.model_validate(raw_tool)
+ key = (str(tool.owner), tool.metadata.name)
+ if key not in seen:
+ seen.add(key)
+ tools.append(tool)
return tools
async def call_tool(
@@ -896,27 +2054,51 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
session: provider_session.Session,
query_id: int,
bound_plugins: list[str] | None = None,
+ query_uuid: str | None = None,
) -> dict[str, Any]:
if not self.is_enable_plugin:
return {'error': 'Tool not found: plugin system is disabled'}
-
- # Pass include_plugins to runtime for validation
- if not self._runtime_available():
- return {'error': 'Plugin runtime is temporarily unavailable'}
-
- return await self._runtime_handler().call_tool(
- tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
+ await self.require_workspace_context(
+ ExecutionContext(
+ instance_uuid=session.instance_uuid,
+ workspace_uuid=session.workspace_uuid,
+ placement_generation=session.placement_generation,
+ query_uuid=query_uuid,
+ bot_uuid=session.bot_uuid,
+ )
)
+ binding = await self._target_binding_for_component(
+ tool_name,
+ component_kind='tool',
+ include_plugins=bound_plugins,
+ )
+ runtime_handler = self._runtime_handler()
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.call_tool(
+ tool_name,
+ parameters,
+ session.model_dump(serialize_as_any=True),
+ query_id,
+ query_uuid=query_uuid,
+ include_plugins=bound_plugins,
+ )
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin or not self._runtime_available():
return []
- # Pass include_plugins to runtime for filtering
- list_commands_data = await self._runtime_handler().list_commands(include_plugins=bound_plugins)
-
- commands = [ComponentManifest.model_validate(command) for command in list_commands_data]
-
+ runtime_handler = self._runtime_handler()
+ commands: list[ComponentManifest] = []
+ seen: set[tuple[str, str]] = set()
+ for binding in await self._operation_bindings(include_plugins=bound_plugins):
+ with runtime_handler.installation_scope(binding):
+ scoped = await runtime_handler.list_commands(include_plugins=bound_plugins)
+ for raw_command in scoped:
+ command = ComponentManifest.model_validate(raw_command)
+ key = (str(command.owner), command.metadata.name)
+ if key not in seen:
+ seen.add(key)
+ commands.append(command)
return commands
async def execute_command(
@@ -926,16 +2108,27 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
return
- # Pass include_plugins to runtime for validation
- gen = self._runtime_handler().execute_command(
- command_ctx.model_dump(serialize_as_any=True),
+ await self.require_workspace_context(
+ ExecutionContext(
+ instance_uuid=command_ctx.instance_uuid,
+ workspace_uuid=command_ctx.workspace_uuid,
+ placement_generation=command_ctx.placement_generation,
+ query_uuid=command_ctx.query_uuid,
+ )
+ )
+ binding = await self._target_binding_for_component(
+ command_ctx.command,
+ component_kind='command',
include_plugins=bound_plugins,
)
-
- async for ret in gen:
- cmd_ret = command_context.CommandReturn.model_validate(ret)
-
- yield cmd_ret
+ runtime_handler = self._runtime_handler()
+ with runtime_handler.installation_scope(binding):
+ gen = runtime_handler.execute_command(
+ command_ctx.model_dump(serialize_as_any=True),
+ include_plugins=bound_plugins,
+ )
+ async for ret in gen:
+ yield command_context.CommandReturn.model_validate(ret)
async def retrieve_knowledge(
self,
@@ -948,9 +2141,15 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self.is_enable_plugin or not self._runtime_available():
return {'results': []}
- return await self._runtime_handler().retrieve_knowledge(
- plugin_author, plugin_name, retriever_name, retrieval_context
- )
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.retrieve_knowledge(
+ plugin_author,
+ plugin_name,
+ retriever_name,
+ retrieval_context,
+ )
def dispose(self):
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
@@ -1001,29 +2200,47 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
context_data: IngestionContext data.
"""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
- return await self._runtime_handler().rag_ingest_document(plugin_author, plugin_name, context_data)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.rag_ingest_document(plugin_author, plugin_name, context_data)
async def call_rag_delete_document(self, plugin_id: str, document_id: str, kb_id: str) -> bool:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
- return await self._runtime_handler().rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
async def get_rag_creation_schema(self, plugin_id: str) -> dict[str, Any]:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
- return await self._runtime_handler().get_rag_creation_schema(plugin_author, plugin_name)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.get_rag_creation_schema(plugin_author, plugin_name)
async def get_rag_retrieval_schema(self, plugin_id: str) -> dict[str, Any]:
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
- return await self._runtime_handler().get_rag_retrieval_schema(plugin_author, plugin_name)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.get_rag_retrieval_schema(plugin_author, plugin_name)
async def rag_on_kb_create(self, plugin_id: str, kb_id: str, config: dict[str, Any]) -> dict[str, Any]:
"""Notify plugin about KB creation."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
- return await self._runtime_handler().rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
async def rag_on_kb_delete(self, plugin_id: str, kb_id: str) -> dict[str, Any]:
"""Notify plugin about KB deletion."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
- return await self._runtime_handler().rag_on_kb_delete(plugin_author, plugin_name, kb_id)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.rag_on_kb_delete(plugin_author, plugin_name, kb_id)
async def call_rag_retrieve(self, plugin_id: str, retrieval_context: dict[str, Any]) -> dict[str, Any]:
"""Call plugin to retrieve knowledge.
@@ -1033,7 +2250,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
retrieval_context: RetrievalContext data.
"""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
- return await self._runtime_handler().retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
async def list_knowledge_engines(self) -> list[dict[str, Any]]:
"""List all available Knowledge Engines from plugins.
@@ -1043,15 +2263,40 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self.is_enable_plugin or not self._runtime_available():
return []
- return await self._runtime_handler().list_knowledge_engines()
+ runtime_handler = self._runtime_handler()
+ engines: list[dict[str, Any]] = []
+ seen: set[tuple[str, str]] = set()
+ for binding in await self._operation_bindings():
+ with runtime_handler.installation_scope(binding):
+ scoped = await runtime_handler.list_knowledge_engines()
+ for engine in scoped:
+ key = (str(engine.get('plugin_id', '')), str(engine.get('name', '')))
+ if key not in seen:
+ seen.add(key)
+ engines.append(engine)
+ return engines
async def list_parsers(self) -> list[dict[str, Any]]:
"""List all available parsers from plugins."""
if not self.is_enable_plugin or not self._runtime_available():
return []
- return await self._runtime_handler().list_parsers()
+ runtime_handler = self._runtime_handler()
+ parsers: list[dict[str, Any]] = []
+ seen: set[tuple[str, str]] = set()
+ for binding in await self._operation_bindings():
+ with runtime_handler.installation_scope(binding):
+ scoped = await runtime_handler.list_parsers()
+ for parser in scoped:
+ key = (str(parser.get('plugin_id', '')), str(parser.get('name', '')))
+ if key not in seen:
+ seen.add(key)
+ parsers.append(parser)
+ return parsers
async def call_parser(self, plugin_id: str, context_data: dict[str, Any], file_bytes: bytes) -> dict[str, Any]:
"""Call plugin to parse a document."""
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
- return await self._runtime_handler().parse_document(plugin_author, plugin_name, context_data, file_bytes)
+ runtime_handler = self._runtime_handler()
+ binding = await self._target_binding(plugin_author, plugin_name)
+ with runtime_handler.installation_scope(binding):
+ return await runtime_handler.parse_document(plugin_author, plugin_name, context_data, file_bytes)
diff --git a/src/langbot/pkg/plugin/github.py b/src/langbot/pkg/plugin/github.py
new file mode 100644
index 000000000..9d6f83355
--- /dev/null
+++ b/src/langbot/pkg/plugin/github.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+from urllib.parse import unquote, urlparse
+
+
+_GITHUB_OWNER_PATTERN = re.compile(r'^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$')
+_GITHUB_REPO_PATTERN = re.compile(r'^[A-Za-z0-9._-]{1,100}$')
+
+
+def _positive_github_id(value: object, field_name: str) -> int:
+ if isinstance(value, bool):
+ raise ValueError(f'{field_name} must be a positive GitHub identifier')
+ try:
+ identifier = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f'{field_name} must be a positive GitHub identifier') from exc
+ if identifier <= 0 or str(value).strip() != str(identifier):
+ raise ValueError(f'{field_name} must be a positive GitHub identifier')
+ return identifier
+
+
+def validate_github_release_asset_url(
+ asset_url: object,
+ *,
+ owner: str,
+ repo: str,
+ release_tag: str,
+) -> str:
+ """Accept only a GitHub browser release URL tied to the requested release."""
+
+ normalized_url = str(asset_url or '').strip()
+ parsed = urlparse(normalized_url)
+ try:
+ port = parsed.port
+ except ValueError as exc:
+ raise ValueError('asset_url has an invalid port') from exc
+ if (
+ parsed.scheme != 'https'
+ or (parsed.hostname or '').lower() != 'github.com'
+ or parsed.username is not None
+ or parsed.password is not None
+ or port not in {None, 443}
+ or parsed.fragment
+ ):
+ raise ValueError('asset_url must be an HTTPS GitHub release asset URL')
+ decoded_path = unquote(parsed.path)
+ expected_prefix = f'/{owner}/{repo}/releases/download/{release_tag}/'
+ if not decoded_path.casefold().startswith(
+ f'/{owner}/{repo}/releases/download/'.casefold()
+ ) or not decoded_path.startswith(expected_prefix):
+ raise ValueError('asset_url does not match the requested GitHub release')
+ if decoded_path == expected_prefix or decoded_path.endswith('/'):
+ raise ValueError('asset_url must identify a GitHub release asset')
+ return normalized_url
+
+
+def validate_github_plugin_install_info(install_info: dict[str, Any]) -> dict[str, Any]:
+ """Normalize a GitHub install request without trusting a tenant-provided URL."""
+
+ owner = str(install_info.get('owner') or '').strip()
+ repo = str(install_info.get('repo') or '').strip()
+ release_tag = str(install_info.get('release_tag') or '').strip()
+ if _GITHUB_OWNER_PATTERN.fullmatch(owner) is None:
+ raise ValueError('owner must be a valid GitHub repository owner')
+ if _GITHUB_REPO_PATTERN.fullmatch(repo) is None:
+ raise ValueError('repo must be a valid GitHub repository name')
+ if not release_tag or '\x00' in release_tag or len(release_tag) > 255:
+ raise ValueError('release_tag must identify a GitHub release')
+
+ release_id_value = install_info.get('release_id')
+ asset_id_value = install_info.get('asset_id')
+ normalized = dict(install_info)
+ normalized.update(
+ {
+ 'owner': owner,
+ 'repo': repo,
+ 'release_tag': release_tag,
+ 'github_url': f'https://github.com/{owner}/{repo}',
+ }
+ )
+ if release_id_value is not None or asset_id_value is not None:
+ if release_id_value is None or asset_id_value is None:
+ raise ValueError('release_id and asset_id must be provided together')
+ normalized['release_id'] = _positive_github_id(release_id_value, 'release_id')
+ normalized['asset_id'] = _positive_github_id(asset_id_value, 'asset_id')
+ normalized.pop('asset_url', None)
+ return normalized
+
+ normalized['asset_url'] = validate_github_release_asset_url(
+ install_info.get('asset_url'),
+ owner=owner,
+ repo=repo,
+ release_tag=release_tag,
+ )
+ return normalized
diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py
index dcfb006b5..bfc1c575f 100644
--- a/src/langbot/pkg/plugin/handler.py
+++ b/src/langbot/pkg/plugin/handler.py
@@ -1,14 +1,30 @@
from __future__ import annotations
+import asyncio
+import inspect
import typing
from typing import Any
import base64
+import contextlib
+import contextvars
import traceback
+from dataclasses import dataclass
import sqlalchemy
from langbot_plugin.runtime.io import handler
from langbot_plugin.runtime.io.connection import Connection
+from langbot_plugin.entities.io.context import (
+ ActionContext,
+ ApplyPluginInstallationRequest,
+ InstallationBinding,
+ PluginInstallationDesiredState,
+ PluginWorkerPolicy,
+ ReconcilePluginInstallationsRequest,
+ RemovePluginInstallationRequest,
+ RuntimeConfig,
+ RuntimeIdentity,
+)
from langbot_plugin.entities.io.actions.enums import (
CommonAction,
RuntimeToLangBotAction,
@@ -19,12 +35,33 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
+from ..api.http.context import ExecutionContext
from ..entity.persistence import plugin as persistence_plugin
from ..entity.persistence import bstorage as persistence_bstorage
+from ..entity.persistence import bot as persistence_bot
+from ..entity.persistence import model as persistence_model
from ..core import app
from ..utils import constants
+_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
+_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
+
+
+def _binary_storage_value_limit(ap: Any) -> int:
+ configured = (
+ ap.instance_config.data.get('plugin', {})
+ .get('binary_storage', {})
+ .get('max_value_bytes', _DEFAULT_BINARY_STORAGE_VALUE_BYTES)
+ )
+ try:
+ configured = int(configured)
+ except (TypeError, ValueError):
+ configured = _DEFAULT_BINARY_STORAGE_VALUE_BYTES
+ if configured < 0:
+ configured = _DEFAULT_BINARY_STORAGE_VALUE_BYTES
+ return min(configured, _HARD_MAX_BINARY_STORAGE_VALUE_BYTES)
+
class _RawAction:
def __init__(self, value: str):
@@ -49,11 +86,373 @@ def _make_rag_error_response(error: Exception, error_type: str, **extra_context)
return handler.ActionResponse.error(message=message)
+@dataclass(frozen=True, slots=True)
+class _PluginInstallationIdentity:
+ workspace_uuid: str
+ plugin_author: str
+ plugin_name: str
+ installation_uuid: str
+ runtime_revision: int
+ artifact_digest: str
+
+
+_UNTRUSTED_SCOPE_FIELDS = frozenset(
+ {
+ 'context',
+ 'action_context',
+ 'instance_uuid',
+ 'workspace_uuid',
+ 'placement_generation',
+ 'installation_uuid',
+ 'runtime_revision',
+ 'artifact_digest',
+ }
+)
+
+_RUNTIME_SCOPED_ACTIONS = frozenset(
+ {
+ CommonAction.FILE_CHUNK.value,
+ RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value,
+ RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value,
+ }
+)
+
+
class RuntimeConnectionHandler(handler.Handler):
"""Runtime connection handler"""
ap: app.Application
+ def validate_inbound_action_context(
+ self,
+ action: str,
+ action_context: ActionContext | None,
+ ) -> ActionContext | None:
+ """Require a complete installation tuple on every tenant action."""
+
+ if action == CommonAction.PING.value:
+ if action_context is not None:
+ raise ValueError('PING does not accept an installation binding')
+ return None
+ if isinstance(action_context, InstallationBinding):
+ return action_context
+ if self._allow_legacy_oss_context(action, action_context):
+ return action_context
+ raise ValueError(f'{action} requires a complete InstallationBinding context')
+
+ def _allow_legacy_oss_context(self, action: str, action_context: ActionContext | None) -> bool:
+ """Keep pre-v4 local plugins usable without weakening shared Runtime."""
+
+ if not (
+ getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'oss'
+ and isinstance(action_context, ActionContext)
+ and not isinstance(action_context, InstallationBinding)
+ ):
+ return False
+ if action in {
+ RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value,
+ RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value,
+ CommonAction.FILE_CHUNK.value,
+ }:
+ return True
+ # Pre-v4 OSS workers receive this capability from Core's settings
+ # response, but their pinned SDK cannot carry revision/digest fields.
+ # Shared Runtime never enters this compatibility branch.
+ return bool(action_context.installation_uuid)
+
+ def _require_runtime_action_context(self) -> ActionContext:
+ action_context = self.current_action_context
+ if action_context is None:
+ raise ValueError('Plugin Runtime action is missing a trusted Workspace context')
+ return action_context
+
+ async def _resolve_installation_identity(
+ self,
+ action_context: InstallationBinding,
+ ) -> _PluginInstallationIdentity:
+ cached = self._installation_bindings.get(action_context.installation_uuid)
+ if cached is not None:
+ cached_binding, identity = cached
+ if cached_binding != action_context:
+ raise ValueError('Plugin installation revision or artifact is stale')
+ return identity
+
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ persistence_plugin.PluginSetting.plugin_author,
+ persistence_plugin.PluginSetting.plugin_name,
+ persistence_plugin.PluginSetting.installation_uuid,
+ persistence_plugin.PluginSetting.runtime_revision,
+ persistence_plugin.PluginSetting.artifact_digest,
+ )
+ .where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.installation_uuid == action_context.installation_uuid)
+ )
+ setting = result.first()
+ if setting is None:
+ raise ValueError('Plugin installation is not registered in this Workspace')
+ if (
+ setting.runtime_revision != action_context.runtime_revision
+ or setting.artifact_digest != action_context.artifact_digest
+ ):
+ raise ValueError('Plugin installation revision or artifact is stale')
+ identity = _PluginInstallationIdentity(
+ workspace_uuid=action_context.workspace_uuid,
+ plugin_author=setting.plugin_author,
+ plugin_name=setting.plugin_name,
+ installation_uuid=setting.installation_uuid,
+ runtime_revision=setting.runtime_revision,
+ artifact_digest=setting.artifact_digest,
+ )
+ self._installation_bindings[action_context.installation_uuid] = (action_context, identity)
+ return identity
+
+ async def _resolve_legacy_oss_installation_identity(
+ self,
+ action_context: ActionContext,
+ ) -> _PluginInstallationIdentity:
+ if not action_context.installation_uuid:
+ raise ValueError('Legacy OSS plugin action is missing its installation capability')
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ persistence_plugin.PluginSetting.plugin_author,
+ persistence_plugin.PluginSetting.plugin_name,
+ persistence_plugin.PluginSetting.installation_uuid,
+ persistence_plugin.PluginSetting.runtime_revision,
+ persistence_plugin.PluginSetting.artifact_digest,
+ )
+ .where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.installation_uuid == action_context.installation_uuid)
+ )
+ setting = result.first()
+ if setting is None:
+ raise ValueError('Plugin installation is not registered in this Workspace')
+ return _PluginInstallationIdentity(
+ workspace_uuid=action_context.workspace_uuid,
+ plugin_author=setting.plugin_author,
+ plugin_name=setting.plugin_name,
+ installation_uuid=setting.installation_uuid,
+ runtime_revision=setting.runtime_revision,
+ artifact_digest=setting.artifact_digest,
+ )
+
+ async def _require_plugin_action_context(
+ self,
+ ) -> tuple[ActionContext, _PluginInstallationIdentity]:
+ action_context = self._require_runtime_action_context()
+ if isinstance(action_context, InstallationBinding):
+ identity = await self._resolve_installation_identity(action_context)
+ elif self._allow_legacy_oss_context('plugin_action', action_context):
+ identity = await self._resolve_legacy_oss_installation_identity(action_context)
+ else:
+ raise ValueError('Plugin action requires a complete InstallationBinding context')
+ return action_context, identity
+
+ async def _require_active_action_context(self, action_context: ActionContext) -> None:
+ """Fence stale Runtime generations against Core's active projection."""
+
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ if workspace_service is None:
+ raise ValueError('Workspace execution service is unavailable')
+ binding = await workspace_service.get_execution_binding(
+ action_context.workspace_uuid,
+ expected_generation=action_context.placement_generation,
+ )
+ if (
+ binding.instance_uuid != action_context.instance_uuid
+ or binding.workspace_uuid != action_context.workspace_uuid
+ or binding.placement_generation != action_context.placement_generation
+ ):
+ raise ValueError('Plugin Runtime action uses a stale Workspace execution binding')
+
+ @contextlib.asynccontextmanager
+ async def _tenant_action_scope(self, action_context: ActionContext):
+ """Bind Runtime-origin work to the trusted Workspace database scope.
+
+ Cloud persistence deliberately rejects unscoped access and PostgreSQL
+ RLS reads the Workspace id from each short database transaction. The
+ wire envelope is validated before this helper is entered, so plugin
+ payload fields can never select the scope. A transaction-free boundary
+ avoids reserving one pooled connection across provider and network waits.
+ """
+
+ persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
+ if persistence_mgr is None:
+ yield
+ return
+ tenant_scope_descriptor = getattr(type(persistence_mgr), 'tenant_scope', None)
+ if not callable(tenant_scope_descriptor):
+ # Lightweight test doubles and older OSS persistence managers do
+ # not expose the transaction-free scope API.
+ yield
+ return
+ async with persistence_mgr.tenant_scope(action_context.workspace_uuid):
+ yield
+
+ def _secure_plugin_actions(self) -> None:
+ """Wrap plugin-origin actions with installation validation and payload scrubbing."""
+
+ for action_name, action_handler in list(self.actions.items()):
+ if action_name == CommonAction.PING.value:
+ continue
+
+ async def secured_action(
+ data: dict[str, Any],
+ *,
+ _action_handler=action_handler,
+ _runtime_scoped=action_name in _RUNTIME_SCOPED_ACTIONS,
+ ) -> handler.ActionResponse:
+ action_context = self._require_runtime_action_context()
+ async with self._tenant_action_scope(action_context):
+ if not _runtime_scoped:
+ action_context, _ = await self._require_plugin_action_context()
+ await self._require_active_action_context(action_context)
+ safe_data = {key: value for key, value in data.items() if key not in _UNTRUSTED_SCOPE_FIELDS}
+ response = _action_handler(safe_data)
+ if inspect.isawaitable(response):
+ response = await response
+ return response
+
+ self.actions[action_name] = secured_action
+
+ async def _get_plugin_setting(
+ self,
+ action_context: ActionContext,
+ identity: _PluginInstallationIdentity,
+ ):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.plugin_author == identity.plugin_author)
+ .where(persistence_plugin.PluginSetting.plugin_name == identity.plugin_name)
+ )
+ setting = result.first()
+ if setting is None:
+ raise ValueError('Plugin installation setting was not found')
+ return setting
+
+ async def _get_plugin_setting_by_name(
+ self,
+ action_context: ActionContext,
+ plugin_author: str,
+ plugin_name: str,
+ ):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
+ .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ )
+ return result.first()
+
+ @staticmethod
+ def _require_setting_binding(setting, action_context: InstallationBinding) -> None:
+ if setting is None:
+ raise ValueError('Plugin installation setting was not found')
+ if (
+ setting.installation_uuid != action_context.installation_uuid
+ or setting.runtime_revision != action_context.runtime_revision
+ or setting.artifact_digest != action_context.artifact_digest
+ ):
+ raise ValueError('Plugin installation binding does not match the active setting')
+
+ async def _resolve_query(
+ self,
+ data: dict[str, Any],
+ action_context: ActionContext,
+ ):
+ query_uuid = data.get('query_uuid')
+ if query_uuid is not None:
+ query = await self.ap.query_pool.get_query(
+ action_context.workspace_uuid,
+ query_uuid,
+ )
+ else:
+ query = await self.ap.query_pool.get_query_by_legacy_id(
+ action_context.workspace_uuid,
+ data['query_id'],
+ )
+
+ if query is None:
+ return None
+ if (
+ getattr(query, 'instance_uuid', None) != action_context.instance_uuid
+ or getattr(query, 'workspace_uuid', None) != action_context.workspace_uuid
+ or getattr(query, 'placement_generation', None) != action_context.placement_generation
+ ):
+ return None
+ return query
+
+ async def _resource_exists(
+ self,
+ model,
+ uuid_column,
+ resource_uuid: str,
+ workspace_uuid: str,
+ ) -> bool:
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(uuid_column)
+ .select_from(model)
+ .where(model.workspace_uuid == workspace_uuid)
+ .where(uuid_column == resource_uuid)
+ .limit(1)
+ )
+ return result.first() is not None
+
+ @staticmethod
+ def _config_contains_file_key(value: Any, file_key: str) -> bool:
+ if isinstance(value, dict):
+ return any(RuntimeConnectionHandler._config_contains_file_key(item, file_key) for item in value.values())
+ if isinstance(value, list):
+ return any(RuntimeConnectionHandler._config_contains_file_key(item, file_key) for item in value)
+ return isinstance(value, str) and value == file_key
+
+ @staticmethod
+ def _execution_context(action_context: ActionContext) -> ExecutionContext:
+ """Project the trusted wire binding into Core's runtime context."""
+
+ return ExecutionContext(
+ instance_uuid=action_context.instance_uuid,
+ workspace_uuid=action_context.workspace_uuid,
+ placement_generation=action_context.placement_generation,
+ )
+
+ @staticmethod
+ def _binary_storage_owner(
+ action_context: ActionContext,
+ identity: _PluginInstallationIdentity,
+ owner_type: str,
+ ) -> str:
+ """Resolve storage ownership exclusively from the trusted binding."""
+
+ if owner_type == 'workspace':
+ return action_context.workspace_uuid
+ if owner_type == 'plugin':
+ return f'{identity.plugin_author}/{identity.plugin_name}'
+ raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
+
+ @classmethod
+ def _binary_storage_key(
+ cls,
+ action_context: ActionContext,
+ *,
+ owner_type: str,
+ owner: str,
+ key: str,
+ ) -> str:
+ """Use Core's canonical key across every persistent owner dimension."""
+
+ # Import lazily: StorageMgr references the Application type, whose
+ # module wires the plugin connector during startup.
+ from ..storage.mgr import StorageMgr
+
+ return StorageMgr.canonical_binary_storage_key(
+ cls._execution_context(action_context),
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ )
+
def __init__(
self,
connection: Connection,
@@ -62,10 +461,21 @@ class RuntimeConnectionHandler(handler.Handler):
):
super().__init__(connection, disconnect_callback)
self.ap = ap
+ self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
+ contextvars.ContextVar(
+ f'{self.__class__.__name__}_{id(self)}_outbound_installation',
+ default=None,
+ )
+ )
+ self._installation_bindings: dict[
+ str,
+ tuple[InstallationBinding, _PluginInstallationIdentity],
+ ] = {}
@self.action(RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS)
async def initialize_plugin_settings(data: dict[str, Any]) -> handler.ActionResponse:
"""Initialize plugin settings"""
+ action_context = self._require_runtime_action_context()
# check if exists plugin setting
plugin_author = data['plugin_author']
plugin_name = data['plugin_name']
@@ -73,36 +483,37 @@ class RuntimeConnectionHandler(handler.Handler):
install_info = data['install_info']
try:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_plugin.PluginSetting)
- .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
- .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ setting = await self._get_plugin_setting_by_name(
+ action_context,
+ plugin_author,
+ plugin_name,
)
-
- setting = result.first()
-
- if setting is not None:
- # delete plugin setting
+ if isinstance(action_context, InstallationBinding):
+ self._require_setting_binding(setting, action_context)
+ elif setting is None:
+ # OSS debug and pre-v4 data/plugins are the only callers
+ # allowed to create settings without a desired-state row.
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_plugin.PluginSetting)
+ sqlalchemy.insert(persistence_plugin.PluginSetting).values(
+ workspace_uuid=action_context.workspace_uuid,
+ plugin_author=plugin_author,
+ plugin_name=plugin_name,
+ install_source=install_source,
+ install_info=install_info,
+ enabled=True,
+ priority=0,
+ config={},
+ )
+ )
+ else:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ .values(install_source=install_source, install_info=install_info)
)
- # create plugin setting
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(persistence_plugin.PluginSetting).values(
- plugin_author=plugin_author,
- plugin_name=plugin_name,
- install_source=install_source,
- install_info=install_info,
- # inherit from existing setting
- enabled=setting.enabled if setting is not None else True,
- priority=setting.priority if setting is not None else 0,
- config=setting.config if setting is not None else {}, # noqa: F821
- )
- )
-
return handler.ActionResponse.success(
data={},
)
@@ -116,14 +527,17 @@ class RuntimeConnectionHandler(handler.Handler):
async def get_plugin_settings(data: dict[str, Any]) -> handler.ActionResponse:
"""Get plugin settings"""
+ action_context = self._require_runtime_action_context()
plugin_author = data['plugin_author']
plugin_name = data['plugin_name']
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_plugin.PluginSetting)
- .where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
- .where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
+ setting = await self._get_plugin_setting_by_name(
+ action_context,
+ plugin_author,
+ plugin_name,
)
+ if isinstance(action_context, InstallationBinding):
+ self._require_setting_binding(setting, action_context)
data = {
'enabled': True,
@@ -131,16 +545,20 @@ class RuntimeConnectionHandler(handler.Handler):
'plugin_config': {},
'install_source': 'local',
'install_info': {},
+ 'installation_uuid': None,
+ 'runtime_revision': None,
+ 'artifact_digest': None,
}
- setting = result.first()
-
if setting is not None:
data['enabled'] = setting.enabled
data['priority'] = setting.priority
data['plugin_config'] = setting.config
data['install_source'] = setting.install_source
data['install_info'] = setting.install_info
+ data['installation_uuid'] = setting.installation_uuid
+ data['runtime_revision'] = setting.runtime_revision
+ data['artifact_digest'] = setting.artifact_digest
return handler.ActionResponse.success(
data=data,
@@ -149,17 +567,17 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.REPLY_MESSAGE)
async def reply_message(data: dict[str, Any]) -> handler.ActionResponse:
"""Reply message"""
+ action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
message_chain = data['message_chain']
quote_origin = data['quote_origin']
- if query_id not in self.ap.query_pool.cached_queries:
+ query = await self._resolve_query(data, action_context)
+ if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
- query = self.ap.query_pool.cached_queries[query_id]
-
message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
self.ap.logger.debug(f'Reply message: {message_chain_obj.model_dump(serialize_as_any=False)}')
@@ -177,14 +595,14 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_BOT_UUID)
async def get_bot_uuid(data: dict[str, Any]) -> handler.ActionResponse:
"""Get bot uuid"""
+ action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
- if query_id not in self.ap.query_pool.cached_queries:
+ query = await self._resolve_query(data, action_context)
+ if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
- query = self.ap.query_pool.cached_queries[query_id]
-
return handler.ActionResponse.success(
data={
'bot_uuid': query.bot_uuid,
@@ -194,17 +612,17 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.SET_QUERY_VAR)
async def set_query_var(data: dict[str, Any]) -> handler.ActionResponse:
"""Set query var"""
+ action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
key = data['key']
value = data['value']
- if query_id not in self.ap.query_pool.cached_queries:
+ query = await self._resolve_query(data, action_context)
+ if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
- query = self.ap.query_pool.cached_queries[query_id]
-
query.variables[key] = value
return handler.ActionResponse.success(
@@ -214,16 +632,16 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_QUERY_VAR)
async def get_query_var(data: dict[str, Any]) -> handler.ActionResponse:
"""Get query var"""
+ action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
key = data['key']
- if query_id not in self.ap.query_pool.cached_queries:
+ query = await self._resolve_query(data, action_context)
+ if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
- query = self.ap.query_pool.cached_queries[query_id]
-
return handler.ActionResponse.success(
data={
'value': query.variables[key],
@@ -233,14 +651,14 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_QUERY_VARS)
async def get_query_vars(data: dict[str, Any]) -> handler.ActionResponse:
"""Get query vars"""
+ action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
- if query_id not in self.ap.query_pool.cached_queries:
+ query = await self._resolve_query(data, action_context)
+ if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
- query = self.ap.query_pool.cached_queries[query_id]
-
return handler.ActionResponse.success(
data={
'vars': query.variables,
@@ -250,14 +668,14 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.CREATE_NEW_CONVERSATION)
async def create_new_conversation(data: dict[str, Any]) -> handler.ActionResponse:
"""Create new conversation"""
+ action_context, _ = await self._require_plugin_action_context()
query_id = data['query_id']
- if query_id not in self.ap.query_pool.cached_queries:
+ query = await self._resolve_query(data, action_context)
+ if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
- query = self.ap.query_pool.cached_queries[query_id]
-
query.session.using_conversation = None
return handler.ActionResponse.success(
@@ -276,7 +694,20 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_BOTS)
async def get_bots(data: dict[str, Any]) -> handler.ActionResponse:
"""Get bots"""
- bots = await self.ap.bot_service.get_bots(include_secret=False)
+ action_context, _ = await self._require_plugin_action_context()
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_bot.Bot).where(
+ persistence_bot.Bot.workspace_uuid == action_context.workspace_uuid
+ )
+ )
+ bots = [
+ self.ap.persistence_mgr.serialize_model(
+ persistence_bot.Bot,
+ bot,
+ ['adapter_config'],
+ )
+ for bot in result.all()
+ ]
return handler.ActionResponse.success(
data={
'bots': bots,
@@ -286,8 +717,23 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_BOT_INFO)
async def get_bot_info(data: dict[str, Any]) -> handler.ActionResponse:
"""Get bot info"""
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
bot_uuid = data['bot_uuid']
- bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid, include_secret=False)
+ if not await self._resource_exists(
+ persistence_bot.Bot,
+ persistence_bot.Bot.uuid,
+ bot_uuid,
+ action_context.workspace_uuid,
+ ):
+ return handler.ActionResponse.error(
+ message=f'Bot with bot_uuid {bot_uuid} not found',
+ )
+ bot = await self.ap.bot_service.get_runtime_bot_info(
+ execution_context,
+ bot_uuid,
+ include_secret=False,
+ )
return handler.ActionResponse.success(
data={
'bot': bot,
@@ -297,15 +743,30 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.SEND_MESSAGE)
async def send_message(data: dict[str, Any]) -> handler.ActionResponse:
"""Send message"""
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
bot_uuid = data['bot_uuid']
target_type = data['target_type']
target_id = data['target_id']
message_chain = data['message_chain']
+ if not await self._resource_exists(
+ persistence_bot.Bot,
+ persistence_bot.Bot.uuid,
+ bot_uuid,
+ action_context.workspace_uuid,
+ ):
+ return handler.ActionResponse.error(
+ message=f'Bot with bot_uuid {bot_uuid} not found',
+ )
+
# Use custom deserializer that properly handles Forward messages
message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
- bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
+ bot = await self.ap.platform_mgr.get_bot_by_uuid(
+ execution_context,
+ bot_uuid,
+ )
if bot is None:
return handler.ActionResponse.error(
message=f'Bot with bot_uuid {bot_uuid} not found',
@@ -324,23 +785,52 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
"""Get llm models, returns list of UUID strings"""
- llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
+ action_context, _ = await self._require_plugin_action_context()
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_model.LLMModel.uuid).where(
+ persistence_model.LLMModel.workspace_uuid == action_context.workspace_uuid
+ )
+ )
return handler.ActionResponse.success(
data={
- 'llm_models': [m['uuid'] for m in llm_models],
+ 'llm_models': list(result.scalars().all()),
},
)
@self.action(PluginToRuntimeAction.INVOKE_LLM)
async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse:
"""Invoke llm"""
+ action_context, _ = await self._require_plugin_action_context()
llm_model_uuid = data['llm_model_uuid']
messages = data['messages']
funcs = data.get('funcs', [])
extra_args = data.get('extra_args', {})
- llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid)
- if llm_model is None:
+ if not await self._resource_exists(
+ persistence_model.LLMModel,
+ persistence_model.LLMModel.uuid,
+ llm_model_uuid,
+ action_context.workspace_uuid,
+ ):
+ return handler.ActionResponse.error(
+ message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
+ )
+ try:
+ execution_context = self._execution_context(action_context)
+ llm_model = await self.ap.model_mgr.get_model_by_uuid(
+ execution_context,
+ llm_model_uuid,
+ )
+ except ValueError:
+ return handler.ActionResponse.error(
+ message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
+ )
+ runtime_workspace_uuid = getattr(
+ getattr(llm_model, 'model_entity', None),
+ 'workspace_uuid',
+ None,
+ )
+ if runtime_workspace_uuid not in (None, action_context.workspace_uuid):
return handler.ActionResponse.error(
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
@@ -361,6 +851,7 @@ class RuntimeConnectionHandler(handler.Handler):
messages=messages_obj,
funcs=funcs_obj,
extra_args=extra_args,
+ execution_context=execution_context,
)
return handler.ActionResponse.success(
@@ -372,46 +863,52 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(RuntimeToLangBotAction.SET_BINARY_STORAGE)
async def set_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:
"""Set binary storage"""
+ action_context, identity = await self._require_plugin_action_context()
key = data['key']
owner_type = data['owner_type']
- owner = data['owner']
- value = base64.b64decode(data['value_base64'])
- max_value_bytes = (
- self.ap.instance_config.data.get('plugin', {})
- .get('binary_storage', {})
- .get(
- 'max_value_bytes',
- 10 * 1024 * 1024,
- )
- )
try:
- max_value_bytes = int(max_value_bytes)
- except (TypeError, ValueError):
- max_value_bytes = 10 * 1024 * 1024
- if max_value_bytes >= 0 and len(value) > max_value_bytes:
+ owner = self._binary_storage_owner(action_context, identity, owner_type)
+ unique_key = self._binary_storage_key(
+ action_context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ )
+ except ValueError as e:
+ return handler.ActionResponse.error(
+ message=str(e),
+ )
+ max_value_bytes = _binary_storage_value_limit(self.ap)
+ encoded_value = data['value_base64']
+ max_encoded_chars = 4 * ((max_value_bytes + 2) // 3) + 4
+ if len(encoded_value) > max_encoded_chars:
+ return handler.ActionResponse.error(
+ message=f'Binary storage value exceeds the {max_value_bytes}-byte limit',
+ )
+ value = await asyncio.to_thread(base64.b64decode, encoded_value)
+ if len(value) > max_value_bytes:
return handler.ActionResponse.error(
message=f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)',
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
- .where(persistence_bstorage.BinaryStorage.key == key)
- .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
- .where(persistence_bstorage.BinaryStorage.owner == owner)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
if result.first() is not None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
- .where(persistence_bstorage.BinaryStorage.key == key)
- .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
- .where(persistence_bstorage.BinaryStorage.owner == owner)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.values(value=value)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
- unique_key=f'{owner_type}:{owner}:{key}',
+ workspace_uuid=action_context.workspace_uuid,
+ unique_key=unique_key,
key=key,
owner_type=owner_type,
owner=owner,
@@ -426,15 +923,26 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE)
async def get_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:
"""Get binary storage"""
+ action_context, identity = await self._require_plugin_action_context()
key = data['key']
owner_type = data['owner_type']
- owner = data['owner']
+ try:
+ owner = self._binary_storage_owner(action_context, identity, owner_type)
+ unique_key = self._binary_storage_key(
+ action_context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ )
+ except ValueError as e:
+ return handler.ActionResponse.error(
+ message=str(e),
+ )
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
- .where(persistence_bstorage.BinaryStorage.key == key)
- .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
- .where(persistence_bstorage.BinaryStorage.owner == owner)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
storage = result.first()
@@ -442,25 +950,41 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.error(
message=f'Storage with key {key} not found',
)
+ max_value_bytes = _binary_storage_value_limit(self.ap)
+ if len(storage.value) > max_value_bytes:
+ return handler.ActionResponse.error(
+ message=f'Binary storage value exceeds the {max_value_bytes}-byte limit',
+ )
return handler.ActionResponse.success(
data={
- 'value_base64': base64.b64encode(storage.value).decode('utf-8'),
+ 'value_base64': (await asyncio.to_thread(base64.b64encode, storage.value)).decode('utf-8'),
},
)
@self.action(RuntimeToLangBotAction.DELETE_BINARY_STORAGE)
async def delete_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:
"""Delete binary storage"""
+ action_context, identity = await self._require_plugin_action_context()
key = data['key']
owner_type = data['owner_type']
- owner = data['owner']
+ try:
+ owner = self._binary_storage_owner(action_context, identity, owner_type)
+ unique_key = self._binary_storage_key(
+ action_context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ )
+ except ValueError as e:
+ return handler.ActionResponse.error(
+ message=str(e),
+ )
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
- .where(persistence_bstorage.BinaryStorage.key == key)
- .where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
- .where(persistence_bstorage.BinaryStorage.owner == owner)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
+ .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
return handler.ActionResponse.success(
@@ -470,11 +994,18 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS)
async def get_binary_storage_keys(data: dict[str, Any]) -> handler.ActionResponse:
"""Get binary storage keys"""
+ action_context, identity = await self._require_plugin_action_context()
owner_type = data['owner_type']
- owner = data['owner']
+ try:
+ owner = self._binary_storage_owner(action_context, identity, owner_type)
+ except ValueError as e:
+ return handler.ActionResponse.error(
+ message=str(e),
+ )
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage.key)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
@@ -488,15 +1019,26 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_CONFIG_FILE)
async def get_config_file(data: dict[str, Any]) -> handler.ActionResponse:
"""Get a config file by file key"""
+ action_context, identity = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
file_key = data['file_key']
try:
- # Load file from storage
- file_bytes = await self.ap.storage_mgr.storage_provider.load(file_key)
+ setting = await self._get_plugin_setting(action_context, identity)
+ if not self._config_contains_file_key(setting.config, file_key):
+ raise ValueError('Config file does not belong to this plugin installation')
+ # The persisted config is user-controlled and therefore cannot
+ # turn an arbitrary opaque object key into authority. Validate
+ # every trusted scope dimension before touching the provider.
+ file_bytes = await self.ap.storage_mgr.load_scoped_object_key(
+ execution_context,
+ file_key,
+ expected_owner_type='plugin_config',
+ )
return handler.ActionResponse.success(
data={
- 'file_base64': base64.b64encode(file_bytes).decode('utf-8'),
+ 'file_base64': (await asyncio.to_thread(base64.b64encode, file_bytes)).decode('utf-8'),
},
)
except Exception as e:
@@ -508,35 +1050,86 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.INVOKE_EMBEDDING)
async def invoke_embedding(data: dict[str, Any]) -> handler.ActionResponse:
+ action_context, _ = await self._require_plugin_action_context()
embedding_model_uuid = data['embedding_model_uuid']
texts = data['texts']
- embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(embedding_model_uuid)
- if embedding_model is None:
+ if not await self._resource_exists(
+ persistence_model.EmbeddingModel,
+ persistence_model.EmbeddingModel.uuid,
+ embedding_model_uuid,
+ action_context.workspace_uuid,
+ ):
+ return handler.ActionResponse.error(
+ message=f'Embedding model with embedding_model_uuid {embedding_model_uuid} not found',
+ )
+ try:
+ execution_context = self._execution_context(action_context)
+ embedding_model = await self.ap.model_mgr.get_embedding_model_by_uuid(
+ execution_context,
+ embedding_model_uuid,
+ )
+ except ValueError:
+ return handler.ActionResponse.error(
+ message=f'Embedding model with embedding_model_uuid {embedding_model_uuid} not found',
+ )
+ runtime_workspace_uuid = getattr(
+ getattr(embedding_model, 'model_entity', None),
+ 'workspace_uuid',
+ None,
+ )
+ if runtime_workspace_uuid not in (None, action_context.workspace_uuid):
return handler.ActionResponse.error(
message=f'Embedding model with embedding_model_uuid {embedding_model_uuid} not found',
)
try:
- vectors = await embedding_model.provider.invoke_embedding(embedding_model, texts)
+ vectors = await embedding_model.provider.invoke_embedding(
+ embedding_model,
+ texts,
+ execution_context=execution_context,
+ )
return handler.ActionResponse.success(data={'vectors': vectors})
except Exception as e:
return _make_rag_error_response(e, 'EmbeddingError', embedding_model_uuid=embedding_model_uuid)
@self.action(PluginToRuntimeAction.INVOKE_RERANK)
async def invoke_rerank(data: dict[str, Any]) -> handler.ActionResponse:
+ action_context, _ = await self._require_plugin_action_context()
rerank_model_uuid = data['rerank_model_uuid']
query = data['query']
documents = data['documents']
top_k = data.get('top_k')
extra_args = data.get('extra_args', {})
+ if not await self._resource_exists(
+ persistence_model.RerankModel,
+ persistence_model.RerankModel.uuid,
+ rerank_model_uuid,
+ action_context.workspace_uuid,
+ ):
+ return handler.ActionResponse.error(
+ message=f'Rerank model with rerank_model_uuid {rerank_model_uuid} not found',
+ )
try:
- rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
+ execution_context = self._execution_context(action_context)
+ rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(
+ execution_context,
+ rerank_model_uuid,
+ )
except ValueError:
return handler.ActionResponse.error(
message=f'Rerank model with rerank_model_uuid {rerank_model_uuid} not found',
)
+ runtime_workspace_uuid = getattr(
+ getattr(rerank_model, 'model_entity', None),
+ 'workspace_uuid',
+ None,
+ )
+ if runtime_workspace_uuid not in (None, action_context.workspace_uuid):
+ return handler.ActionResponse.error(
+ message=f'Rerank model with rerank_model_uuid {rerank_model_uuid} not found',
+ )
try:
scores = await rerank_model.provider.invoke_rerank(
@@ -544,6 +1137,7 @@ class RuntimeConnectionHandler(handler.Handler):
query=query,
documents=documents[:64],
extra_args=extra_args,
+ execution_context=execution_context,
)
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
if top_k is not None:
@@ -554,6 +1148,8 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.VECTOR_UPSERT)
async def vector_upsert(data: dict[str, Any]) -> handler.ActionResponse:
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
collection_id = data['collection_id']
vectors = data['vectors']
ids = data['ids']
@@ -567,6 +1163,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.error(message='documents must match vectors length')
try:
await self.ap.rag_runtime_service.vector_upsert(
+ execution_context,
collection_id,
vectors,
ids,
@@ -575,10 +1172,16 @@ class RuntimeConnectionHandler(handler.Handler):
)
return handler.ActionResponse.success(data={})
except Exception as e:
- return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
+ return _make_rag_error_response(
+ e,
+ 'VectorStoreError',
+ collection_id=collection_id,
+ )
@self.action(PluginToRuntimeAction.VECTOR_SEARCH)
async def vector_search(data: dict[str, Any]) -> handler.ActionResponse:
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
collection_id = data['collection_id']
query_vector = data['query_vector']
top_k = data['top_k']
@@ -588,6 +1191,7 @@ class RuntimeConnectionHandler(handler.Handler):
vector_weight = data.get('vector_weight')
try:
results = await self.ap.rag_runtime_service.vector_search(
+ execution_context,
collection_id,
query_vector,
top_k,
@@ -598,36 +1202,68 @@ class RuntimeConnectionHandler(handler.Handler):
)
return handler.ActionResponse.success(data={'results': results})
except Exception as e:
- return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
+ return _make_rag_error_response(
+ e,
+ 'VectorStoreError',
+ collection_id=collection_id,
+ )
@self.action(PluginToRuntimeAction.VECTOR_DELETE)
async def vector_delete(data: dict[str, Any]) -> handler.ActionResponse:
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
collection_id = data['collection_id']
file_ids = data.get('file_ids')
filters = data.get('filters')
try:
- count = await self.ap.rag_runtime_service.vector_delete(collection_id, file_ids, filters)
+ count = await self.ap.rag_runtime_service.vector_delete(
+ execution_context,
+ collection_id,
+ file_ids,
+ filters,
+ )
return handler.ActionResponse.success(data={'count': count})
except Exception as e:
- return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
+ return _make_rag_error_response(
+ e,
+ 'VectorStoreError',
+ collection_id=collection_id,
+ )
@self.action(PluginToRuntimeAction.VECTOR_LIST)
async def vector_list(data: dict[str, Any]) -> handler.ActionResponse:
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
collection_id = data['collection_id']
filters = data.get('filters')
limit = data.get('limit', 20)
offset = data.get('offset', 0)
try:
- items, total = await self.ap.rag_runtime_service.vector_list(collection_id, filters, limit, offset)
+ items, total = await self.ap.rag_runtime_service.vector_list(
+ execution_context,
+ collection_id,
+ filters,
+ limit,
+ offset,
+ )
return handler.ActionResponse.success(data={'items': items, 'total': total})
except Exception as e:
- return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
+ return _make_rag_error_response(
+ e,
+ 'VectorStoreError',
+ collection_id=collection_id,
+ )
@self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM)
async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse:
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
storage_path = data['storage_path']
try:
- content_bytes = await self.ap.rag_runtime_service.get_file_stream(storage_path)
+ content_bytes = await self.ap.rag_runtime_service.get_file_stream(
+ execution_context,
+ storage_path,
+ )
file_key = await self.send_file(content_bytes, '')
return handler.ActionResponse.success(data={'file_key': file_key})
except Exception as e:
@@ -636,9 +1272,14 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.LIST_PARSERS)
async def list_parsers(data: dict[str, Any]) -> handler.ActionResponse:
"""Plugin requests host to list available parser plugins."""
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
mime_type = data.get('mime_type')
try:
- parsers = await self.ap.knowledge_service.list_parsers(mime_type)
+ parsers = await self.ap.knowledge_service.list_parsers(
+ execution_context,
+ mime_type,
+ )
return handler.ActionResponse.success(data={'parsers': parsers})
except Exception as e:
return _make_rag_error_response(e, 'ParserDiscoveryError', mime_type=mime_type)
@@ -646,6 +1287,8 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.INVOKE_PARSER)
async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:
"""Plugin requests host to invoke a parser plugin."""
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
plugin_author = data['plugin_author']
plugin_name = data['plugin_name']
storage_path = data['storage_path']
@@ -654,12 +1297,16 @@ class RuntimeConnectionHandler(handler.Handler):
metadata = data.get('metadata', {})
try:
# Read file from storage
- file_bytes = await self.ap.rag_runtime_service.get_file_stream(storage_path)
+ file_bytes = await self.ap.rag_runtime_service.get_file_stream(
+ execution_context,
+ storage_path,
+ )
context_data = {
'mime_type': mime_type,
'filename': filename,
'metadata': metadata,
}
+ await self.ap.plugin_connector.require_workspace_context(execution_context)
result = await self.ap.plugin_connector.call_parser(
f'{plugin_author}/{plugin_name}', context_data, file_bytes
)
@@ -671,27 +1318,34 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.LIST_KNOWLEDGE_BASES)
async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
- """List all knowledge bases available in the LangBot instance (unrestricted)."""
- knowledge_bases = []
- for kb_uuid, kb in self.ap.rag_mgr.knowledge_bases.items():
- knowledge_bases.append(
- {
- 'uuid': kb.get_uuid(),
- 'name': kb.get_name(),
- 'description': kb.knowledge_base_entity.description or '',
- }
- )
+ """List knowledge bases visible to the bound Workspace."""
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
+ details = await self.ap.rag_mgr.get_all_knowledge_base_details(execution_context)
+ knowledge_bases = [
+ {
+ 'uuid': kb['uuid'],
+ 'name': kb['name'],
+ 'description': kb.get('description') or '',
+ }
+ for kb in details
+ ]
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
- """Retrieve documents from any knowledge base (unrestricted)."""
+ """Retrieve documents from a knowledge base in the bound Workspace."""
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
- kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
+ kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(
+ execution_context,
+ kb_id,
+ )
if not kb:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} not found',
@@ -699,6 +1353,7 @@ class RuntimeConnectionHandler(handler.Handler):
try:
entries = await kb.retrieve(
+ execution_context,
query_text,
settings={
'top_k': top_k,
@@ -713,15 +1368,16 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
"""List knowledge bases configured for the current query's pipeline."""
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
query_id = data['query_id']
- if query_id not in self.ap.query_pool.cached_queries:
+ query = await self._resolve_query(data, action_context)
+ if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
- query = self.ap.query_pool.cached_queries[query_id]
-
kb_uuids = []
if query.pipeline_config:
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
@@ -734,7 +1390,10 @@ class RuntimeConnectionHandler(handler.Handler):
knowledge_bases = []
for kb_uuid in kb_uuids:
- kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
+ kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(
+ execution_context,
+ kb_uuid,
+ )
if kb:
knowledge_bases.append(
{
@@ -749,19 +1408,20 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)
async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse:
"""Retrieve documents from a knowledge base within the pipeline's scope."""
+ action_context, _ = await self._require_plugin_action_context()
+ execution_context = self._execution_context(action_context)
query_id = data['query_id']
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
- if query_id not in self.ap.query_pool.cached_queries:
+ query = await self._resolve_query(data, action_context)
+ if query is None:
return handler.ActionResponse.error(
message=f'Query with query_id {query_id} not found',
)
- query = self.ap.query_pool.cached_queries[query_id]
-
# Validate kb_id is in pipeline's allowed list
allowed_kb_uuids = []
if query.pipeline_config:
@@ -777,7 +1437,10 @@ class RuntimeConnectionHandler(handler.Handler):
message=f'Knowledge base {kb_id} is not configured for this pipeline',
)
- kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
+ kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(
+ execution_context,
+ kb_id,
+ )
if not kb:
return handler.ActionResponse.error(
message=f'Knowledge base {kb_id} not found',
@@ -786,6 +1449,7 @@ class RuntimeConnectionHandler(handler.Handler):
try:
session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
entries = await kb.retrieve(
+ execution_context,
query_text,
settings={
'top_k': top_k,
@@ -809,23 +1473,146 @@ class RuntimeConnectionHandler(handler.Handler):
},
)
- async def ping(self) -> dict[str, Any]:
- """Ping the runtime"""
- return await self.call_action(
- CommonAction.PING,
- {},
- timeout=10,
+ self._secure_plugin_actions()
+
+ @contextlib.contextmanager
+ def installation_scope(self, binding: InstallationBinding | None):
+ """Attach one immutable installation tuple to nested wire actions."""
+
+ token = self._outbound_installation_context.set(binding)
+ try:
+ yield
+ finally:
+ self._outbound_installation_context.reset(token)
+
+ def register_installation_binding(
+ self,
+ binding: InstallationBinding,
+ *,
+ plugin_author: str,
+ plugin_name: str,
+ ) -> None:
+ """Install Core's current desired-state fence for inbound actions."""
+
+ existing = self._installation_bindings.get(binding.installation_uuid)
+ if existing is not None:
+ existing_binding = existing[0]
+ if existing_binding.workspace_uuid != binding.workspace_uuid:
+ raise ValueError('Plugin installation cannot move between Workspaces')
+ if binding.placement_generation < existing_binding.placement_generation or (
+ binding.placement_generation == existing_binding.placement_generation
+ and binding.runtime_revision < existing_binding.runtime_revision
+ ):
+ raise ValueError('Cannot register a stale plugin installation binding')
+ self._installation_bindings[binding.installation_uuid] = (
+ binding,
+ _PluginInstallationIdentity(
+ workspace_uuid=binding.workspace_uuid,
+ plugin_author=plugin_author,
+ plugin_name=plugin_name,
+ installation_uuid=binding.installation_uuid,
+ runtime_revision=binding.runtime_revision,
+ artifact_digest=binding.artifact_digest,
+ ),
)
- async def set_runtime_config(self, cloud_service_url: str) -> dict[str, Any]:
- """Push runtime configuration (e.g. marketplace URL) to the runtime."""
- return await self.call_action(
- LangBotToRuntimeAction.SET_RUNTIME_CONFIG,
- {
- 'cloud_service_url': cloud_service_url,
- },
- timeout=10,
+ def unregister_installation_binding(self, binding: InstallationBinding) -> None:
+ existing = self._installation_bindings.get(binding.installation_uuid)
+ if existing is not None and existing[0] == binding:
+ self._installation_bindings.pop(binding.installation_uuid, None)
+
+ def resolve_outbound_action_context(
+ self,
+ action_context: InstallationBinding | ActionContext | dict[str, Any] | None,
+ ) -> InstallationBinding | ActionContext | None:
+ if action_context is not None:
+ return super().resolve_outbound_action_context(action_context)
+ inbound_context = self.current_action_context
+ if inbound_context is not None:
+ return inbound_context
+ return self._outbound_installation_context.get()
+
+ def require_outbound_installation_context(self) -> InstallationBinding:
+ binding = self._outbound_installation_context.get()
+ if not isinstance(binding, InstallationBinding):
+ raise ValueError('Host plugin action requires an InstallationBinding scope')
+ return binding
+
+ async def ping(self) -> dict[str, Any]:
+ """Ping the runtime"""
+ with self.installation_scope(None):
+ return await self.call_action(
+ CommonAction.PING,
+ {},
+ timeout=10,
+ )
+
+ async def set_runtime_config(
+ self,
+ *,
+ runtime_identity: RuntimeIdentity,
+ worker_policy: PluginWorkerPolicy,
+ runtime_profile: typing.Literal['oss_dev', 'shared'],
+ cloud_service_url: str | None,
+ ) -> dict[str, Any]:
+ """Push the instance-scoped, immutable Runtime handshake."""
+
+ runtime_config = RuntimeConfig(
+ runtime_identity=runtime_identity,
+ worker_policy=worker_policy,
+ runtime_profile=runtime_profile,
+ cloud_service_url=cloud_service_url,
)
+ with self.installation_scope(None):
+ return await self.call_action(
+ LangBotToRuntimeAction.SET_RUNTIME_CONFIG,
+ runtime_config.model_dump(exclude_none=True),
+ timeout=10,
+ )
+
+ async def reconcile_plugin_installations(
+ self,
+ installations: tuple[PluginInstallationDesiredState, ...],
+ ) -> dict[str, Any]:
+ request = ReconcilePluginInstallationsRequest(installations=installations)
+ with self.installation_scope(None):
+ return await self.call_action(
+ LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
+ request.model_dump(),
+ timeout=120,
+ )
+
+ async def apply_plugin_installation(
+ self,
+ binding: InstallationBinding,
+ *,
+ artifact_package: bytes | None,
+ enabled: bool,
+ ) -> dict[str, Any]:
+ with self.installation_scope(binding):
+ artifact_file_key = None
+ if artifact_package is not None:
+ artifact_file_key = await self.send_file(artifact_package, 'lbpkg')
+ request = ApplyPluginInstallationRequest(
+ artifact_file_key=artifact_file_key,
+ enabled=enabled,
+ )
+ return await self.call_action(
+ LangBotToRuntimeAction.APPLY_PLUGIN_INSTALLATION,
+ request.model_dump(exclude_none=True),
+ timeout=120,
+ )
+
+ async def remove_plugin_installation(
+ self,
+ binding: InstallationBinding,
+ ) -> dict[str, Any]:
+ with self.installation_scope(binding):
+ return await self.call_action(
+ LangBotToRuntimeAction.REMOVE_PLUGIN_INSTALLATION,
+ RemovePluginInstallationRequest().model_dump(),
+ timeout=120,
+ )
async def install_plugin(
self, install_source: str, install_info: dict[str, Any]
@@ -894,9 +1681,11 @@ class RuntimeConnectionHandler(handler.Handler):
async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:
"""Set plugin config"""
+ action_context = self.require_outbound_installation_context()
# update plugin setting
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
.values(config=config)
@@ -974,7 +1763,7 @@ class RuntimeConnectionHandler(handler.Handler):
await self.delete_local_file(plugin_icon_file_key)
return {
- 'plugin_icon_base64': base64.b64encode(plugin_icon_bytes).decode('utf-8'),
+ 'plugin_icon_base64': (await asyncio.to_thread(base64.b64encode, plugin_icon_bytes)).decode('utf-8'),
'mime_type': mime_type,
}
@@ -1049,7 +1838,7 @@ class RuntimeConnectionHandler(handler.Handler):
asset_bytes = await self.read_local_file(asset_file_key)
await self.delete_local_file(asset_file_key)
return {
- 'asset_base64': base64.b64encode(asset_bytes).decode('utf-8'),
+ 'asset_base64': (await asyncio.to_thread(base64.b64encode, asset_bytes)).decode('utf-8'),
'mime_type': mime_type,
}
@@ -1079,9 +1868,11 @@ class RuntimeConnectionHandler(handler.Handler):
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
"""Cleanup plugin settings and binary storage"""
+ action_context = self.require_outbound_installation_context()
# Delete plugin settings
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_plugin.PluginSetting)
+ .where(persistence_plugin.PluginSetting.workspace_uuid == action_context.workspace_uuid)
.where(persistence_plugin.PluginSetting.plugin_author == plugin_author)
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
)
@@ -1090,6 +1881,7 @@ class RuntimeConnectionHandler(handler.Handler):
owner = f'{plugin_author}/{plugin_name}'
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
+ .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.owner_type == 'plugin')
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
@@ -1101,15 +1893,19 @@ class RuntimeConnectionHandler(handler.Handler):
session: dict[str, Any],
query_id: int,
include_plugins: list[str] | None = None,
+ query_uuid: str | None = None,
) -> dict[str, Any]:
"""Call tool"""
+ query_ref: dict[str, Any] = {'query_id': query_id}
+ if query_uuid is not None:
+ query_ref['query_uuid'] = query_uuid
result = await self.call_action(
LangBotToRuntimeAction.CALL_TOOL,
{
'tool_name': tool_name,
'tool_parameters': parameters,
'session': session,
- 'query_id': query_id,
+ **query_ref,
'include_plugins': include_plugins,
},
timeout=180,
@@ -1166,11 +1962,12 @@ class RuntimeConnectionHandler(handler.Handler):
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
- result = await self.call_action(
- LangBotToRuntimeAction.GET_DEBUG_INFO,
- {},
- timeout=10,
- )
+ with self.installation_scope(None):
+ result = await self.call_action(
+ LangBotToRuntimeAction.GET_DEBUG_INFO,
+ {},
+ timeout=10,
+ )
return result
# ================= RAG Capability Callers (LangBot -> Runtime) =================
diff --git a/src/langbot/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py
index 1431cf0b2..9d5dc803f 100644
--- a/src/langbot/pkg/provider/modelmgr/modelmgr.py
+++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py
@@ -1,42 +1,179 @@
from __future__ import annotations
import asyncio
-import sqlalchemy
import traceback
+from typing import TypeVar
-from . import requester
+import sqlalchemy
+
+from ...api.http.context import (
+ ExecutionContext,
+ PrincipalContext,
+ PrincipalType,
+ RequestContext,
+)
+from ...api.http.service.tenant import TenantContext, require_workspace_uuid
from ...core import app
from ...discover import engine
-from . import token
-from ...entity.persistence import model as persistence_model
from ...entity.errors import provider as provider_errors
+from ...entity.persistence import model as persistence_model
+from ...workspace.entities import WorkspaceExecutionBinding
+from ...workspace.errors import WorkspaceError, WorkspaceInvariantError
+from . import requester, token
+
+
+_CacheKey = tuple[str, str, int, str]
+_ModelEntity = TypeVar(
+ '_ModelEntity',
+ persistence_model.LLMModel,
+ persistence_model.EmbeddingModel,
+ persistence_model.RerankModel,
+)
class ModelManager:
- """Model manager"""
+ """Workspace-scoped runtime provider and model cache."""
ap: app.Application
- provider_dict: dict[str, requester.RuntimeProvider]
- """运行时模型提供商字典, uuid -> RuntimeProvider"""
-
- llm_models: list[requester.RuntimeLLMModel]
-
- embedding_models: list[requester.RuntimeEmbeddingModel]
-
- rerank_models: list[requester.RuntimeRerankModel]
+ provider_dict: dict[_CacheKey, requester.RuntimeProvider]
+ llm_model_dict: dict[_CacheKey, requester.RuntimeLLMModel]
+ embedding_model_dict: dict[_CacheKey, requester.RuntimeEmbeddingModel]
+ rerank_model_dict: dict[_CacheKey, requester.RuntimeRerankModel]
requester_components: list[engine.Component]
-
requester_dict: dict[str, type[requester.ProviderAPIRequester]]
def __init__(self, ap: app.Application):
self.ap = ap
- self.llm_models = []
- self.embedding_models = []
- self.rerank_models = []
+ self.provider_dict = {}
+ self.llm_model_dict = {}
+ self.embedding_model_dict = {}
+ self.rerank_model_dict = {}
self.requester_components = []
self.requester_dict = {}
+ self._scope_generations: dict[tuple[str, str], int] = {}
+ self._provider_keys_by_scope: dict[tuple[str, str], set[_CacheKey]] = {}
+ self._llm_keys_by_scope: dict[tuple[str, str], set[_CacheKey]] = {}
+ self._embedding_keys_by_scope: dict[tuple[str, str], set[_CacheKey]] = {}
+ self._rerank_keys_by_scope: dict[tuple[str, str], set[_CacheKey]] = {}
+
+ def _cache_index(self, cache: dict) -> dict[tuple[str, str], set[_CacheKey]]:
+ if cache is self.provider_dict:
+ return self._provider_keys_by_scope
+ if cache is self.llm_model_dict:
+ return self._llm_keys_by_scope
+ if cache is self.embedding_model_dict:
+ return self._embedding_keys_by_scope
+ if cache is self.rerank_model_dict:
+ return self._rerank_keys_by_scope
+ raise ValueError('Unknown model runtime cache')
+
+ def _cache_set(self, cache: dict, key: _CacheKey, value: object) -> None:
+ cache[key] = value
+ self._cache_index(cache).setdefault(key[:2], set()).add(key)
+
+ def _cache_pop(self, cache: dict, key: _CacheKey) -> object | None:
+ removed = cache.pop(key, None)
+ scope = key[:2]
+ index = self._cache_index(cache)
+ keys = index.get(scope)
+ if keys is not None:
+ keys.discard(key)
+ if not keys:
+ index.pop(scope, None)
+ if not any(
+ scope in candidate
+ for candidate in (
+ self._provider_keys_by_scope,
+ self._llm_keys_by_scope,
+ self._embedding_keys_by_scope,
+ self._rerank_keys_by_scope,
+ )
+ ):
+ self._scope_generations.pop(scope, None)
+ return removed
+
+ def _observe_execution_context(
+ self,
+ context: ExecutionContext,
+ ) -> tuple[requester.RuntimeProvider, ...]:
+ """Prune superseded runtime objects when a Workspace generation advances."""
+
+ scope = (context.instance_uuid, context.workspace_uuid)
+ previous_generation = self._scope_generations.get(scope)
+ if previous_generation is not None and context.placement_generation < previous_generation:
+ raise WorkspaceInvariantError('Model runtime placement generation rolled back')
+ if previous_generation == context.placement_generation:
+ return ()
+ retired_providers: list[requester.RuntimeProvider] = []
+ if previous_generation is not None:
+ for cache, index in (
+ (self.provider_dict, self._provider_keys_by_scope),
+ (self.llm_model_dict, self._llm_keys_by_scope),
+ (self.embedding_model_dict, self._embedding_keys_by_scope),
+ (self.rerank_model_dict, self._rerank_keys_by_scope),
+ ):
+ for key in index.pop(scope, ()):
+ removed = cache.pop(key, None)
+ if cache is self.provider_dict and removed is not None:
+ retired_providers.append(removed)
+ self._scope_generations[scope] = context.placement_generation
+ return tuple(retired_providers)
+
+ async def _close_runtime_providers(
+ self,
+ providers: tuple[requester.RuntimeProvider, ...] | list[requester.RuntimeProvider],
+ ) -> None:
+ """Close each retired requester once without blocking other cleanup."""
+
+ seen: set[int] = set()
+ for provider in providers:
+ provider_id = id(provider)
+ if provider_id in seen:
+ continue
+ seen.add(provider_id)
+ try:
+ await provider.requester.aclose()
+ except Exception as exc:
+ self.ap.logger.warning(
+ f'Failed to close model requester for provider {provider.provider_entity.uuid}: {exc}'
+ )
+
+ async def _observe_and_close_execution_context(
+ self,
+ context: ExecutionContext,
+ *,
+ retain_empty: bool = True,
+ ) -> None:
+ await self._close_runtime_providers(self._observe_execution_context(context))
+ if not retain_empty:
+ scope = (context.instance_uuid, context.workspace_uuid)
+ if not any(
+ scope in candidate
+ for candidate in (
+ self._provider_keys_by_scope,
+ self._llm_keys_by_scope,
+ self._embedding_keys_by_scope,
+ self._rerank_keys_by_scope,
+ )
+ ):
+ self._scope_generations.pop(scope, None)
+
+ async def shutdown(self) -> None:
+ """Release every requester owned by the model runtime cache."""
+
+ providers = list(self.provider_dict.values())
+ self.provider_dict = {}
+ self.llm_model_dict = {}
+ self.embedding_model_dict = {}
+ self.rerank_model_dict = {}
+ self._scope_generations = {}
+ self._provider_keys_by_scope = {}
+ self._llm_keys_by_scope = {}
+ self._embedding_keys_by_scope = {}
+ self._rerank_keys_by_scope = {}
+ await self._close_runtime_providers(providers)
@staticmethod
def _get_litellm_provider_from_manifest(component: engine.Component | None) -> str | None:
@@ -60,12 +197,85 @@ class ModelManager:
return litellm_provider
return None
- async def initialize(self):
+ @staticmethod
+ def _context_from_binding(
+ binding: WorkspaceExecutionBinding,
+ *,
+ trigger_principal: PrincipalContext | None = None,
+ ) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ trigger_principal=trigger_principal,
+ )
+
+ @staticmethod
+ def _cache_key(context: ExecutionContext, resource_uuid: str) -> _CacheKey:
+ return (
+ context.instance_uuid,
+ context.workspace_uuid,
+ context.placement_generation,
+ resource_uuid,
+ )
+
+ @staticmethod
+ def _ensure_same_scope(
+ expected: ExecutionContext,
+ actual: ExecutionContext,
+ *,
+ resource: str,
+ ) -> None:
+ if (
+ actual.instance_uuid != expected.instance_uuid
+ or actual.workspace_uuid != expected.workspace_uuid
+ or actual.placement_generation != expected.placement_generation
+ ):
+ raise WorkspaceInvariantError(f'{resource} runtime belongs to another Workspace execution scope')
+
+ @staticmethod
+ def _ensure_entity_workspace(entity: object, context: ExecutionContext, *, resource: str) -> None:
+ workspace_uuid = getattr(entity, 'workspace_uuid', None)
+ if workspace_uuid != context.workspace_uuid:
+ raise WorkspaceInvariantError(f'{resource} belongs to another Workspace')
+
+ async def resolve_execution_context(self, context: TenantContext) -> ExecutionContext:
+ """Resolve and fence-check an explicit tenant context for runtime access."""
+
+ workspace_uuid = require_workspace_uuid(context)
+ expected_generation = None
+ supplied_instance_uuid = None
+ trigger_principal = None
+
+ if isinstance(context, (RequestContext, ExecutionContext)):
+ expected_generation = context.placement_generation
+ supplied_instance_uuid = context.instance_uuid
+ trigger_principal = context.principal if isinstance(context, RequestContext) else context.trigger_principal
+
+ binding = await self.ap.workspace_service.get_execution_binding(
+ workspace_uuid,
+ expected_generation=expected_generation,
+ )
+ if supplied_instance_uuid is not None and supplied_instance_uuid != binding.instance_uuid:
+ raise WorkspaceInvariantError('Runtime context belongs to another LangBot instance')
+
+ execution_context = self._context_from_binding(binding, trigger_principal=trigger_principal)
+ scope = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ )
+ if scope in self._scope_generations:
+ await self._observe_and_close_execution_context(
+ execution_context,
+ retain_empty=False,
+ )
+ return execution_context
+
+ async def initialize(self) -> None:
self.requester_components = self.ap.discover.get_components_by_kind('LLMAPIRequester')
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
for component in self.requester_components:
- # Skip components that use litellm_provider (they will use litellmchat.py instead)
litellm_provider = self._get_litellm_provider_from_manifest(component)
if litellm_provider:
self.ap.logger.debug(
@@ -76,134 +286,259 @@ class ModelManager:
requester_dict[component.metadata.name] = component.get_python_component_class()
self.requester_dict = requester_dict
-
await self.load_models_from_db()
- # Check if space models service is disabled
space_config = self.ap.instance_config.data.get('space', {})
if space_config.get('disable_models_service', False):
self.ap.logger.info('LangBot Space Models service is disabled, skipping sync.')
return
+ # Space model synchronization is a legacy OSS-singleton facility. Cloud
+ # receives tenant model projections from its control plane and must not
+ # resolve an OSS-local Workspace outside a tenant-scoped unit of work.
+ persistence_mgr = getattr(self.ap, 'persistence_mgr', None)
+ cloud_runtime = getattr(getattr(persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ self.ap.logger.info('Skipping legacy LangBot Space model sync in Cloud Runtime.')
+ return
+
+ try:
+ binding = await self.ap.workspace_service.get_local_execution_binding()
+ except WorkspaceError as exc:
+ self.ap.logger.info(f'Skipping LangBot Space model sync outside an OSS local Workspace: {exc}')
+ return
+
+ sync_context = self._context_from_binding(
+ binding,
+ trigger_principal=PrincipalContext(principal_type=PrincipalType.SYSTEM),
+ )
sync_timeout = space_config.get('models_sync_timeout')
try:
if sync_timeout:
await asyncio.wait_for(
- self.sync_new_models_from_space(),
+ self.sync_new_models_from_space(sync_context),
timeout=float(sync_timeout),
)
else:
- await self.sync_new_models_from_space()
+ await self.sync_new_models_from_space(sync_context)
except asyncio.TimeoutError:
self.ap.logger.warning(f'LangBot Space model sync timed out after {sync_timeout}s, skipping startup sync.')
- except Exception as e:
+ except Exception as exc:
self.ap.logger.warning('Failed to sync new models from LangBot Space, model list may not be updated.')
- self.ap.logger.warning(f' - Error: {e}')
+ self.ap.logger.warning(f' - Error: {exc}')
+
+ async def load_models_from_db(self) -> None:
+ """Load every active projected Workspace into isolated runtime caches."""
- async def load_models_from_db(self):
- """Load models from database"""
self.ap.logger.info('Loading models from db...')
-
- self.llm_models = []
- self.embedding_models = []
- self.rerank_models = []
+ await self._close_runtime_providers(list(self.provider_dict.values()))
self.provider_dict = {}
+ self.llm_model_dict = {}
+ self.embedding_model_dict = {}
+ self.rerank_model_dict = {}
+ self._scope_generations = {}
+ self._provider_keys_by_scope = {}
+ self._llm_keys_by_scope = {}
+ self._embedding_keys_by_scope = {}
+ self._rerank_keys_by_scope = {}
+
+ list_bindings = getattr(self.ap.workspace_service, 'list_active_execution_bindings', None)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ if not callable(list_bindings) or not callable(tenant_uow):
+ raise RuntimeError('Cloud model loading requires explicit instance discovery and tenant UoWs')
+ for binding in await list_bindings():
+ context = self._context_from_binding(
+ binding,
+ trigger_principal=PrincipalContext(principal_type=PrincipalType.SYSTEM),
+ )
+ async with tenant_uow(binding.workspace_uuid):
+ await self._load_workspace_models(context)
+ return
+
+ # Compatibility path for isolated manager tests and older embedders.
+ contexts: dict[str, ExecutionContext] = {}
+
+ async def context_for(workspace_uuid: str | None) -> ExecutionContext:
+ if not workspace_uuid:
+ raise WorkspaceInvariantError('Runtime model resource has no Workspace')
+ cached = contexts.get(workspace_uuid)
+ if cached is not None:
+ return cached
+ binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid)
+ resolved = self._context_from_binding(
+ binding,
+ trigger_principal=PrincipalContext(principal_type=PrincipalType.SYSTEM),
+ )
+ await self._observe_and_close_execution_context(resolved)
+ contexts[workspace_uuid] = resolved
+ return resolved
+
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
)
- for provider in providers_result.all():
+ for provider_entity in providers_result.all():
try:
- runtime_provider = await self.load_provider(provider)
- self.provider_dict[provider.uuid] = runtime_provider
- except provider_errors.RequesterNotFoundError as e:
- self.ap.logger.warning(f'Requester {e.requester_name} not found, skipping provider {provider.uuid}')
- continue
- except Exception as e:
- self.ap.logger.error(f'Failed to load provider {provider.uuid}: {e}\n{traceback.format_exc()}')
+ context = await context_for(provider_entity.workspace_uuid)
+ runtime_provider = await self._build_provider(context, provider_entity)
+ self._cache_set(
+ self.provider_dict,
+ self._cache_key(context, provider_entity.uuid),
+ runtime_provider,
+ )
+ except provider_errors.RequesterNotFoundError as exc:
+ self.ap.logger.warning(
+ f'Requester {exc.requester_name} not found, skipping provider {provider_entity.uuid}'
+ )
+ except Exception as exc:
+ self.ap.logger.error(f'Failed to load provider {provider_entity.uuid}: {exc}\n{traceback.format_exc()}')
- # Load LLM models
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.LLMModel))
- llm_models = result.all()
- for llm_model in llm_models:
- try:
- provider = self.provider_dict.get(llm_model.provider_uuid)
- if provider is None:
- self.ap.logger.warning(f'Provider {llm_model.provider_uuid} not found for model {llm_model.uuid}')
- continue
- runtime_llm_model = await self.load_llm_model_with_provider(llm_model, provider)
- self.llm_models.append(runtime_llm_model)
- except Exception as e:
- self.ap.logger.error(f'Failed to load model {llm_model.uuid}: {e}\n{traceback.format_exc()}')
+ await self._load_model_kind(
+ persistence_model.LLMModel,
+ self.llm_model_dict,
+ self._build_llm_model,
+ context_for,
+ )
+ await self._load_model_kind(
+ persistence_model.EmbeddingModel,
+ self.embedding_model_dict,
+ self._build_embedding_model,
+ context_for,
+ )
+ await self._load_model_kind(
+ persistence_model.RerankModel,
+ self.rerank_model_dict,
+ self._build_rerank_model,
+ context_for,
+ )
- # Load embedding models
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.EmbeddingModel))
- embedding_models = result.all()
- for embedding_model in embedding_models:
+ async def _load_model_kind(self, entity_type, cache: dict, builder, context_for) -> None:
+ result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(entity_type))
+ for model_entity in result.all():
try:
- provider = self.provider_dict.get(embedding_model.provider_uuid)
+ context = await context_for(model_entity.workspace_uuid)
+ provider = self.provider_dict.get(self._cache_key(context, model_entity.provider_uuid))
if provider is None:
self.ap.logger.warning(
- f'Provider {embedding_model.provider_uuid} not found for model {embedding_model.uuid}'
+ f'Provider {model_entity.provider_uuid} not found for model {model_entity.uuid}'
)
continue
- runtime_embedding_model = await self.load_embedding_model_with_provider(embedding_model, provider)
- self.embedding_models.append(runtime_embedding_model)
- except Exception as e:
- self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
+ runtime_model = builder(context, model_entity, provider)
+ self._cache_set(
+ cache,
+ self._cache_key(context, model_entity.uuid),
+ runtime_model,
+ )
+ except Exception as exc:
+ self.ap.logger.error(f'Failed to load model {model_entity.uuid}: {exc}\n{traceback.format_exc()}')
- # Load rerank models
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
- rerank_models = result.all()
- for rerank_model in rerank_models:
- try:
- provider = self.provider_dict.get(rerank_model.provider_uuid)
- if provider is None:
- self.ap.logger.warning(
- f'Provider {rerank_model.provider_uuid} not found for model {rerank_model.uuid}'
- )
- continue
- runtime_rerank_model = await self.load_rerank_model_with_provider(rerank_model, provider)
- self.rerank_models.append(runtime_rerank_model)
- except Exception as e:
- self.ap.logger.error(f'Failed to load model {rerank_model.uuid}: {e}\n{traceback.format_exc()}')
+ async def _load_workspace_models(self, context: ExecutionContext) -> None:
+ """Load one Workspace while its tenant transaction is active."""
- async def sync_new_models_from_space(self):
- """Sync models from Space"""
- space_model_provider = await self.ap.persistence_mgr.execute_async(
+ providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.requester == 'space-chat-completions'
+ persistence_model.ModelProvider.workspace_uuid == context.workspace_uuid
)
)
- result = space_model_provider.first()
- if result is None:
+ provider_entities = providers_result.all()
+ if provider_entities:
+ # Empty Workspaces are the dominant SaaS registration case. Do
+ # not retain one generation record per account until the
+ # Workspace owns an actual runtime model resource.
+ await self._observe_and_close_execution_context(context)
+ for provider_entity in provider_entities:
+ try:
+ runtime_provider = await self._build_provider(context, provider_entity)
+ self._cache_set(
+ self.provider_dict,
+ self._cache_key(context, provider_entity.uuid),
+ runtime_provider,
+ )
+ except provider_errors.RequesterNotFoundError as exc:
+ self.ap.logger.warning(
+ f'Requester {exc.requester_name} not found, skipping provider {provider_entity.uuid}'
+ )
+ except Exception as exc:
+ self.ap.logger.error(f'Failed to load provider {provider_entity.uuid}: {exc}\n{traceback.format_exc()}')
+
+ await self._load_workspace_model_kind(
+ context,
+ persistence_model.LLMModel,
+ self.llm_model_dict,
+ self._build_llm_model,
+ )
+ await self._load_workspace_model_kind(
+ context,
+ persistence_model.EmbeddingModel,
+ self.embedding_model_dict,
+ self._build_embedding_model,
+ )
+ await self._load_workspace_model_kind(
+ context,
+ persistence_model.RerankModel,
+ self.rerank_model_dict,
+ self._build_rerank_model,
+ )
+
+ async def _load_workspace_model_kind(self, context, entity_type, cache: dict, builder) -> None:
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(entity_type).where(entity_type.workspace_uuid == context.workspace_uuid)
+ )
+ for model_entity in result.all():
+ try:
+ provider = self.provider_dict.get(self._cache_key(context, model_entity.provider_uuid))
+ if provider is None:
+ self.ap.logger.warning(
+ f'Provider {model_entity.provider_uuid} not found for model {model_entity.uuid}'
+ )
+ continue
+ runtime_model = builder(context, model_entity, provider)
+ self._cache_set(
+ cache,
+ self._cache_key(context, model_entity.uuid),
+ runtime_model,
+ )
+ except Exception as exc:
+ self.ap.logger.error(f'Failed to load model {model_entity.uuid}: {exc}\n{traceback.format_exc()}')
+
+ async def sync_new_models_from_space(self, context: ExecutionContext) -> None:
+ """Sync legacy Space models for the explicitly selected OSS Workspace."""
+
+ context = await self.resolve_execution_context(context)
+ await self.ap.workspace_service.get_local_execution_binding(
+ context.workspace_uuid,
+ expected_generation=context.placement_generation,
+ )
+ space_model_provider_result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_model.ModelProvider).where(
+ persistence_model.ModelProvider.workspace_uuid == context.workspace_uuid,
+ persistence_model.ModelProvider.requester == 'space-chat-completions',
+ )
+ )
+ space_model_provider = space_model_provider_result.first()
+ if space_model_provider is None:
raise provider_errors.ProviderNotFoundError('LangBot Models')
- space_model_provider = result
-
- # get the latest models from space
space_models = await self.ap.space_service.get_models()
-
- # Index existing models by uuid. Space reuses a model's uuid across
- # renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6``
- # may later become ``claude-opus-4-7``). So for Space-managed models we
- # upsert: create when the uuid is new, otherwise update name/abilities/
- # ranking to track Space. Models owned by other providers are never
- # touched, even on an (unexpected) uuid collision.
- existing_llm_models = {m['uuid']: m for m in await self.ap.llm_model_service.get_llm_models()}
- existing_embedding_models = {
- m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models()
+ existing_llm_models = {
+ model['uuid']: model
+ for model in await self.ap.llm_model_service.get_llm_models(context, include_secret=True)
}
- existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models()}
+ existing_embedding_models = {
+ model['uuid']: model
+ for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
+ }
+ existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models(context)}
created = 0
updated = 0
-
for space_model in space_models:
if space_model.category == 'chat':
existing = existing_llm_models.get(space_model.uuid)
if existing is None:
- # model will be automatically loaded
await self.ap.llm_model_service.create_llm_model(
+ context,
{
'uuid': space_model.uuid,
'name': space_model.model_id,
@@ -228,14 +563,14 @@ class ModelManager:
or list(existing.get('abilities') or []) != list(desired['abilities'])
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
- await self.ap.llm_model_service.update_llm_model(space_model.uuid, dict(desired))
+ await self.ap.llm_model_service.update_llm_model(context, space_model.uuid, dict(desired))
updated += 1
elif space_model.category == 'embedding':
existing = existing_embedding_models.get(space_model.uuid)
if existing is None:
- # model will be automatically loaded
await self.ap.embedding_models_service.create_embedding_model(
+ context,
{
'uuid': space_model.uuid,
'name': space_model.model_id,
@@ -256,13 +591,18 @@ class ModelManager:
existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
- await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired))
+ await self.ap.embedding_models_service.update_embedding_model(
+ context,
+ space_model.uuid,
+ dict(desired),
+ )
updated += 1
elif space_model.category == 'rerank':
existing = existing_rerank_models.get(space_model.uuid)
if existing is None:
await self.ap.rerank_models_service.create_rerank_model(
+ context,
{
'uuid': space_model.uuid,
'name': space_model.model_id,
@@ -283,7 +623,9 @@ class ModelManager:
existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
- await self.ap.rerank_models_service.update_rerank_model(space_model.uuid, dict(desired))
+ await self.ap.rerank_models_service.update_rerank_model(
+ context, space_model.uuid, dict(desired)
+ )
updated += 1
if created or updated:
@@ -291,313 +633,378 @@ class ModelManager:
async def init_temporary_runtime_llm_model(
self,
+ context: TenantContext,
model_info: dict,
) -> requester.RuntimeLLMModel:
- """Initialize runtime LLM model from dict (for testing)"""
- provider_info = model_info.get('provider', {})
-
- runtime_provider = await self.load_provider(provider_info)
-
- runtime_llm_model = requester.RuntimeLLMModel(
- model_entity=persistence_model.LLMModel(
- uuid=model_info.get('uuid', ''),
- name=model_info.get('name', ''),
- provider_uuid='',
- abilities=model_info.get('abilities', []),
- context_length=model_info.get('context_length'),
- extra_args=model_info.get('extra_args', {}),
- ),
- provider=runtime_provider,
+ execution_context = await self.resolve_execution_context(context)
+ provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
+ runtime_provider = await self._build_provider(
+ execution_context,
+ persistence_model.ModelProvider(**provider_info),
)
-
- return runtime_llm_model
+ model_entity = persistence_model.LLMModel(
+ workspace_uuid=execution_context.workspace_uuid,
+ uuid=model_info.get('uuid', ''),
+ name=model_info.get('name', ''),
+ provider_uuid=runtime_provider.provider_entity.uuid,
+ abilities=model_info.get('abilities', []),
+ context_length=model_info.get('context_length'),
+ extra_args=model_info.get('extra_args', {}),
+ )
+ return self._build_llm_model(execution_context, model_entity, runtime_provider)
async def init_temporary_runtime_embedding_model(
self,
+ context: TenantContext,
model_info: dict,
) -> requester.RuntimeEmbeddingModel:
- """Initialize runtime embedding model from dict (for testing)"""
- provider_info = model_info.get('provider', {})
- runtime_provider = await self.load_provider(provider_info)
-
- runtime_embedding_model = requester.RuntimeEmbeddingModel(
- model_entity=persistence_model.EmbeddingModel(
- uuid=model_info.get('uuid', ''),
- name=model_info.get('name', ''),
- provider_uuid='',
- extra_args=model_info.get('extra_args', {}),
- ),
- provider=runtime_provider,
+ execution_context = await self.resolve_execution_context(context)
+ provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
+ runtime_provider = await self._build_provider(
+ execution_context,
+ persistence_model.ModelProvider(**provider_info),
)
-
- return runtime_embedding_model
+ model_entity = persistence_model.EmbeddingModel(
+ workspace_uuid=execution_context.workspace_uuid,
+ uuid=model_info.get('uuid', ''),
+ name=model_info.get('name', ''),
+ provider_uuid=runtime_provider.provider_entity.uuid,
+ extra_args=model_info.get('extra_args', {}),
+ )
+ return self._build_embedding_model(execution_context, model_entity, runtime_provider)
async def init_temporary_runtime_rerank_model(
self,
+ context: TenantContext,
model_info: dict,
) -> requester.RuntimeRerankModel:
- """Initialize runtime rerank model from dict (for testing)"""
- provider_info = model_info.get('provider', {})
- runtime_provider = await self.load_provider(provider_info)
-
- runtime_rerank_model = requester.RuntimeRerankModel(
- model_entity=persistence_model.RerankModel(
- uuid=model_info.get('uuid', ''),
- name=model_info.get('name', ''),
- provider_uuid='',
- extra_args=model_info.get('extra_args', {}),
- ),
- provider=runtime_provider,
+ execution_context = await self.resolve_execution_context(context)
+ provider_info = {**model_info.get('provider', {}), 'workspace_uuid': execution_context.workspace_uuid}
+ runtime_provider = await self._build_provider(
+ execution_context,
+ persistence_model.ModelProvider(**provider_info),
)
+ model_entity = persistence_model.RerankModel(
+ workspace_uuid=execution_context.workspace_uuid,
+ uuid=model_info.get('uuid', ''),
+ name=model_info.get('name', ''),
+ provider_uuid=runtime_provider.provider_entity.uuid,
+ extra_args=model_info.get('extra_args', {}),
+ )
+ return self._build_rerank_model(execution_context, model_entity, runtime_provider)
- return runtime_rerank_model
-
- async def load_provider(
- self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
- ) -> requester.RuntimeProvider:
- """Load provider from dict"""
+ @staticmethod
+ def _coerce_provider(
+ provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
+ context: ExecutionContext,
+ ) -> persistence_model.ModelProvider:
if isinstance(provider_info, sqlalchemy.Row):
provider_entity = persistence_model.ModelProvider(**provider_info._mapping)
elif isinstance(provider_info, dict):
- provider_entity = persistence_model.ModelProvider(**provider_info)
+ provider_entity = persistence_model.ModelProvider(
+ **{**provider_info, 'workspace_uuid': context.workspace_uuid}
+ )
else:
provider_entity = provider_info
+ ModelManager._ensure_entity_workspace(provider_entity, context, resource='Provider')
+ return provider_entity
- # Get requester manifest to check for litellm_provider
+ async def _build_provider(
+ self,
+ context: ExecutionContext,
+ provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
+ ) -> requester.RuntimeProvider:
+ provider_entity = self._coerce_provider(provider_info, context)
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
litellm_provider = self._get_litellm_provider_from_manifest(requester_manifest)
-
- # Build config from base_url
config = {'base_url': provider_entity.base_url}
- # Check if requester manifest specifies litellm_provider
if litellm_provider:
from .requesters import litellmchat
- # Use unified LiteLLMRequester with provider prefix
- # Map litellm_provider (YAML spec) to custom_llm_provider (config)
config['custom_llm_provider'] = litellm_provider
- requester_inst = litellmchat.LiteLLMRequester(
- ap=self.ap,
- config=config,
- )
+ requester_inst = litellmchat.LiteLLMRequester(ap=self.ap, config=config)
self.ap.logger.debug(
f'Using LiteLLMRequester for {provider_entity.requester} '
f'with custom_llm_provider={config["custom_llm_provider"]}'
)
else:
- # Use original requester class (for backward compatibility)
if provider_entity.requester not in self.requester_dict:
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
- requester_inst = self.requester_dict[provider_entity.requester](
- ap=self.ap,
- config=config,
- )
+ requester_inst = self.requester_dict[provider_entity.requester](ap=self.ap, config=config)
await requester_inst.initialize()
-
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
-
- provider = requester.RuntimeProvider(
+ return requester.RuntimeProvider(
+ execution_context=context,
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
)
+
+ async def load_provider(
+ self,
+ context: TenantContext,
+ provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict,
+ ) -> requester.RuntimeProvider:
+ execution_context = await self.resolve_execution_context(context)
+ return await self._build_provider(execution_context, provider_info)
+
+ async def cache_provider(self, context: TenantContext, provider: requester.RuntimeProvider) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ self._ensure_same_scope(execution_context, provider.execution_context, resource='Provider')
+ self._ensure_entity_workspace(provider.provider_entity, execution_context, resource='Provider')
+ self._observe_execution_context(execution_context)
+ self._cache_set(
+ self.provider_dict,
+ self._cache_key(execution_context, provider.provider_entity.uuid),
+ provider,
+ )
+
+ async def get_provider_by_uuid(
+ self,
+ context: TenantContext,
+ provider_uuid: str,
+ ) -> requester.RuntimeProvider:
+ execution_context = await self.resolve_execution_context(context)
+ provider = self.provider_dict.get(self._cache_key(execution_context, provider_uuid))
+ if provider is None:
+ raise ValueError(f'Model provider {provider_uuid} not found')
+ self._ensure_same_scope(execution_context, provider.execution_context, resource='Provider')
return provider
- async def remove_provider(self, provider_uuid: str):
- """Remove provider
+ async def remove_provider(self, context: TenantContext, provider_uuid: str) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ removed = self._cache_pop(
+ self.provider_dict,
+ self._cache_key(execution_context, provider_uuid),
+ )
+ if removed is not None:
+ await self._close_runtime_providers([removed])
- This method will not consider the models using this provider,
- because the models should be removed by the caller.
- """
- del self.provider_dict[provider_uuid]
-
- async def reload_provider(self, provider_uuid: str):
- """Reload provider"""
- provider_entity = await self.ap.persistence_mgr.execute_async(
+ async def reload_provider(self, context: TenantContext, provider_uuid: str) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
- persistence_model.ModelProvider.uuid == provider_uuid
+ persistence_model.ModelProvider.workspace_uuid == execution_context.workspace_uuid,
+ persistence_model.ModelProvider.uuid == provider_uuid,
)
)
- provider_entity = provider_entity.first()
+ provider_entity = result.first()
if provider_entity is None:
raise provider_errors.ProviderNotFoundError(provider_uuid)
- new_runtime_provider = await self.load_provider(provider_entity)
+ new_provider = await self._build_provider(execution_context, provider_entity)
+ scope = (execution_context.instance_uuid, execution_context.workspace_uuid)
+ for cache, index in (
+ (self.llm_model_dict, self._llm_keys_by_scope),
+ (self.embedding_model_dict, self._embedding_keys_by_scope),
+ (self.rerank_model_dict, self._rerank_keys_by_scope),
+ ):
+ for key in tuple(index.get(scope, ())):
+ model = cache.get(key)
+ if model is not None and model.provider.provider_entity.uuid == provider_uuid:
+ model.provider = new_provider
+ self._observe_execution_context(execution_context)
+ provider_key = self._cache_key(execution_context, provider_uuid)
+ old_provider = self.provider_dict.get(provider_key)
+ self._cache_set(
+ self.provider_dict,
+ provider_key,
+ new_provider,
+ )
+ if old_provider is not None and old_provider is not new_provider:
+ await self._close_runtime_providers([old_provider])
- # update refs in runtime models
- for model in self.llm_models:
- if model.provider.provider_entity.uuid == provider_uuid:
- model.provider = new_runtime_provider
- for model in self.embedding_models:
- if model.provider.provider_entity.uuid == provider_uuid:
- model.provider = new_runtime_provider
- for model in self.rerank_models:
- if model.provider.provider_entity.uuid == provider_uuid:
- model.provider = new_runtime_provider
+ @staticmethod
+ def _coerce_model(model_info: _ModelEntity | sqlalchemy.Row, entity_type: type[_ModelEntity]) -> _ModelEntity:
+ if isinstance(model_info, sqlalchemy.Row):
+ return entity_type(**model_info._mapping)
+ return model_info
- # update ref in provider dict
- self.provider_dict[provider_uuid] = new_runtime_provider
-
- async def load_llm_model_with_provider(
+ def _validate_model_provider(
self,
+ context: ExecutionContext,
+ model_entity: _ModelEntity,
+ provider: requester.RuntimeProvider,
+ ) -> None:
+ self._ensure_entity_workspace(model_entity, context, resource='Model')
+ self._ensure_same_scope(context, provider.execution_context, resource='Provider')
+ if model_entity.provider_uuid != provider.provider_entity.uuid:
+ raise WorkspaceInvariantError('Model references a different provider')
+
+ def _build_llm_model(
+ self,
+ context: ExecutionContext,
model_info: persistence_model.LLMModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeLLMModel:
- """Load LLM model with provider info"""
- if isinstance(model_info, sqlalchemy.Row):
- model_info = persistence_model.LLMModel(**model_info._mapping)
-
- runtime_llm_model = requester.RuntimeLLMModel(
- model_entity=model_info,
+ model_entity = self._coerce_model(model_info, persistence_model.LLMModel)
+ self._validate_model_provider(context, model_entity, provider)
+ return requester.RuntimeLLMModel(
+ execution_context=context,
+ model_entity=model_entity,
provider=provider,
)
- return runtime_llm_model
-
- async def load_embedding_model_with_provider(
+ def _build_embedding_model(
self,
+ context: ExecutionContext,
model_info: persistence_model.EmbeddingModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeEmbeddingModel:
- """Load embedding model with provider info"""
- if isinstance(model_info, sqlalchemy.Row):
- model_info = persistence_model.EmbeddingModel(**model_info._mapping)
-
- runtime_embedding_model = requester.RuntimeEmbeddingModel(
- model_entity=model_info,
+ model_entity = self._coerce_model(model_info, persistence_model.EmbeddingModel)
+ self._validate_model_provider(context, model_entity, provider)
+ return requester.RuntimeEmbeddingModel(
+ execution_context=context,
+ model_entity=model_entity,
provider=provider,
)
- return runtime_embedding_model
-
- async def load_rerank_model_with_provider(
+ def _build_rerank_model(
self,
+ context: ExecutionContext,
model_info: persistence_model.RerankModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeRerankModel:
- """Load rerank model with provider info"""
- if isinstance(model_info, sqlalchemy.Row):
- model_info = persistence_model.RerankModel(**model_info._mapping)
-
- runtime_rerank_model = requester.RuntimeRerankModel(
- model_entity=model_info,
+ model_entity = self._coerce_model(model_info, persistence_model.RerankModel)
+ self._validate_model_provider(context, model_entity, provider)
+ return requester.RuntimeRerankModel(
+ execution_context=context,
+ model_entity=model_entity,
provider=provider,
)
- return runtime_rerank_model
+ async def load_llm_model_with_provider(
+ self,
+ context: TenantContext,
+ model_info: persistence_model.LLMModel | sqlalchemy.Row,
+ provider: requester.RuntimeProvider,
+ ) -> requester.RuntimeLLMModel:
+ execution_context = await self.resolve_execution_context(context)
+ return self._build_llm_model(execution_context, model_info, provider)
- async def load_llm_model(self, model_info: dict):
- """Load LLM model from dict (with provider info)"""
- provider_info = model_info.get('provider', {})
- if not provider_info:
- raise ValueError('Provider info is required')
+ async def load_embedding_model_with_provider(
+ self,
+ context: TenantContext,
+ model_info: persistence_model.EmbeddingModel | sqlalchemy.Row,
+ provider: requester.RuntimeProvider,
+ ) -> requester.RuntimeEmbeddingModel:
+ execution_context = await self.resolve_execution_context(context)
+ return self._build_embedding_model(execution_context, model_info, provider)
- model_entity = persistence_model.LLMModel(
- uuid=model_info.get('uuid', ''),
- name=model_info.get('name', ''),
- provider_uuid=model_info.get('provider_uuid', ''),
- abilities=model_info.get('abilities', []),
- context_length=model_info.get('context_length'),
- extra_args=model_info.get('extra_args', {}),
+ async def load_rerank_model_with_provider(
+ self,
+ context: TenantContext,
+ model_info: persistence_model.RerankModel | sqlalchemy.Row,
+ provider: requester.RuntimeProvider,
+ ) -> requester.RuntimeRerankModel:
+ execution_context = await self.resolve_execution_context(context)
+ return self._build_rerank_model(execution_context, model_info, provider)
+
+ async def cache_llm_model(self, context: TenantContext, model: requester.RuntimeLLMModel) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ self._ensure_same_scope(execution_context, model.execution_context, resource='LLM model')
+ self._observe_execution_context(execution_context)
+ self._cache_set(
+ self.llm_model_dict,
+ self._cache_key(execution_context, model.model_entity.uuid),
+ model,
)
- provider_entity = persistence_model.ModelProvider(
- uuid=provider_info.get('uuid', ''),
- name=provider_info.get('name', ''),
- requester=provider_info.get('requester', ''),
- base_url=provider_info.get('base_url', ''),
- api_keys=provider_info.get('api_keys', []),
+ async def cache_embedding_model(
+ self,
+ context: TenantContext,
+ model: requester.RuntimeEmbeddingModel,
+ ) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ self._ensure_same_scope(execution_context, model.execution_context, resource='Embedding model')
+ self._observe_execution_context(execution_context)
+ self._cache_set(
+ self.embedding_model_dict,
+ self._cache_key(execution_context, model.model_entity.uuid),
+ model,
)
- await self.load_llm_model_with_provider(model_entity, provider_entity)
-
- async def load_embedding_model(self, model_info: dict):
- """Load embedding model from dict (with provider info)"""
- provider_info = model_info.get('provider', {})
- if not provider_info:
- raise ValueError('Provider info is required')
-
- model_entity = persistence_model.EmbeddingModel(
- uuid=model_info.get('uuid', ''),
- name=model_info.get('name', ''),
- provider_uuid=model_info.get('provider_uuid', ''),
- extra_args=model_info.get('extra_args', {}),
+ async def cache_rerank_model(self, context: TenantContext, model: requester.RuntimeRerankModel) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ self._ensure_same_scope(execution_context, model.execution_context, resource='Rerank model')
+ self._observe_execution_context(execution_context)
+ self._cache_set(
+ self.rerank_model_dict,
+ self._cache_key(execution_context, model.model_entity.uuid),
+ model,
)
- provider_entity = persistence_model.ModelProvider(
- uuid=provider_info.get('uuid', ''),
- name=provider_info.get('name', ''),
- requester=provider_info.get('requester', ''),
- base_url=provider_info.get('base_url', ''),
- api_keys=provider_info.get('api_keys', []),
+ async def get_model_by_uuid(self, context: TenantContext, model_uuid: str) -> requester.RuntimeLLMModel:
+ execution_context = await self.resolve_execution_context(context)
+ model = self.llm_model_dict.get(self._cache_key(execution_context, model_uuid))
+ if model is None:
+ raise ValueError(f'LLM model {model_uuid} not found')
+ self._ensure_same_scope(execution_context, model.execution_context, resource='LLM model')
+ return model
+
+ async def get_embedding_model_by_uuid(
+ self,
+ context: TenantContext,
+ model_uuid: str,
+ ) -> requester.RuntimeEmbeddingModel:
+ execution_context = await self.resolve_execution_context(context)
+ model = self.embedding_model_dict.get(self._cache_key(execution_context, model_uuid))
+ if model is None:
+ raise ValueError(f'Embedding model {model_uuid} not found')
+ self._ensure_same_scope(execution_context, model.execution_context, resource='Embedding model')
+ return model
+
+ async def get_rerank_model_by_uuid(
+ self,
+ context: TenantContext,
+ model_uuid: str,
+ ) -> requester.RuntimeRerankModel:
+ execution_context = await self.resolve_execution_context(context)
+ model = self.rerank_model_dict.get(self._cache_key(execution_context, model_uuid))
+ if model is None:
+ raise ValueError(f'Rerank model {model_uuid} not found')
+ self._ensure_same_scope(execution_context, model.execution_context, resource='Rerank model')
+ return model
+
+ async def remove_llm_model(self, context: TenantContext, model_uuid: str) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ self._cache_pop(
+ self.llm_model_dict,
+ self._cache_key(execution_context, model_uuid),
)
- await self.load_embedding_model_with_provider(model_entity, provider_entity)
+ async def remove_embedding_model(self, context: TenantContext, model_uuid: str) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ self._cache_pop(
+ self.embedding_model_dict,
+ self._cache_key(execution_context, model_uuid),
+ )
- async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
- """Get LLM model by uuid"""
- for model in self.llm_models:
- if model.model_entity.uuid == uuid:
- return model
- raise ValueError(f'LLM model {uuid} not found')
-
- async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
- """Get embedding model by uuid"""
- for model in self.embedding_models:
- if model.model_entity.uuid == uuid:
- return model
- raise ValueError(f'Embedding model {uuid} not found')
-
- async def get_rerank_model_by_uuid(self, uuid: str) -> requester.RuntimeRerankModel:
- """Get rerank model by uuid"""
- for model in self.rerank_models:
- if model.model_entity.uuid == uuid:
- return model
- raise ValueError(f'Rerank model {uuid} not found')
-
- async def remove_llm_model(self, model_uuid: str):
- """Remove LLM model"""
- for model in self.llm_models:
- if model.model_entity.uuid == model_uuid:
- self.llm_models.remove(model)
- return
-
- async def remove_embedding_model(self, model_uuid: str):
- """Remove embedding model"""
- for model in self.embedding_models:
- if model.model_entity.uuid == model_uuid:
- self.embedding_models.remove(model)
- return
-
- async def remove_rerank_model(self, model_uuid: str):
- """Remove rerank model"""
- for model in self.rerank_models:
- if model.model_entity.uuid == model_uuid:
- self.rerank_models.remove(model)
- return
+ async def remove_rerank_model(self, context: TenantContext, model_uuid: str) -> None:
+ execution_context = await self.resolve_execution_context(context)
+ self._cache_pop(
+ self.rerank_model_dict,
+ self._cache_key(execution_context, model_uuid),
+ )
def get_available_requesters_info(self, model_type: str) -> list[dict]:
- """Get all available requesters"""
- if model_type != '':
+ if model_type:
return [
component.to_plain_dict()
for component in self.requester_components
if model_type in component.spec['support_type']
]
- else:
- return [component.to_plain_dict() for component in self.requester_components]
+ return [component.to_plain_dict() for component in self.requester_components]
def get_available_requester_info_by_name(self, name: str) -> dict | None:
- """Get requester info by name"""
for component in self.requester_components:
if component.metadata.name == name:
return component.to_plain_dict()
return None
def get_available_requester_manifest_by_name(self, name: str) -> engine.Component | None:
- """Get requester manifest by name"""
for component in self.requester_components:
if component.metadata.name == name:
return component
diff --git a/src/langbot/pkg/provider/modelmgr/requester.py b/src/langbot/pkg/provider/modelmgr/requester.py
index 377f7d4a8..0c8232dd5 100644
--- a/src/langbot/pkg/provider/modelmgr/requester.py
+++ b/src/langbot/pkg/provider/modelmgr/requester.py
@@ -5,7 +5,9 @@ import typing
import time
from ...core import app
+from ...api.http.context import ExecutionContext
from ...entity.persistence import model as persistence_model
+from ...workspace.errors import WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from . import token
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -16,6 +18,20 @@ LLM_USAGE_QUERY_VARIABLE = '_llm_usage'
STREAM_USAGE_QUERY_VARIABLE = '_stream_usage'
+def _ensure_same_execution_scope(
+ expected: ExecutionContext,
+ actual: ExecutionContext,
+ *,
+ resource: str,
+) -> None:
+ if (
+ actual.instance_uuid != expected.instance_uuid
+ or actual.workspace_uuid != expected.workspace_uuid
+ or actual.placement_generation != expected.placement_generation
+ ):
+ raise WorkspaceInvariantError(f'{resource} belongs to another Workspace execution scope')
+
+
def _store_llm_usage(query: pipeline_query.Query | None, usage_info: dict | None) -> None:
"""Store the latest provider usage on the query for upstream action handlers."""
if query is None or not usage_info:
@@ -39,24 +55,61 @@ class RuntimeProvider:
def __init__(
self,
+ execution_context: ExecutionContext,
provider_entity: persistence_model.ModelProvider,
token_mgr: token.TokenManager,
requester: ProviderAPIRequester,
):
+ if provider_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceInvariantError('Provider belongs to another Workspace')
+ self.execution_context = execution_context
self.provider_entity = provider_entity
self.token_mgr = token_mgr
self.requester = requester
+ def _validate_invocation(
+ self,
+ model: RuntimeLLMModel | RuntimeEmbeddingModel | RuntimeRerankModel,
+ execution_context: ExecutionContext,
+ ) -> None:
+ _ensure_same_execution_scope(self.execution_context, execution_context, resource='Provider invocation')
+ _ensure_same_execution_scope(self.execution_context, model.execution_context, resource='Runtime model')
+ if model.provider is not self:
+ raise WorkspaceInvariantError('Runtime model is attached to another provider')
+
+ def _resolve_llm_execution_context(
+ self,
+ query: pipeline_query.Query | None,
+ execution_context: ExecutionContext | None,
+ ) -> ExecutionContext:
+ if query is not None:
+ from ...pipeline.pool import get_query_execution_context
+
+ query_context = get_query_execution_context(query)
+ if execution_context is not None:
+ _ensure_same_execution_scope(
+ query_context,
+ execution_context,
+ resource='Explicit LLM invocation context',
+ )
+ return query_context
+ if execution_context is None:
+ raise WorkspaceInvariantError('LLM invocation requires an ExecutionContext when query is absent')
+ return execution_context
+
async def invoke_llm(
self,
- query: pipeline_query.Query,
+ query: pipeline_query.Query | None,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
+ execution_context: ExecutionContext | None = None,
) -> provider_message.Message:
"""Bridge method for invoking LLM with monitoring"""
+ invocation_context = self._resolve_llm_execution_context(query, execution_context)
+ self._validate_invocation(model, invocation_context)
# Start timing for monitoring
start_time = time.time()
input_tokens = 0
@@ -130,14 +183,17 @@ class RuntimeProvider:
async def invoke_llm_stream(
self,
- query: pipeline_query.Query,
+ query: pipeline_query.Query | None,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
+ execution_context: ExecutionContext | None = None,
) -> provider_message.MessageChunk:
"""Bridge method for invoking LLM stream with monitoring"""
+ invocation_context = self._resolve_llm_execution_context(query, execution_context)
+ self._validate_invocation(model, invocation_context)
# Start timing for monitoring
start_time = time.time()
status = 'success'
@@ -212,6 +268,8 @@ class RuntimeProvider:
model: RuntimeEmbeddingModel,
input_text: typing.List[str],
extra_args: dict[str, typing.Any] = {},
+ *,
+ execution_context: ExecutionContext,
knowledge_base_id: str | None = None,
query_text: str | None = None,
session_id: str | None = None,
@@ -219,6 +277,7 @@ class RuntimeProvider:
call_type: str | None = None,
) -> typing.List[typing.List[float]]:
"""Bridge method for invoking embedding with monitoring"""
+ self._validate_invocation(model, execution_context)
# Start timing for monitoring
start_time = time.time()
prompt_tokens = 0
@@ -254,6 +313,7 @@ class RuntimeProvider:
try:
await self.requester.ap.monitoring_service.record_embedding_call(
+ execution_context,
model_name=model.model_entity.name,
prompt_tokens=prompt_tokens,
total_tokens=total_tokens,
@@ -276,8 +336,11 @@ class RuntimeProvider:
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
+ *,
+ execution_context: ExecutionContext,
) -> typing.List[dict]:
"""Bridge method for invoking rerank with monitoring"""
+ self._validate_invocation(model, execution_context)
start_time = time.time()
status = 'success'
@@ -316,9 +379,16 @@ class RuntimeLLMModel:
def __init__(
self,
+ execution_context: ExecutionContext,
model_entity: persistence_model.LLMModel,
provider: RuntimeProvider,
):
+ _ensure_same_execution_scope(provider.execution_context, execution_context, resource='LLM model')
+ if model_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceInvariantError('LLM model belongs to another Workspace')
+ if model_entity.provider_uuid != provider.provider_entity.uuid:
+ raise WorkspaceInvariantError('LLM model references another provider')
+ self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
@@ -334,9 +404,16 @@ class RuntimeEmbeddingModel:
def __init__(
self,
+ execution_context: ExecutionContext,
model_entity: persistence_model.EmbeddingModel,
provider: RuntimeProvider,
):
+ _ensure_same_execution_scope(provider.execution_context, execution_context, resource='Embedding model')
+ if model_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceInvariantError('Embedding model belongs to another Workspace')
+ if model_entity.provider_uuid != provider.provider_entity.uuid:
+ raise WorkspaceInvariantError('Embedding model references another provider')
+ self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
@@ -352,9 +429,16 @@ class RuntimeRerankModel:
def __init__(
self,
+ execution_context: ExecutionContext,
model_entity: persistence_model.RerankModel,
provider: RuntimeProvider,
):
+ _ensure_same_execution_scope(provider.execution_context, execution_context, resource='Rerank model')
+ if model_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceInvariantError('Rerank model belongs to another Workspace')
+ if model_entity.provider_uuid != provider.provider_entity.uuid:
+ raise WorkspaceInvariantError('Rerank model references another provider')
+ self.execution_context = execution_context
self.model_entity = model_entity
self.provider = provider
@@ -379,6 +463,17 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
async def initialize(self):
pass
+ async def aclose(self) -> None:
+ """Release requester-owned clients when its runtime provider retires.
+
+ Most built-in requesters are currently stateless, but provider
+ extensions may own connection pools or background resources. Keeping
+ the lifecycle hook on the base class lets Workspace generation changes
+ and application shutdown retire them deterministically.
+ """
+
+ return None
+
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any] | list[dict[str, typing.Any]]:
"""Scan models supported by the provider.
diff --git a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
index 9d8568437..f4daaf1df 100644
--- a/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
+++ b/src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
@@ -8,6 +8,7 @@ import litellm
from litellm import acompletion, aembedding, arerank
from .. import errors, requester
+from ....utils import httpclient
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
@@ -960,14 +961,17 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
try:
- async with httpx.AsyncClient(timeout=timeout) as client:
+ async with httpx.AsyncClient(
+ timeout=timeout,
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ ) as client:
resp = await client.post(rerank_url, headers=headers, json=payload)
resp.raise_for_status()
- data = resp.json()
+ data = await httpclient.parse_json_response(resp)
except httpx.HTTPStatusError as e:
body = ''
try:
- body = e.response.text
+ body = await httpclient.response_text(e.response)
except Exception:
pass
raise errors.RequesterError(f'rerank 请求失败 (HTTP {e.response.status_code}): {body or str(e)}')
@@ -1003,10 +1007,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
models_url = f'{base_url}/models'
try:
- async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client:
+ async with httpx.AsyncClient(
+ trust_env=True,
+ timeout=timeout,
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ ) as client:
response = await client.get(models_url, headers=headers)
response.raise_for_status()
- payload = response.json()
+ payload = await httpclient.parse_json_response(response)
models = []
for item in payload.get('data', []):
diff --git a/src/langbot/pkg/provider/runner.py b/src/langbot/pkg/provider/runner.py
index 987b3a0e9..ca6b86017 100644
--- a/src/langbot/pkg/provider/runner.py
+++ b/src/langbot/pkg/provider/runner.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import abc
+import asyncio
import typing
from typing import TYPE_CHECKING
@@ -11,6 +12,32 @@ if TYPE_CHECKING:
preregistered_runners: list[typing.Type[RequestRunner]] = []
+_DEFAULT_SYNC_ITERATION_LIMIT = 100_000
+
+_T = typing.TypeVar('_T')
+
+
+def _next_sync(iterator: typing.Iterator[_T]) -> tuple[bool, _T | None]:
+ try:
+ return True, next(iterator)
+ except StopIteration:
+ return False, None
+
+
+async def iterate_sync(
+ iterable: typing.Iterable[_T],
+ *,
+ max_items: int = _DEFAULT_SYNC_ITERATION_LIMIT,
+) -> typing.AsyncGenerator[_T, None]:
+ """Consume a blocking SDK iterator without stalling the event loop."""
+
+ iterator = iter(iterable)
+ for _ in range(max(max_items, 1)):
+ has_item, item = await asyncio.to_thread(_next_sync, iterator)
+ if not has_item:
+ return
+ yield typing.cast(_T, item)
+ raise RuntimeError('Synchronous provider stream exceeded the event limit')
def runner_class(name: str):
@@ -43,3 +70,6 @@ class RequestRunner(abc.ABC):
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
"""运行请求"""
pass
+
+ async def aclose(self) -> None:
+ """Release request-scoped resources after one runner invocation."""
diff --git a/src/langbot/pkg/provider/runners/cozeapi.py b/src/langbot/pkg/provider/runners/cozeapi.py
index 26980f81e..a7ca53408 100644
--- a/src/langbot/pkg/provider/runners/cozeapi.py
+++ b/src/langbot/pkg/provider/runners/cozeapi.py
@@ -2,7 +2,6 @@ from __future__ import annotations
import typing
import json
-import base64
from langbot.pkg.provider import runner
from langbot.pkg.core import app
@@ -11,6 +10,16 @@ from langbot.pkg.utils import image
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from langbot.libs.coze_server_api.client import AsyncCozeAPIClient
+_MAX_COZE_GENERATED_CHARS = 1024 * 1024
+_MAX_COZE_MEDIA_BYTES = 10 * 1024 * 1024
+
+
+def _append_bounded(current: str, addition: typing.Any) -> str:
+ addition = str(addition or '')
+ if len(current) + len(addition) > _MAX_COZE_GENERATED_CHARS:
+ raise ValueError('Coze response exceeds the runtime limit')
+ return current + addition
+
@runner.runner_class('coze-api')
class CozeAPIRunner(runner.RequestRunner):
@@ -77,7 +86,10 @@ class CozeAPIRunner(runner.RequestRunner):
content_parts.append({'type': 'text', 'text': ce.text})
elif ce.type == 'image_base64':
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
- file_bytes = base64.b64decode(image_b64)
+ file_bytes = await image.decode_base64_limited(
+ image_b64,
+ max_bytes=_MAX_COZE_MEDIA_BYTES,
+ )
file_id = await self._get_file_id(file_bytes)
content_parts.append({'type': 'image', 'file_id': file_id})
elif ce.type == 'file':
@@ -144,7 +156,7 @@ class CozeAPIRunner(runner.RequestRunner):
auto_save_history=self.auto_save_history,
stream=True,
):
- self.ap.logger.debug(f'coze-chat-stream: {chunk}')
+ self.ap.logger.debug(f'coze-chat-stream: {str(chunk)[:1000]}')
event_type = chunk.get('event')
data = chunk.get('data', {})
@@ -153,11 +165,17 @@ class CozeAPIRunner(runner.RequestRunner):
if event_type == 'conversation.message.delta':
# 收集内容
if 'content' in data:
- full_content += data.get('content', '')
+ full_content = _append_bounded(
+ full_content,
+ data.get('content', ''),
+ )
# 收集推理内容(如果有)
if 'reasoning_content' in data:
- full_reasoning += data.get('reasoning_content', '')
+ full_reasoning = _append_bounded(
+ full_reasoning,
+ data.get('reasoning_content', ''),
+ )
elif event_type.split('.')[-1] == 'done': # 本地部署coze时,结束event不为done
# 保存会话ID
@@ -179,6 +197,8 @@ class CozeAPIRunner(runner.RequestRunner):
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
if not remove_think:
content = f'\n{full_reasoning}\n\n{content}'.strip()
+ if len(content) > _MAX_COZE_GENERATED_CHARS:
+ raise ValueError('Coze response exceeds the runtime limit')
# 一次性返回完整内容
yield provider_message.Message(
@@ -227,7 +247,7 @@ class CozeAPIRunner(runner.RequestRunner):
auto_save_history=self.auto_save_history,
stream=True,
):
- self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}')
+ self.ap.logger.debug(f'coze-chat-stream-chunk: {str(chunk)[:1000]}')
event_type = chunk.get('event')
data = chunk.get('data', {})
@@ -263,7 +283,7 @@ class CozeAPIRunner(runner.RequestRunner):
error_msg = f'Coze API错误: {data.get("message", "未知错误")}'
yield provider_message.MessageChunk(role='assistant', content=error_msg, finish_reason='error')
return
- full_content += content
+ full_content = _append_bounded(full_content, content)
if message_idx % 8 == 0 or is_final:
if full_content:
yield provider_message.MessageChunk(role='assistant', content=full_content, is_final=is_final)
@@ -286,3 +306,6 @@ class CozeAPIRunner(runner.RequestRunner):
else:
async for msg in self._chat_messages(query):
yield msg
+
+ async def aclose(self) -> None:
+ await self.coze.close()
diff --git a/src/langbot/pkg/provider/runners/dashscopeapi.py b/src/langbot/pkg/provider/runners/dashscopeapi.py
index a2c593ccc..00af120fb 100644
--- a/src/langbot/pkg/provider/runners/dashscopeapi.py
+++ b/src/langbot/pkg/provider/runners/dashscopeapi.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
import typing
import re
@@ -10,6 +11,9 @@ from ...core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
+_MAX_DASHSCOPE_RESPONSE_CHARS = 1024 * 1024
+_MAX_DASHSCOPE_REFERENCES = 1024
+
class DashscopeAPIError(Exception):
"""Dashscope API 请求失败"""
@@ -19,6 +23,13 @@ class DashscopeAPIError(Exception):
super().__init__(self.message)
+def _append_bounded(current: str, addition: typing.Any) -> str:
+ addition = str(addition or '')
+ if len(current) + len(addition) > _MAX_DASHSCOPE_RESPONSE_CHARS:
+ raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
+ return current + addition
+
+
@runner.runner_class('dashscope-app-api')
class DashScopeAPIRunner(runner.RequestRunner):
"阿里云百炼DashsscopeAPI对话请求器"
@@ -111,18 +122,16 @@ class DashScopeAPIRunner(runner.RequestRunner):
if remove_think:
has_thoughts = False
# 发送对话请求
- response = dashscope.Application.call(
- api_key=self.api_key, # 智能体应用的API Key
- app_id=self.app_id, # 智能体应用的ID
- prompt=plain_text, # 用户输入的文本信息
- stream=True, # 流式输出
- incremental_output=True, # 增量输出,使用流式输出需要开启增量输出
- session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话
+ response = await asyncio.to_thread(
+ dashscope.Application.call,
+ api_key=self.api_key,
+ app_id=self.app_id,
+ prompt=plain_text,
+ stream=True,
+ incremental_output=True,
+ session_id=query.session.using_conversation.uuid,
enable_thinking=has_thoughts,
has_thoughts=has_thoughts,
- # rag_options={ # 主要用于文件交互,暂不支持
- # "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个
- # }
)
idx_chunk = 0
try:
@@ -131,7 +140,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
except AttributeError:
is_stream = False
if is_stream:
- for chunk in response:
+ async for chunk in runner.iterate_sync(response):
if chunk.get('status_code') != 200:
raise DashscopeAPIError(
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
@@ -145,15 +154,27 @@ class DashScopeAPIRunner(runner.RequestRunner):
if stream_think and stream_think[0].get('thought'):
if not think_start:
think_start = True
- pending_content += f'\n{stream_think[0].get("thought")}'
+ pending_content = _append_bounded(
+ pending_content,
+ f'\n{stream_think[0].get("thought")}',
+ )
else:
# 继续输出 reasoning_content
- pending_content += stream_think[0].get('thought')
+ pending_content = _append_bounded(
+ pending_content,
+ stream_think[0].get('thought'),
+ )
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
think_end = True
- pending_content += '\n\n'
+ pending_content = _append_bounded(
+ pending_content,
+ '\n\n',
+ )
if stream_output.get('text') is not None:
- pending_content += stream_output.get('text')
+ pending_content = _append_bounded(
+ pending_content,
+ stream_output.get('text'),
+ )
# 是否是流式最后一个chunk
is_final = False if stream_output.get('finish_reason', False) == 'null' else True
@@ -162,12 +183,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 从模型传出的参考资料信息中提取用于替换的字典
if references_dict_list is not None:
- for doc in references_dict_list:
+ for doc in references_dict_list[:_MAX_DASHSCOPE_REFERENCES]:
if doc.get('index_id') is not None:
references_dict[doc.get('index_id')] = doc.get('doc_name')
# 将参考资料替换到文本中
pending_content = self._replace_references(pending_content, references_dict)
+ if len(pending_content) > _MAX_DASHSCOPE_RESPONSE_CHARS:
+ raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
if idx_chunk % 8 == 0 or is_final:
yield provider_message.MessageChunk(
@@ -178,7 +201,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 保存当前会话的session_id用于下次对话的语境
query.session.using_conversation.uuid = stream_output.get('session_id')
else:
- for chunk in response:
+ async for chunk in runner.iterate_sync(response):
if chunk.get('status_code') != 200:
raise DashscopeAPIError(
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
@@ -192,15 +215,27 @@ class DashScopeAPIRunner(runner.RequestRunner):
if stream_think and stream_think[0].get('thought'):
if not think_start:
think_start = True
- pending_content += f'\n{stream_think[0].get("thought")}'
+ pending_content = _append_bounded(
+ pending_content,
+ f'\n{stream_think[0].get("thought")}',
+ )
else:
# 继续输出 reasoning_content
- pending_content += stream_think[0].get('thought')
+ pending_content = _append_bounded(
+ pending_content,
+ stream_think[0].get('thought'),
+ )
elif think_start and (not stream_think or stream_think[0].get('thought') == '') and not think_end:
think_end = True
- pending_content += '\n\n'
+ pending_content = _append_bounded(
+ pending_content,
+ '\n\n',
+ )
if stream_output.get('text') is not None:
- pending_content += stream_output.get('text')
+ pending_content = _append_bounded(
+ pending_content,
+ stream_output.get('text'),
+ )
# 保存当前会话的session_id用于下次对话的语境
query.session.using_conversation.uuid = stream_output.get('session_id')
@@ -210,12 +245,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 从模型传出的参考资料信息中提取用于替换的字典
if references_dict_list is not None:
- for doc in references_dict_list:
+ for doc in references_dict_list[:_MAX_DASHSCOPE_REFERENCES]:
if doc.get('index_id') is not None:
references_dict[doc.get('index_id')] = doc.get('doc_name')
# 将参考资料替换到文本中
- pending_content = self._replace_references(pending_content, references_dict)
+ pending_content = self._replace_references(pending_content, references_dict)
+ if len(pending_content) > _MAX_DASHSCOPE_RESPONSE_CHARS:
+ raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
yield provider_message.Message(
role='assistant',
@@ -240,18 +277,16 @@ class DashScopeAPIRunner(runner.RequestRunner):
biz_params.update(query.variables)
# 发送对话请求
- response = dashscope.Application.call(
- api_key=self.api_key, # 智能体应用的API Key
- app_id=self.app_id, # 智能体应用的ID
- prompt=plain_text, # 用户输入的文本信息
- stream=True, # 流式输出
- incremental_output=True, # 增量输出,使用流式输出需要开启增量输出
- session_id=query.session.using_conversation.uuid, # 会话ID用于,多轮对话
- biz_params=biz_params, # 工作流应用的自定义输入参数传递
- flow_stream_mode='message_format', # 消息模式,输出/结束节点的流式结果
- # rag_options={ # 主要用于文件交互,暂不支持
- # "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个
- # }
+ response = await asyncio.to_thread(
+ dashscope.Application.call,
+ api_key=self.api_key,
+ app_id=self.app_id,
+ prompt=plain_text,
+ stream=True,
+ incremental_output=True,
+ session_id=query.session.using_conversation.uuid,
+ biz_params=biz_params,
+ flow_stream_mode='message_format',
)
# 处理API返回的流式输出
@@ -262,7 +297,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
is_stream = False
idx_chunk = 0
if is_stream:
- for chunk in response:
+ async for chunk in runner.iterate_sync(response):
if chunk.get('status_code') != 200:
raise DashscopeAPIError(
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
@@ -273,7 +308,10 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 获取流式传输的output
stream_output = chunk.get('output', {})
if stream_output.get('workflow_message') is not None:
- pending_content += stream_output.get('workflow_message').get('message').get('content')
+ pending_content = _append_bounded(
+ pending_content,
+ stream_output.get('workflow_message').get('message').get('content'),
+ )
# if stream_output.get('text') is not None:
# pending_content += stream_output.get('text')
@@ -284,12 +322,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 从模型传出的参考资料信息中提取用于替换的字典
if references_dict_list is not None:
- for doc in references_dict_list:
+ for doc in references_dict_list[:_MAX_DASHSCOPE_REFERENCES]:
if doc.get('index_id') is not None:
references_dict[doc.get('index_id')] = doc.get('doc_name')
# 将参考资料替换到文本中
pending_content = self._replace_references(pending_content, references_dict)
+ if len(pending_content) > _MAX_DASHSCOPE_RESPONSE_CHARS:
+ raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
if idx_chunk % 8 == 0 or is_final:
yield provider_message.MessageChunk(
role='assistant',
@@ -301,7 +341,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
query.session.using_conversation.uuid = stream_output.get('session_id')
else:
- for chunk in response:
+ async for chunk in runner.iterate_sync(response):
if chunk.get('status_code') != 200:
raise DashscopeAPIError(
f'Dashscope API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
@@ -312,7 +352,10 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 获取流式传输的output
stream_output = chunk.get('output', {})
if stream_output.get('text') is not None:
- pending_content += stream_output.get('text')
+ pending_content = _append_bounded(
+ pending_content,
+ stream_output.get('text'),
+ )
is_final = False if stream_output.get('finish_reason', False) == 'null' else True
@@ -324,12 +367,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 从模型传出的参考资料信息中提取用于替换的字典
if references_dict_list is not None:
- for doc in references_dict_list:
+ for doc in references_dict_list[:_MAX_DASHSCOPE_REFERENCES]:
if doc.get('index_id') is not None:
references_dict[doc.get('index_id')] = doc.get('doc_name')
# 将参考资料替换到文本中
- pending_content = self._replace_references(pending_content, references_dict)
+ pending_content = self._replace_references(pending_content, references_dict)
+ if len(pending_content) > _MAX_DASHSCOPE_RESPONSE_CHARS:
+ raise DashscopeAPIError('Dashscope response exceeds the runtime limit')
yield provider_message.Message(
role='assistant',
diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py
index 7566d20aa..d3c175080 100644
--- a/src/langbot/pkg/provider/runners/difysvapi.py
+++ b/src/langbot/pkg/provider/runners/difysvapi.py
@@ -1,10 +1,11 @@
from __future__ import annotations
+import asyncio
+import heapq
import typing
import json
import time
import uuid
-import base64
import mimetypes
import os
import re
@@ -16,19 +17,40 @@ from langbot.pkg.provider import runner
from langbot.pkg.core import app
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.platform.message as platform_message
-from langbot.pkg.utils import image
+from langbot.pkg.utils import httpclient, image
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from langbot.libs.dify_service_api.v1 import client, errors
-import httpx
-
-# Module-level store for paused-workflow form state. The key isolates the bot,
-# pipeline, adapter, and launcher; each value holds an insertion-ordered map of
-# form_token -> form_data so one conversation can pause multiple workflows.
-PendingFormKey = tuple[str, str, str, str, str]
+# Module-level store for paused-workflow form state. The key includes the full
+# execution scope before the bot, pipeline, adapter, and launcher dimensions;
+# each value holds an insertion-ordered map of form_token -> form_data so one
+# conversation can pause multiple workflows without crossing Workspaces or
+# placement generations.
+PendingFormKey = tuple[str, str, int, str, str, str, str, str]
_PENDING_FORMS: dict[PendingFormKey, 'OrderedDict[str, dict[str, typing.Any]]'] = {}
+_PENDING_FORM_EXPIRY_HEAP: list[tuple[float, int, PendingFormKey, str]] = []
+_PENDING_FORM_ACTIVE_COUNT = 0
+_PENDING_FORM_REVISION = 0
_PENDING_FORM_DEFAULT_TTL = 30 * 60 # 30 minutes safety cap
+_PENDING_FORM_MAX_SESSIONS = 4096
+_PENDING_FORM_MAX_PER_SESSION = 16
+_PENDING_FORM_HEAP_COMPACT_FLOOR = 64
+_PENDING_FORM_HEAP_MAX_MULTIPLIER = 4
+_PENDING_FORM_REVISION_KEY = '_langbot_cache_revision'
_STREAM_FORM_PLACEHOLDER = '\u200b'
+_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
+
+
+def _read_local_file_limited(path: str) -> bytes:
+ """Read a local platform attachment without allowing an oversized allocation."""
+
+ if os.path.getsize(path) > _MAX_DIFY_UPLOAD_BYTES:
+ raise ValueError('Dify upload file exceeds the size limit')
+ with open(path, 'rb') as file:
+ content = file.read(_MAX_DIFY_UPLOAD_BYTES + 1)
+ if len(content) > _MAX_DIFY_UPLOAD_BYTES:
+ raise ValueError('Dify upload file exceeds the size limit')
+ return content
def _merge_stream_text(accumulated: str, incoming: typing.Any) -> str:
@@ -48,10 +70,13 @@ def _dify_user_from_query(query: pipeline_query.Query) -> str:
def _session_key_from_query(query: pipeline_query.Query) -> PendingFormKey:
- """Build a process-local pending-form key isolated by bot and pipeline."""
+ """Build a process-local pending-form key isolated by execution scope."""
adapter = getattr(query, 'adapter', None)
adapter_type = f'{type(adapter).__module__}.{type(adapter).__qualname__}'
return (
+ str(getattr(query, 'instance_uuid', '') or ''),
+ str(getattr(query, 'workspace_uuid', '') or ''),
+ int(getattr(query, 'placement_generation', 0) or 0),
str(getattr(query, 'bot_uuid', '') or ''),
str(getattr(query, 'pipeline_uuid', '') or ''),
adapter_type,
@@ -60,22 +85,103 @@ def _session_key_from_query(query: pipeline_query.Query) -> PendingFormKey:
)
+def _synchronize_pending_form_cache_if_externally_cleared() -> None:
+ """Keep test/debug direct cache clears from retaining stale heap entries."""
+
+ global _PENDING_FORM_ACTIVE_COUNT
+ if _PENDING_FORMS:
+ return
+ _PENDING_FORM_EXPIRY_HEAP.clear()
+ _PENDING_FORM_ACTIVE_COUNT = 0
+
+
+def _pending_form_entry_is_current(
+ expires_at: float,
+ revision: int,
+ session_key: PendingFormKey,
+ form_token: str,
+) -> bool:
+ forms = _PENDING_FORMS.get(session_key)
+ if forms is None:
+ return False
+ stored = forms.get(form_token)
+ if stored is None:
+ return False
+ return stored.get(_PENDING_FORM_REVISION_KEY) == revision and stored.get('_expires_at') == expires_at
+
+
+def _peek_valid_pending_form_expiry(
+ *,
+ pop: bool = False,
+) -> tuple[float, int, PendingFormKey, str] | None:
+ while _PENDING_FORM_EXPIRY_HEAP:
+ entry = _PENDING_FORM_EXPIRY_HEAP[0]
+ if _pending_form_entry_is_current(*entry):
+ if pop:
+ heapq.heappop(_PENDING_FORM_EXPIRY_HEAP)
+ return entry
+ heapq.heappop(_PENDING_FORM_EXPIRY_HEAP)
+ return None
+
+
+def _drop_pending_form(session_key: PendingFormKey, form_token: str) -> None:
+ global _PENDING_FORM_ACTIVE_COUNT
+ forms = _PENDING_FORMS.get(session_key)
+ if forms is None or forms.pop(form_token, None) is None:
+ return
+ _PENDING_FORM_ACTIVE_COUNT = max(_PENDING_FORM_ACTIVE_COUNT - 1, 0)
+ if not forms:
+ _PENDING_FORMS.pop(session_key, None)
+
+
+def _drop_pending_form_session(session_key: PendingFormKey) -> None:
+ global _PENDING_FORM_ACTIVE_COUNT
+ forms = _PENDING_FORMS.pop(session_key, None)
+ if forms is not None:
+ _PENDING_FORM_ACTIVE_COUNT = max(
+ _PENDING_FORM_ACTIVE_COUNT - len(forms),
+ 0,
+ )
+
+
+def _compact_pending_form_expiry_heap_if_needed() -> None:
+ max_heap_entries = max(
+ _PENDING_FORM_HEAP_COMPACT_FLOOR,
+ _PENDING_FORM_ACTIVE_COUNT * _PENDING_FORM_HEAP_MAX_MULTIPLIER,
+ )
+ if len(_PENDING_FORM_EXPIRY_HEAP) <= max_heap_entries:
+ return
+ _PENDING_FORM_EXPIRY_HEAP[:] = [
+ (
+ float(stored['_expires_at']),
+ int(stored[_PENDING_FORM_REVISION_KEY]),
+ session_key,
+ form_token,
+ )
+ for session_key, forms in _PENDING_FORMS.items()
+ for form_token, stored in forms.items()
+ ]
+ heapq.heapify(_PENDING_FORM_EXPIRY_HEAP)
+
+
def _prune_pending_forms(now: float | None = None) -> None:
+ _synchronize_pending_form_cache_if_externally_cleared()
if now is None:
now = time.time()
- for session_key in list(_PENDING_FORMS.keys()):
- forms = _PENDING_FORMS[session_key]
- expired_tokens = [token for token, data in forms.items() if data.get('_expires_at', 0) <= now]
- for token in expired_tokens:
- forms.pop(token, None)
- if not forms:
- _PENDING_FORMS.pop(session_key, None)
+ while True:
+ entry = _peek_valid_pending_form_expiry()
+ if entry is None or entry[0] > now:
+ break
+ _, _, session_key, form_token = _peek_valid_pending_form_expiry(pop=True)
+ _drop_pending_form(session_key, form_token)
+ _compact_pending_form_expiry_heap_if_needed()
def _set_pending_form(session_key: PendingFormKey, form_data: dict[str, typing.Any]) -> None:
+ global _PENDING_FORM_ACTIVE_COUNT, _PENDING_FORM_REVISION
_prune_pending_forms()
- if isinstance(session_key, tuple) and len(session_key) > 1:
- form_data['pipeline_uuid'] = session_key[1]
+ if isinstance(session_key, tuple) and len(session_key) == 8:
+ form_data['pipeline_uuid'] = session_key[4]
stored = dict(form_data)
expiration_time = stored.get('expiration_time')
try:
@@ -83,11 +189,31 @@ def _set_pending_form(session_key: PendingFormKey, form_data: dict[str, typing.A
except (TypeError, ValueError):
expiration_ts = 0.0
stored['_expires_at'] = expiration_ts or (time.time() + _PENDING_FORM_DEFAULT_TTL)
+ _PENDING_FORM_REVISION += 1
+ stored[_PENDING_FORM_REVISION_KEY] = _PENDING_FORM_REVISION
form_token = str(stored.get('form_token') or '')
forms = _PENDING_FORMS.setdefault(session_key, OrderedDict())
# Re-insert at the end so this becomes the "latest" entry
- forms.pop(form_token, None)
+ if forms.pop(form_token, None) is None:
+ _PENDING_FORM_ACTIVE_COUNT += 1
forms[form_token] = stored
+ heapq.heappush(
+ _PENDING_FORM_EXPIRY_HEAP,
+ (
+ stored['_expires_at'],
+ _PENDING_FORM_REVISION,
+ session_key,
+ form_token,
+ ),
+ )
+ while len(forms) > _PENDING_FORM_MAX_PER_SESSION:
+ oldest_token = next(iter(forms))
+ _drop_pending_form(session_key, oldest_token)
+ if len(_PENDING_FORMS) > _PENDING_FORM_MAX_SESSIONS:
+ oldest_entry = _peek_valid_pending_form_expiry()
+ if oldest_entry is not None:
+ _drop_pending_form_session(oldest_entry[2])
+ _compact_pending_form_expiry_heap_if_needed()
def _get_pending_form_by_token(session_key: PendingFormKey, form_token: str) -> dict[str, typing.Any] | None:
@@ -139,11 +265,11 @@ def _clear_pending_form(session_key: PendingFormKey, form_token: str | None = No
if not forms:
return
if form_token is None:
- _PENDING_FORMS.pop(session_key, None)
+ _drop_pending_form_session(session_key)
+ _compact_pending_form_expiry_heap_if_needed()
return
- forms.pop(form_token, None)
- if not forms:
- _PENDING_FORMS.pop(session_key, None)
+ _drop_pending_form(session_key, form_token)
+ _compact_pending_form_expiry_heap_if_needed()
def _format_human_input_text(
@@ -716,6 +842,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
base_url=self.pipeline_config['ai']['dify-service-api']['base-url'],
)
+ async def aclose(self) -> None:
+ await self.dify_client.aclose()
+
def _process_thinking_content(
self,
content: str,
@@ -791,13 +920,16 @@ class DifyServiceAPIRunner(runner.RequestRunner):
async def download_file(file_url: str) -> tuple[bytes, str]:
"""Download file from url (supports data url)."""
- async with httpx.AsyncClient() as client_session:
- resp = await client_session.get(file_url)
+ client_session = httpclient.get_session()
+ async with client_session.get(file_url, timeout=120) as resp:
resp.raise_for_status()
content_type = (
resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'
)
- return resp.content, content_type
+ return (
+ await httpclient.read_limited(resp, max_bytes=_MAX_DIFY_UPLOAD_BYTES),
+ content_type,
+ )
def _detect_file_type(content_type: str) -> str:
"""Map MIME to dify file type."""
@@ -815,7 +947,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
plain_text += ce.text
elif ce.type == 'image_base64':
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
- file_bytes = base64.b64decode(image_b64)
+ file_bytes = await image.decode_base64_limited(
+ image_b64,
+ max_bytes=_MAX_DIFY_UPLOAD_BYTES,
+ )
image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}')
upload_files.append({'type': 'image', 'id': image_id})
elif ce.type == 'file_url':
@@ -835,7 +970,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
content_type = 'application/octet-stream'
if ';' in header:
content_type = header.split(';')[0][5:] or content_type
- file_bytes = base64.b64decode(b64_data)
+ file_bytes = await image.decode_base64_limited(
+ b64_data,
+ max_bytes=_MAX_DIFY_UPLOAD_BYTES,
+ )
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
file_type = _detect_file_type(content_type)
upload_files.append({'type': file_type, 'id': file_id})
@@ -860,15 +998,19 @@ class DifyServiceAPIRunner(runner.RequestRunner):
}
async def _download_file_for_form(self, file_url: str) -> tuple[bytes, str, str]:
- async with httpx.AsyncClient() as client_session:
- resp = await client_session.get(file_url)
+ client_session = httpclient.get_session()
+ async with client_session.get(file_url, timeout=120) as resp:
resp.raise_for_status()
content_type = (
resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'
)
parsed = urlparse(file_url)
file_name = os.path.basename(parsed.path) or 'file'
- return resp.content, content_type, file_name
+ return (
+ await httpclient.read_limited(resp, max_bytes=_MAX_DIFY_UPLOAD_BYTES),
+ content_type,
+ file_name,
+ )
async def _platform_file_to_dify(self, item: typing.Any, user: str) -> dict | None:
try:
@@ -885,13 +1027,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
content_type = header.split(';', 1)[0][5:] or content_type
return await self._upload_file_bytes_for_user(
file_name,
- base64.b64decode(b64_data),
+ await image.decode_base64_limited(
+ b64_data,
+ max_bytes=_MAX_DIFY_UPLOAD_BYTES,
+ ),
content_type,
user,
)
if item.path:
- with open(item.path, 'rb') as f:
- file_bytes = f.read()
+ file_bytes = await asyncio.to_thread(_read_local_file_limited, str(item.path))
content_type = mimetypes.guess_type(str(item.path))[0] or 'application/octet-stream'
file_name = item.name or os.path.basename(str(item.path)) or 'file'
return await self._upload_file_bytes_for_user(file_name, file_bytes, content_type, user)
diff --git a/src/langbot/pkg/provider/runners/langflowapi.py b/src/langbot/pkg/provider/runners/langflowapi.py
index 8995476d3..10c66df59 100644
--- a/src/langbot/pkg/provider/runners/langflowapi.py
+++ b/src/langbot/pkg/provider/runners/langflowapi.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import codecs
import typing
import json
import httpx
@@ -11,6 +12,44 @@ from ...core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
+_MAX_LANGFLOW_LINE_CHARS = 1024 * 1024
+_MAX_LANGFLOW_TOTAL_BYTES = 16 * 1024 * 1024
+_MAX_LANGFLOW_RESPONSE_BYTES = 1024 * 1024
+
+
+async def _iter_limited_lines(
+ response: httpx.Response,
+) -> typing.AsyncGenerator[str, None]:
+ decoder = codecs.getincrementaldecoder('utf-8')('replace')
+ buffer = ''
+ total_bytes = 0
+ async for chunk in response.aiter_bytes(chunk_size=8192):
+ total_bytes += len(chunk)
+ if total_bytes > _MAX_LANGFLOW_TOTAL_BYTES:
+ raise ValueError('Langflow stream exceeds the runtime limit')
+ buffer += decoder.decode(chunk)
+ while '\n' in buffer:
+ line, buffer = buffer.split('\n', 1)
+ if len(line) > _MAX_LANGFLOW_LINE_CHARS:
+ raise ValueError('Langflow event exceeds the runtime limit')
+ yield line.rstrip('\r')
+ if len(buffer) > _MAX_LANGFLOW_LINE_CHARS:
+ raise ValueError('Langflow event exceeds the runtime limit')
+ buffer += decoder.decode(b'', final=True)
+ if buffer:
+ if len(buffer) > _MAX_LANGFLOW_LINE_CHARS:
+ raise ValueError('Langflow event exceeds the runtime limit')
+ yield buffer.rstrip('\r')
+
+
+async def _read_limited_response(response: httpx.Response) -> bytes:
+ body = bytearray()
+ async for chunk in response.aiter_bytes(chunk_size=8192):
+ body.extend(chunk)
+ if len(body) > _MAX_LANGFLOW_RESPONSE_BYTES:
+ raise ValueError('Langflow response exceeds the runtime limit')
+ return bytes(body)
+
@runner.runner_class('langflow-api')
class LangflowAPIRunner(runner.RequestRunner):
@@ -99,7 +138,7 @@ class LangflowAPIRunner(runner.RequestRunner):
accumulated_content = ''
message_count = 0
- async for line in response.aiter_lines():
+ async for line in _iter_limited_lines(response):
data_str = line
if data_str.startswith('data: '):
@@ -144,11 +183,15 @@ class LangflowAPIRunner(runner.RequestRunner):
yield provider_message.MessageChunk(role='assistant', content=accumulated_content, is_final=True)
else:
# 非流式请求
- response = await client.post(url, json=payload, headers=headers, timeout=120.0)
- response.raise_for_status()
-
- # 解析响应
- response_data = response.json()
+ async with client.stream(
+ 'POST',
+ url,
+ json=payload,
+ headers=headers,
+ timeout=120.0,
+ ) as response:
+ response.raise_for_status()
+ response_data = json.loads(await _read_limited_response(response))
# 提取消息内容
# 根据Langflow API文档,响应结构可能在outputs[0].outputs[0].outputs.message.message中
diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py
index ac5cc3fbf..dc8f82dcb 100644
--- a/src/langbot/pkg/provider/runners/localagent.py
+++ b/src/langbot/pkg/provider/runners/localagent.py
@@ -11,6 +11,7 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.rag.context as rag_context
+from ...pipeline.pool import get_query_execution_context
rag_combined_prompt_template = """
The following are relevant context entries retrieved from the knowledge base.
@@ -210,7 +211,7 @@ class LocalAgentRunner(runner.RequestRunner):
req_messages.append(
provider_message.Message(
role='system',
- content=self.ap.box_service.get_system_guidance(query.query_id),
+ content=self.ap.box_service.get_system_guidance(query),
)
)
@@ -223,11 +224,15 @@ class LocalAgentRunner(runner.RequestRunner):
) -> list[modelmgr_requester.RuntimeLLMModel]:
"""Build ordered list of models to try: primary model + fallback models."""
candidates = []
+ execution_context = get_query_execution_context(query)
# Primary model
if query.use_llm_model_uuid:
try:
- primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
+ primary = await self.ap.model_mgr.get_model_by_uuid(
+ execution_context,
+ query.use_llm_model_uuid,
+ )
candidates.append(primary)
except ValueError:
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
@@ -236,7 +241,10 @@ class LocalAgentRunner(runner.RequestRunner):
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
for fb_uuid in fallback_uuids:
try:
- fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
+ fb_model = await self.ap.model_mgr.get_model_by_uuid(
+ execution_context,
+ fb_uuid,
+ )
candidates.append(fb_model)
except ValueError:
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
@@ -346,12 +354,13 @@ class LocalAgentRunner(runner.RequestRunner):
if kb_uuids and user_message_text:
# only support text for now
all_results: list[rag_context.RetrievalResultEntry] = []
+ execution_context = get_query_execution_context(query)
kb_engine_plugins: set[str] = set()
# Retrieve from each knowledge base
for kb_uuid in kb_uuids:
- kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
+ kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(execution_context, kb_uuid)
if not kb:
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
@@ -364,6 +373,7 @@ class LocalAgentRunner(runner.RequestRunner):
kb_engine_plugins.add(engine_plugin_id)
result = await kb.retrieve(
+ execution_context,
user_message_text,
settings={
'bot_uuid': query.bot_uuid or '',
@@ -398,7 +408,10 @@ class LocalAgentRunner(runner.RequestRunner):
)
if all_results and rerank_model_uuid:
try:
- rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
+ rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(
+ execution_context,
+ rerank_model_uuid,
+ )
rerank_top_k = int(local_agent_config.get('rerank-top-k', 5))
doc_texts = []
@@ -411,6 +424,7 @@ class LocalAgentRunner(runner.RequestRunner):
model=rerank_model,
query=user_message_text,
documents=doc_texts_capped,
+ execution_context=execution_context,
)
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
diff --git a/src/langbot/pkg/provider/runners/n8nsvapi.py b/src/langbot/pkg/provider/runners/n8nsvapi.py
index 543fd7ef9..24ef7f59c 100644
--- a/src/langbot/pkg/provider/runners/n8nsvapi.py
+++ b/src/langbot/pkg/provider/runners/n8nsvapi.py
@@ -12,6 +12,8 @@ from ...core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
+_MAX_N8N_RESPONSE_CHARS = 1024 * 1024
+
class N8nAPIError(Exception):
"""N8n API 请求失败"""
@@ -94,6 +96,8 @@ class N8nServiceAPIRunner(runner.RequestRunner):
else:
chunk_str = str(raw_chunk)
+ if len(full_text) + len(chunk_str) > _MAX_N8N_RESPONSE_CHARS:
+ raise N8nAPIError('n8n response exceeds the runtime limit')
full_text += chunk_str
buffer += chunk_str
@@ -112,7 +116,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
if obj.get('type') == 'item' and 'content' in obj:
chunk_idx += 1
- content = obj['content']
+ content = str(obj['content'])
+ if len(full_content) + len(content) > _MAX_N8N_RESPONSE_CHARS:
+ raise N8nAPIError('n8n response exceeds the runtime limit')
full_content += content
elif obj.get('type') == 'end':
is_final = True
@@ -128,6 +134,8 @@ class N8nServiceAPIRunner(runner.RequestRunner):
except json.JSONDecodeError:
# buffer 末尾可能是一个不完整的 JSON,等待更多数据
break
+ except N8nAPIError:
+ raise
except Exception as e:
# 记录解析失败并继续接收后续 chunk
try:
@@ -255,7 +263,12 @@ class N8nServiceAPIRunner(runner.RequestRunner):
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
if response.status != 200:
- error_text = await response.text()
+ error_text = (
+ await httpclient.read_limited(
+ response,
+ max_bytes=_MAX_N8N_RESPONSE_CHARS,
+ )
+ ).decode('utf-8', errors='replace')
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
diff --git a/src/langbot/pkg/provider/runners/tboxapi.py b/src/langbot/pkg/provider/runners/tboxapi.py
index 0fb22a642..9072daa84 100644
--- a/src/langbot/pkg/provider/runners/tboxapi.py
+++ b/src/langbot/pkg/provider/runners/tboxapi.py
@@ -1,8 +1,9 @@
from __future__ import annotations
+import asyncio
import typing
import json
-import base64
+import logging
import tempfile
import os
@@ -11,10 +12,13 @@ from tboxsdk.model.file import File, FileType
from .. import runner
from ...core import app
-from ...utils import image
+from ...utils import bounded_executor, image
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
+_MAX_TBOX_RESPONSE_CHARS = 1024 * 1024
+_MAX_TBOX_MEDIA_BYTES = 10 * 1024 * 1024
+
class TboxAPIError(Exception):
"""TBox API 请求失败"""
@@ -24,6 +28,19 @@ class TboxAPIError(Exception):
super().__init__(self.message)
+def _append_bounded(current: str, addition: typing.Any) -> str:
+ addition = str(addition or '')
+ if len(current) + len(addition) > _MAX_TBOX_RESPONSE_CHARS:
+ raise TboxAPIError('Tbox response exceeds the runtime limit')
+ return current + addition
+
+
+def _write_temp_media(file_bytes: bytes, suffix: str) -> str:
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp_file:
+ tmp_file.write(file_bytes)
+ return tmp_file.name
+
+
@runner.runner_class('tbox-app-api')
class TboxAPIRunner(runner.RequestRunner):
"蚂蚁百宝箱API对话请求器"
@@ -42,6 +59,7 @@ class TboxAPIRunner(runner.RequestRunner):
self.api_key = self.pipeline_config['ai']['tbox-app-api']['api-key']
# 初始化Tbox client
+ logging.getLogger('tbox.client').setLevel(logging.WARNING)
self.tbox_client = TboxClient(authorization=self.api_key)
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]:
@@ -59,19 +77,29 @@ class TboxAPIRunner(runner.RequestRunner):
plain_text += ce.text
elif ce.type == 'image_base64':
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
- # 创建临时文件
- file_bytes = base64.b64decode(image_b64)
+ file_bytes = await image.decode_base64_limited(
+ image_b64,
+ max_bytes=_MAX_TBOX_MEDIA_BYTES,
+ )
+ tmp_file_path: str | None = None
try:
- with tempfile.NamedTemporaryFile(suffix=f'.{image_format}', delete=False) as tmp_file:
- tmp_file.write(file_bytes)
- tmp_file_path = tmp_file.name
- file_upload_resp = self.tbox_client.upload_file(tmp_file_path)
+ tmp_file_path = await asyncio.to_thread(
+ _write_temp_media,
+ file_bytes,
+ f'.{image_format}',
+ )
+ file_upload_resp = await asyncio.to_thread(
+ self.tbox_client.upload_file,
+ tmp_file_path,
+ )
image_id = file_upload_resp.get('data', '')
image_ids.append(image_id)
finally:
- # 清理临时文件
- if os.path.exists(tmp_file_path):
- os.unlink(tmp_file_path)
+ if tmp_file_path and os.path.exists(tmp_file_path):
+ await bounded_executor.run_blocking_cleanup(
+ os.unlink,
+ tmp_file_path,
+ )
elif isinstance(query.user_message.content, str):
plain_text = query.user_message.content
@@ -98,18 +126,23 @@ class TboxAPIRunner(runner.RequestRunner):
files = [File(file_id=image_id, type=FileType.IMAGE) for image_id in image_ids]
# 发送对话请求
- response = self.tbox_client.chat(
- app_id=self.app_id, # Tbox中智能体应用的ID
- user_id=query.bot_uuid, # 用户ID
- query=plain_text, # 用户输入的文本信息
- stream=is_stream, # 是否流式输出
- conversation_id=conversation_id, # 会话ID,为None时Tbox会自动创建一个新会话
- files=files, # 图片内容
+ response = await asyncio.to_thread(
+ self.tbox_client.chat,
+ app_id=self.app_id,
+ user_id=query.bot_uuid,
+ query=plain_text,
+ stream=is_stream,
+ conversation_id=conversation_id,
+ files=files,
)
if is_stream:
# 解析Tbox流式输出内容,并发送给上游
- for chunk in self._process_stream_message(response, query, remove_think):
+ async for chunk in self._process_stream_message(
+ response,
+ query,
+ remove_think,
+ ):
yield chunk
else:
message = self._process_non_stream_message(response, query, remove_think)
@@ -127,13 +160,16 @@ class TboxAPIRunner(runner.RequestRunner):
thinking_content = payload.get('reasoningContent', [])
result = ''
if thinking_content and not remove_think:
- result += f'\n{thinking_content[0].get("text", "")}\n\n'
+ result = _append_bounded(
+ result,
+ f'\n{thinking_content[0].get("text", "")}\n\n',
+ )
content = payload.get('result', [])
if content:
- result += content[0].get('chunk', '')
+ result = _append_bounded(result, content[0].get('chunk', ''))
return result
- def _process_stream_message(
+ async def _process_stream_message(
self, response: typing.Generator[dict], query: pipeline_query.Query, remove_think: bool
):
idx_msg = 0
@@ -141,7 +177,7 @@ class TboxAPIRunner(runner.RequestRunner):
conversation_id = None
think_start = False
think_end = False
- for chunk in response:
+ async for chunk in runner.iterate_sync(response):
if chunk.get('type', '') == 'chunk':
"""
Tbox返回的消息内容chunk结构
@@ -149,7 +185,10 @@ class TboxAPIRunner(runner.RequestRunner):
"""
# 如果包含思考过程,拼接
if think_start and not think_end:
- pending_content += '\n\n'
+ pending_content = _append_bounded(
+ pending_content,
+ '\n\n',
+ )
think_end = True
payload = chunk.get('payload', {})
@@ -158,7 +197,10 @@ class TboxAPIRunner(runner.RequestRunner):
query.session.using_conversation.uuid = conversation_id
if payload.get('text'):
idx_msg += 1
- pending_content += payload.get('text')
+ pending_content = _append_bounded(
+ pending_content,
+ payload.get('text'),
+ )
elif chunk.get('type', '') == 'thinking' and not remove_think:
"""
Tbox返回的思考过程chunk结构
@@ -170,9 +212,15 @@ class TboxAPIRunner(runner.RequestRunner):
content = payload.get('ext_data', {}).get('text')
if not think_start:
think_start = True
- pending_content += f'\n{content}'
+ pending_content = _append_bounded(
+ pending_content,
+ f'\n{content}',
+ )
else:
- pending_content += content
+ pending_content = _append_bounded(
+ pending_content,
+ content,
+ )
elif chunk.get('type', '') == 'error':
raise TboxAPIError(
f'Tbox API 请求失败: status_code={chunk.get("status_code")} message={chunk.get("message")} request_id={chunk.get("request_id")} '
diff --git a/src/langbot/pkg/provider/runners/weknoraapi.py b/src/langbot/pkg/provider/runners/weknoraapi.py
index 9d46eebb7..6af41548f 100644
--- a/src/langbot/pkg/provider/runners/weknoraapi.py
+++ b/src/langbot/pkg/provider/runners/weknoraapi.py
@@ -10,6 +10,15 @@ import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from langbot.libs.weknora_api import client, errors
+_MAX_WEKNORA_GENERATED_CHARS = 1024 * 1024
+
+
+def _append_bounded(current: str, addition: typing.Any) -> str:
+ addition = str(addition or '')
+ if len(current) + len(addition) > _MAX_WEKNORA_GENERATED_CHARS:
+ raise errors.WeKnoraAPIError('WeKnora response exceeds the runtime limit')
+ return current + addition
+
@runner.runner_class('weknora-api')
class WeKnoraAPIRunner(runner.RequestRunner):
@@ -94,7 +103,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
web_search_enabled=web_search_enabled,
timeout=timeout,
):
- self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk))
+ self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)[:1000])
response_type = chunk.get('response_type', '')
content = chunk.get('content', '')
@@ -120,7 +129,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
elif response_type == 'answer':
if content:
- full_answer += content
+ full_answer = _append_bounded(full_answer, content)
elif response_type == 'error':
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
@@ -158,14 +167,14 @@ class WeKnoraAPIRunner(runner.RequestRunner):
knowledge_base_ids=knowledge_base_ids,
timeout=timeout,
):
- self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk))
+ self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)[:1000])
response_type = chunk.get('response_type', '')
content = chunk.get('content', '')
if response_type == 'answer':
if content:
- full_answer += content
+ full_answer = _append_bounded(full_answer, content)
elif response_type == 'error':
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
@@ -207,7 +216,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
web_search_enabled=web_search_enabled,
timeout=timeout,
):
- self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk))
+ self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk)[:1000])
response_type = chunk.get('response_type', '')
content = chunk.get('content', '')
@@ -235,7 +244,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
elif response_type == 'answer':
message_idx += 1
if content:
- pending_answer += content
+ pending_answer = _append_bounded(pending_answer, content)
if done:
is_final = True
@@ -288,7 +297,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
knowledge_base_ids=knowledge_base_ids,
timeout=timeout,
):
- self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk))
+ self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk)[:1000])
response_type = chunk.get('response_type', '')
content = chunk.get('content', '')
@@ -297,7 +306,7 @@ class WeKnoraAPIRunner(runner.RequestRunner):
if response_type == 'answer':
message_idx += 1
if content:
- pending_answer += content
+ pending_answer = _append_bounded(pending_answer, content)
if done:
is_final = True
diff --git a/src/langbot/pkg/provider/session/sessionmgr.py b/src/langbot/pkg/provider/session/sessionmgr.py
index 8d7823651..74b9a8c40 100644
--- a/src/langbot/pkg/provider/session/sessionmgr.py
+++ b/src/langbot/pkg/provider/session/sessionmgr.py
@@ -1,42 +1,332 @@
from __future__ import annotations
import asyncio
+import dataclasses
+import heapq
+import time
-from ...core import app
from langbot_plugin.api.entities.builtin.provider import message as provider_message, prompt as provider_prompt
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from ...api.http.context import ExecutionContext
+from ...core import app
+from ...pipeline.pool import (
+ ExecutionContextMismatchError,
+ ExecutionContextRequiredError,
+ bind_execution_context,
+ get_query_execution_context,
+)
+
+SessionKey = tuple[
+ str,
+ str,
+ int,
+ str,
+ str,
+ int | str,
+]
+SessionExpiryEntry = tuple[float, int, SessionKey]
+
+_SESSION_EXPIRY_HEAP_MIN_LIMIT = 64
+_SESSION_EXPIRY_HEAP_ACTIVE_MULTIPLIER = 4
+
+
+def _query_session_key(query: pipeline_query.Query) -> tuple[SessionKey, ExecutionContext]:
+ execution_context = get_query_execution_context(query)
+ bot_uuid = getattr(query, 'bot_uuid', None)
+ if not isinstance(bot_uuid, str) or not bot_uuid.strip():
+ raise ExecutionContextRequiredError('Query.bot_uuid is required for session lookup')
+
+ execution_context = bind_execution_context(execution_context, bot_uuid=bot_uuid)
+ key: SessionKey = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ bot_uuid,
+ query.launcher_type.value,
+ query.launcher_id,
+ )
+ return key, execution_context
+
class SessionManager:
"""会话管理器"""
ap: app.Application
- session_list: list[provider_session.Session]
-
def __init__(self, ap: app.Application):
self.ap = ap
- self.session_list = []
+ self._legacy_sessions: list[provider_session.Session] = []
+ self._session_index: dict[SessionKey, provider_session.Session] = {}
+ self._session_keys_by_workspace: dict[str, set[SessionKey]] = {}
+ self._session_expiry_heap: list[SessionExpiryEntry] = []
+ self._next_access_revision = 0
+
+ @property
+ def session_list(self) -> list[provider_session.Session]:
+ """Compatibility view for API services that enumerate sessions."""
+
+ return [
+ *self._legacy_sessions,
+ *self._session_index.values(),
+ ]
+
+ @session_list.setter
+ def session_list(self, sessions: list[provider_session.Session]) -> None:
+ """Replace the cache while keeping the O(1) index consistent."""
+
+ session_values = list(sessions)
+ self._legacy_sessions = []
+ self._session_index = {}
+ self._session_keys_by_workspace = {}
+ self._session_expiry_heap = []
+ self._next_access_revision = 0
+ now = time.monotonic()
+ for session in session_values:
+ key = getattr(session, '_langbot_session_key', None)
+ if isinstance(key, tuple) and len(key) == 6:
+ self._session_index[key] = session
+ self._session_keys_by_workspace.setdefault(key[1], set()).add(key)
+ last_accessed = getattr(session, '_langbot_last_accessed', None)
+ if last_accessed is None:
+ last_accessed = now
+ self._touch_session(
+ session,
+ key,
+ float(last_accessed),
+ compact=False,
+ )
+ else:
+ self._legacy_sessions.append(session)
+ self._compact_session_expiry_heap(force=True)
+
+ def _retention_config(self) -> dict:
+ instance_config = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
+ if not isinstance(instance_config, dict):
+ return {}
+ config = instance_config.get('system', {}).get('session_retention', {})
+ return config if isinstance(config, dict) else {}
+
+ def _positive_config_int(self, name: str, default: int) -> int:
+ try:
+ value = int(self._retention_config().get(name, default))
+ except (TypeError, ValueError):
+ value = default
+ return max(value, 1)
+
+ @staticmethod
+ def _session_is_idle(session: provider_session.Session) -> bool:
+ semaphore = getattr(session, '_semaphore', None)
+ concurrency = getattr(session, '_langbot_session_concurrency', None)
+ if semaphore is None or not isinstance(concurrency, int):
+ return True
+ return getattr(semaphore, '_value', -1) == concurrency
+
+ def _remove_session(self, session: provider_session.Session) -> None:
+ key = getattr(session, '_langbot_session_key', None)
+ if isinstance(key, tuple) and len(key) == 6 and self._session_index.get(key) is session:
+ self._session_index.pop(key, None)
+ workspace_keys = self._session_keys_by_workspace.get(key[1])
+ if workspace_keys is not None:
+ workspace_keys.discard(key)
+ if not workspace_keys:
+ self._session_keys_by_workspace.pop(key[1], None)
+ else:
+ try:
+ self._legacy_sessions.remove(session)
+ except ValueError:
+ pass
+
+ def _touch_session(
+ self,
+ session: provider_session.Session,
+ key: SessionKey,
+ now: float,
+ *,
+ compact: bool = True,
+ ) -> None:
+ self._next_access_revision += 1
+ revision = self._next_access_revision
+ object.__setattr__(session, '_langbot_last_accessed', now)
+ object.__setattr__(session, '_langbot_access_revision', revision)
+ heapq.heappush(
+ self._session_expiry_heap,
+ (now, revision, key),
+ )
+ if compact:
+ self._compact_session_expiry_heap()
+
+ def _compact_session_expiry_heap(self, *, force: bool = False) -> None:
+ limit = max(
+ len(self._session_index) * _SESSION_EXPIRY_HEAP_ACTIVE_MULTIPLIER,
+ _SESSION_EXPIRY_HEAP_MIN_LIMIT,
+ )
+ if not force and len(self._session_expiry_heap) <= limit:
+ return
+ self._session_expiry_heap = [
+ (
+ float(getattr(session, '_langbot_last_accessed', 0.0)),
+ int(getattr(session, '_langbot_access_revision', 0)),
+ key,
+ )
+ for key, session in self._session_index.items()
+ ]
+ heapq.heapify(self._session_expiry_heap)
+
+ def _pop_current_expiry_entry(
+ self,
+ ) -> tuple[float, int, SessionKey, provider_session.Session] | None:
+ while self._session_expiry_heap:
+ last_accessed, revision, key = heapq.heappop(self._session_expiry_heap)
+ session = self._session_index.get(key)
+ if session is None:
+ continue
+ if getattr(session, '_langbot_access_revision', None) != revision:
+ continue
+ return last_accessed, revision, key, session
+ return None
+
+ def _prune_expired_sessions(self, now: float) -> None:
+ idle_ttl = self._positive_config_int('idle_ttl_seconds', 86400)
+ cutoff = now - idle_ttl
+ while self._session_expiry_heap:
+ last_accessed, _, _ = self._session_expiry_heap[0]
+ if last_accessed > cutoff:
+ break
+ current = self._pop_current_expiry_entry()
+ if current is None:
+ break
+ last_accessed, revision, key, session = current
+ if last_accessed > cutoff:
+ heapq.heappush(
+ self._session_expiry_heap,
+ (last_accessed, revision, key),
+ )
+ break
+ if self._session_is_idle(session):
+ self._remove_session(session)
+ continue
+ # The session became active without another cache lookup. Give it
+ # a fresh TTL instead of repeatedly examining the same expired
+ # entry or losing its future expiry record.
+ self._touch_session(session, key, now)
+
+ def _prune_workspace_capacity(
+ self,
+ workspace_uuid: str,
+ max_entries_per_workspace: int,
+ ) -> None:
+ workspace_keys = self._session_keys_by_workspace.get(workspace_uuid, set())
+ overflow = len(workspace_keys) - max_entries_per_workspace + 1
+ if overflow <= 0:
+ return
+ idle_workspace_sessions = sorted(
+ (
+ session
+ for key in tuple(workspace_keys)
+ if (session := self._session_index.get(key)) is not None and self._session_is_idle(session)
+ ),
+ key=lambda session: float(getattr(session, '_langbot_last_accessed', 0.0)),
+ )
+ for session in idle_workspace_sessions[:overflow]:
+ self._remove_session(session)
+
+ def _evict_oldest_idle_session(self, now: float) -> bool:
+ # At most one current entry per active session is examined. Stale heap
+ # revisions do not count and are discarded in O(log N).
+ current_probes = 0
+ max_probes = len(self._session_index)
+ while current_probes < max_probes:
+ current = self._pop_current_expiry_entry()
+ if current is None:
+ return False
+ _, _, key, session = current
+ current_probes += 1
+ if self._session_is_idle(session):
+ self._remove_session(session)
+ return True
+ self._touch_session(session, key, now)
+ return False
+
+ def _prune_sessions(self, now: float, workspace_uuid: str) -> None:
+ self._prune_expired_sessions(now)
+ max_entries_per_workspace = self._positive_config_int('max_entries_per_workspace', 200)
+ self._prune_workspace_capacity(
+ workspace_uuid,
+ max_entries_per_workspace,
+ )
+
+ max_entries = self._positive_config_int('max_entries', 2000)
+ overflow = len(self._session_index) - max_entries + 1
+ if overflow <= 0:
+ return
+ for _ in range(overflow):
+ if not self._evict_oldest_idle_session(now):
+ break
async def initialize(self):
pass
async def get_session(self, query: pipeline_query.Query) -> provider_session.Session:
"""获取会话"""
- for session in self.session_list:
- if query.launcher_type == session.launcher_type and query.launcher_id == session.launcher_id:
- return session
+ session_key, execution_context = _query_session_key(query)
+ now = time.monotonic()
+ session = self._session_index.get(session_key)
+ if session is not None:
+ self._touch_session(session, session_key, now)
+ return session
+
+ self._prune_sessions(now, execution_context.workspace_uuid)
+ max_entries_per_workspace = self._positive_config_int('max_entries_per_workspace', 200)
+ workspace_entries = len(
+ self._session_keys_by_workspace.get(
+ execution_context.workspace_uuid,
+ (),
+ )
+ )
+ if workspace_entries >= max_entries_per_workspace:
+ raise RuntimeError(f'Workspace session cache capacity reached ({max_entries_per_workspace})')
+ max_entries = self._positive_config_int('max_entries', 2000)
+ if len(self._session_index) >= max_entries:
+ raise RuntimeError(f'Session cache capacity reached ({max_entries})')
session_concurrency = self.ap.instance_config.data['concurrency']['session']
session = provider_session.Session(
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ bot_uuid=query.bot_uuid,
launcher_type=query.launcher_type,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
)
+ session_context = dataclasses.replace(
+ execution_context,
+ pipeline_uuid=None,
+ query_uuid=None,
+ )
+ # langbot-plugin 0.4.13 ignores Workspace fields. Preserve them until
+ # the Workspace-aware SDK becomes the minimum supported version.
+ object.__setattr__(session, 'instance_uuid', session_context.instance_uuid)
+ object.__setattr__(session, 'workspace_uuid', session_context.workspace_uuid)
+ object.__setattr__(
+ session,
+ 'placement_generation',
+ session_context.placement_generation,
+ )
+ object.__setattr__(session, 'bot_uuid', query.bot_uuid)
+ object.__setattr__(session, '_execution_context', session_context)
+ object.__setattr__(session, '_langbot_session_key', session_key)
+ object.__setattr__(session, '_langbot_session_concurrency', session_concurrency)
session._semaphore = asyncio.Semaphore(session_concurrency)
- self.session_list.append(session)
+ self._session_index[session_key] = session
+ self._session_keys_by_workspace.setdefault(
+ execution_context.workspace_uuid,
+ set(),
+ ).add(session_key)
+ self._touch_session(session, session_key, now)
return session
async def get_conversation(
@@ -49,6 +339,17 @@ class SessionManager:
) -> provider_session.Conversation:
"""获取对话或创建对话"""
+ session_key, execution_context = _query_session_key(query)
+ if getattr(session, '_langbot_session_key', None) != session_key:
+ raise ExecutionContextMismatchError('Session does not belong to the Query execution scope')
+ execution_context = bind_execution_context(
+ execution_context,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ )
+ if execution_context.bot_uuid != getattr(session, 'bot_uuid', None):
+ raise ExecutionContextMismatchError('Session bot_uuid does not match the Query execution scope')
+
if not session.conversations:
session.conversations = []
@@ -63,7 +364,11 @@ class SessionManager:
messages=prompt_messages,
)
- if session.using_conversation is None or session.using_conversation.pipeline_uuid != pipeline_uuid:
+ if (
+ session.using_conversation is None
+ or session.using_conversation.pipeline_uuid != pipeline_uuid
+ or session.using_conversation.bot_uuid != bot_uuid
+ ):
conversation = provider_session.Conversation(
prompt=prompt,
messages=[],
@@ -71,6 +376,47 @@ class SessionManager:
bot_uuid=bot_uuid,
)
session.conversations.append(conversation)
+ max_conversations = self._positive_config_int('max_conversations_per_session', 20)
+ if len(session.conversations) > max_conversations:
+ del session.conversations[:-max_conversations]
session.using_conversation = conversation
return session.using_conversation
+
+ def trim_conversation_messages(
+ self,
+ conversation: provider_session.Conversation,
+ *,
+ max_rounds: int,
+ ) -> None:
+ """Bound retained process-local history after a completed turn."""
+
+ try:
+ max_rounds = int(max_rounds)
+ except (TypeError, ValueError):
+ max_rounds = 10
+ max_rounds = max(max_rounds, 1)
+ max_messages = self._positive_config_int('max_messages_per_conversation', 100)
+
+ kept_reversed = []
+ user_rounds = 0
+ for message in reversed(conversation.messages):
+ if user_rounds >= max_rounds:
+ break
+ kept_reversed.append(message)
+ if getattr(message, 'role', None) == 'user':
+ user_rounds += 1
+ retained = list(reversed(kept_reversed))[-max_messages:]
+ # Binary payloads are needed for the current model call, but retaining
+ # them in process-local history makes a few image/file turns consume
+ # hundreds of MB. Historical URL and text references remain intact.
+ for message in retained:
+ content = getattr(message, 'content', None)
+ if not isinstance(content, list):
+ continue
+ for element in content:
+ if getattr(element, 'image_base64', None) is not None:
+ element.image_base64 = None
+ if getattr(element, 'file_base64', None) is not None:
+ element.file_base64 = None
+ conversation.messages = retained
diff --git a/src/langbot/pkg/provider/tools/loaders/availability.py b/src/langbot/pkg/provider/tools/loaders/availability.py
index 58d795864..1b9293c3d 100644
--- a/src/langbot/pkg/provider/tools/loaders/availability.py
+++ b/src/langbot/pkg/provider/tools/loaders/availability.py
@@ -11,7 +11,7 @@ async def is_box_backend_available(ap: Any) -> bool:
if not getattr(box_service, 'available', False):
return False
try:
- status = await box_service.get_status()
+ status = await box_service.get_backend_status()
backend_info = status.get('backend', {})
return bool(backend_info.get('available', False))
except Exception:
diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py
index 1a594260b..2084bd154 100644
--- a/src/langbot/pkg/provider/tools/loaders/mcp.py
+++ b/src/langbot/pkg/provider/tools/loaders/mcp.py
@@ -1,12 +1,13 @@
from __future__ import annotations
-import base64
import enum
import json
import math
import re
import time
import typing
+import ipaddress
+from urllib.parse import urlparse
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import timedelta
import traceback
@@ -26,6 +27,10 @@ from pydantic import AnyUrl
from .. import loader
from ....core import app
+from ....core.task_boundary import create_detached_task, run_in_workspace_uow
+from ....api.http.context import ExecutionContext
+from ....api.http.service.tenant import TenantContext, require_workspace_uuid
+from ....workspace.errors import WorkspaceError, WorkspaceInvariantError
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp
@@ -35,7 +40,8 @@ from .mcp_stdio import (
MCPSessionErrorPhase,
_ColdStartRetry,
_get_default_memory_mb,
-) # noqa: F401
+)
+from .mcp_policy import require_stdio_mcp_enabled, stdio_mcp_enabled
# Synthesized LLM tools for MCP resources (not from server tools/list).
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
@@ -45,6 +51,7 @@ MCP_TOOL_READ_RESOURCE = 'langbot_mcp_read_resource'
MCP_RESOURCE_DISCOVERY_MAX_PAGES = 20
MCP_RESOURCE_CACHE_TTL_SECONDS = 30
+MCP_RESOURCE_CACHE_MAX_ENTRIES = 32
MCP_RESOURCE_PREVIEW_MAX_BYTES = 64 * 1024
MCP_RESOURCE_AGENT_READ_MAX_BYTES = 64 * 1024
MCP_RESOURCE_AGENT_READ_MAX_TOKENS = 12000
@@ -129,10 +136,13 @@ def _truncate_text(text: str, max_bytes: int, max_tokens: int | None = None) ->
def _blob_size(blob: str) -> int:
- try:
- return len(base64.b64decode(blob, validate=False))
- except Exception:
+ # MCP BlobResourceContents is schema-validated base64 without whitespace.
+ # Compute decoded size in O(1) without allocating a second binary copy.
+ encoded_chars = len(blob)
+ if encoded_chars % 4:
return len(blob.encode('utf-8', errors='ignore'))
+ padding = 2 if blob.endswith('==') else 1 if blob.endswith('=') else 0
+ return max((encoded_chars // 4) * 3 - padding, 0)
def _resource_to_dict(resource: mcp_types.Resource | mcp_types.ResourceLink) -> dict:
@@ -262,11 +272,19 @@ class RuntimeMCPSession:
_box_stdio_runtime: BoxStdioSessionRuntime
- def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
+ def __init__(
+ self,
+ server_name: str,
+ server_config: dict,
+ enable: bool,
+ ap: app.Application,
+ execution_context: ExecutionContext,
+ ):
self.server_name = server_name
self.server_uuid = server_config.get('uuid', '')
self.server_config = server_config
self.ap = ap
+ self.execution_context = execution_context
self.enable = enable
self.session = None
self.tool_call_timeout_sec = self._parse_tool_call_timeout(
@@ -314,6 +332,7 @@ class RuntimeMCPSession:
def _parse_tool_call_timeout(self, value: typing.Any) -> float:
"""Return a safe tool-call timeout; zero explicitly disables it."""
+
try:
timeout = -1 if isinstance(value, bool) else float(value)
if timeout > 0:
@@ -331,7 +350,44 @@ class RuntimeMCPSession:
return MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
return timeout
+ async def _assert_execution_active(self) -> None:
+ """Fail closed when this long-lived session belongs to a stale placement."""
+
+ binding = await self.ap.workspace_service.get_execution_binding(
+ self.execution_context.workspace_uuid,
+ expected_generation=self.execution_context.placement_generation,
+ )
+ if binding.instance_uuid != self.execution_context.instance_uuid:
+ raise WorkspaceInvariantError('MCP session instance does not match the active Workspace binding')
+
+ async def _sleep_with_execution_fence(self, delay: float) -> None:
+ """Back off without reconnecting after the captured placement expires."""
+
+ await self._assert_execution_active()
+ try:
+ await asyncio.wait_for(self._shutdown_event.wait(), timeout=delay)
+ except asyncio.TimeoutError:
+ pass
+ if not self._shutdown_event.is_set():
+ await self._assert_execution_active()
+
+ def _stop_for_stale_execution(self, error: WorkspaceError) -> None:
+ """Mark the session terminal without retrying a fenced placement."""
+
+ self.status = MCPSessionStatus.ERROR
+ self.error_message = 'Workspace execution binding is stale'
+ self._shutdown_event.set()
+ self._ready_event.set()
+ self.ap.logger.info(
+ f'MCP session {self.server_name} stopped because its Workspace execution binding is stale: {error}'
+ )
+
async def _init_stdio_python_server(self):
+ # Final transport gate: this must run before both the Box branch and
+ # the backwards-compatible host-stdio branch. Service/UI checks are
+ # usability guards; this is the execution security boundary.
+ require_stdio_mcp_enabled(self.ap, self.server_config)
+
if self._uses_box_stdio():
await self._box_stdio_runtime.initialize()
return
@@ -372,12 +428,24 @@ class RuntimeMCPSession:
await self._box_stdio_runtime.initialize()
async def _init_sse_server(self):
+ trust_env = self._remote_http_trust_env()
+
+ def httpx_client_factory(headers=None, timeout=None, auth=None):
+ return httpx.AsyncClient(
+ headers=headers,
+ timeout=timeout,
+ auth=auth,
+ follow_redirects=True,
+ trust_env=trust_env,
+ )
+
sse_transport = await self.exit_stack.enter_async_context(
sse_client(
self.server_config['url'],
headers=self.server_config.get('headers', {}),
timeout=self.server_config.get('timeout', 10),
sse_read_timeout=self.server_config.get('ssereadtimeout', 30),
+ httpx_client_factory=httpx_client_factory,
)
)
@@ -387,6 +455,18 @@ class RuntimeMCPSession:
await self.session.initialize()
+ def _remote_http_trust_env(self) -> bool:
+ configured = self.server_config.get('trust_env')
+ if isinstance(configured, bool):
+ return configured
+ hostname = (urlparse(str(self.server_config.get('url', ''))).hostname or '').lower()
+ if hostname == 'localhost':
+ return False
+ try:
+ return not ipaddress.ip_address(hostname).is_loopback
+ except ValueError:
+ return True
+
@asynccontextmanager
async def _streamable_http_session(self) -> typing.AsyncIterator[ClientSession]:
"""Enter a fully initialized Streamable HTTP session as one context.
@@ -403,6 +483,7 @@ class RuntimeMCPSession:
headers=self.server_config.get('headers', {}),
timeout=self.server_config.get('timeout', 10),
follow_redirects=True,
+ trust_env=self._remote_http_trust_env(),
) as http_client:
async with streamable_http_client(
self.server_config['url'],
@@ -458,6 +539,7 @@ class RuntimeMCPSession:
async def _lifecycle_loop(self):
"""Manage the full MCP session lifecycle in a background task."""
try:
+ await self._assert_execution_active()
if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'remote':
@@ -467,9 +549,11 @@ class RuntimeMCPSession:
elif self.server_config['mode'] == 'http':
await self._init_streamable_http_server()
else:
- raise ValueError(f'Unknown MCP server mode: {self.server_name}: {self.server_config}')
+ raise ValueError(f'Unknown MCP server mode for {self.server_name}')
+ await self._assert_execution_active()
await self.refresh()
+ await self._assert_execution_active()
self.status = MCPSessionStatus.CONNECTED
@@ -487,6 +571,7 @@ class RuntimeMCPSession:
)
for task in pending:
task.cancel()
+ await asyncio.gather(*pending, return_exceptions=True)
if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear()
self.ap.logger.info(
@@ -528,6 +613,7 @@ class RuntimeMCPSession:
)
for task in pending:
task.cancel()
+ await asyncio.gather(*pending, return_exceptions=True)
if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear()
self.ap.logger.info(
@@ -590,7 +676,11 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
- await asyncio.sleep(1)
+ try:
+ await self._sleep_with_execution_fence(1)
+ except WorkspaceError as fence_error:
+ self._stop_for_stale_execution(fence_error)
+ return
continue
except _CallerReconnect:
# A tool/resource call hit a server-expired session and asked us
@@ -607,6 +697,7 @@ class RuntimeMCPSession:
self.error_message = None
self.error_phase = None
try:
+ await self._assert_execution_active()
if self.server_config['mode'] == 'stdio':
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'remote':
@@ -616,8 +707,12 @@ class RuntimeMCPSession:
elif self.server_config['mode'] == 'http':
await self._init_streamable_http_server()
await self.refresh()
+ await self._assert_execution_active()
self.status = MCPSessionStatus.CONNECTED
self.ap.logger.info(f'MCP session {self.server_name} reconnected successfully after session expiry')
+ except WorkspaceError as reconnect_err:
+ self._stop_for_stale_execution(reconnect_err)
+ return
except Exception as reconnect_err:
self.status = MCPSessionStatus.ERROR
self.error_message = str(reconnect_err)
@@ -645,8 +740,15 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
- await asyncio.sleep(2)
+ try:
+ await self._sleep_with_execution_fence(2)
+ except WorkspaceError as fence_error:
+ self._stop_for_stale_execution(fence_error)
+ return
continue
+ except WorkspaceError as e:
+ self._stop_for_stale_execution(e)
+ return
except Exception as e:
if self._shutdown_event.is_set():
return # Shutdown requested, don't retry
@@ -660,7 +762,11 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
- await asyncio.sleep(1)
+ try:
+ await self._sleep_with_execution_fence(1)
+ except WorkspaceError as fence_error:
+ self._stop_for_stale_execution(fence_error)
+ return
continue
# Explicitly disabled Box is a deliberate refusal, not a
# transient failure. Surface it immediately without log
@@ -686,7 +792,11 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.CONNECTING
self.error_message = None
self.error_phase = None
- await asyncio.sleep(delay)
+ try:
+ await self._sleep_with_execution_fence(delay)
+ except WorkspaceError as fence_error:
+ self._stop_for_stale_execution(fence_error)
+ return
attempt += 1
@staticmethod
@@ -769,6 +879,7 @@ class RuntimeMCPSession:
Returns True if reconnection succeeded within the timeout.
"""
+ await self._assert_execution_active()
if self._shutdown_event.is_set():
return False
@@ -779,6 +890,7 @@ class RuntimeMCPSession:
try:
await asyncio.wait_for(reconnected_event.wait(), timeout=self._RECONNECT_WAIT_TIMEOUT)
+ await self._assert_execution_active()
return self.status == MCPSessionStatus.CONNECTED
except asyncio.TimeoutError:
self.ap.logger.warning(f'MCP session {self.server_name} reconnect timed out')
@@ -794,6 +906,7 @@ class RuntimeMCPSession:
if not self.enable:
return
+ await self._assert_execution_active()
# Create background task for lifecycle management with retry
self._lifecycle_task = asyncio.create_task(self._lifecycle_loop_with_retry())
@@ -805,11 +918,13 @@ class RuntimeMCPSession:
self.status = MCPSessionStatus.ERROR
raise Exception(f'Connection timeout after {startup_timeout} seconds')
+ await self._assert_execution_active()
# Check for errors
if self.status == MCPSessionStatus.ERROR:
raise Exception('Connection failed, please check URL')
async def refresh(self):
+ await self._assert_execution_active()
if not self.session:
return
@@ -825,6 +940,7 @@ class RuntimeMCPSession:
self.resource_capabilities = {}
tools = await self.session.list_tools()
+ await self._assert_execution_active()
self.ap.logger.debug(f'Refresh MCP tools: {tools}')
@@ -846,34 +962,44 @@ class RuntimeMCPSession:
)
await self._refresh_resources()
+ await self._assert_execution_active()
async def _refresh_resources(self):
+ await self._assert_execution_active()
if not self.session:
return
try:
cursor: str | None = None
for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES):
+ await self._assert_execution_active()
resources_result = await self.session.list_resources(cursor)
+ await self._assert_execution_active()
for resource in resources_result.resources:
self.resources.append(_resource_to_dict(resource))
cursor = getattr(resources_result, 'nextCursor', None)
if not cursor:
break
self.ap.logger.debug(f'Refresh MCP resources: {len(self.resources)} resources found')
+ except WorkspaceError:
+ raise
except Exception as e:
self.ap.logger.debug(f'MCP server {self.server_name} does not support resources or failed to list: {e}')
try:
cursor = None
for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES):
+ await self._assert_execution_active()
templates_result = await self.session.list_resource_templates(cursor)
+ await self._assert_execution_active()
for template in templates_result.resourceTemplates:
self.resource_templates.append(_resource_template_to_dict(template))
cursor = getattr(templates_result, 'nextCursor', None)
if not cursor:
break
self.ap.logger.debug(f'Refresh MCP resource templates: {len(self.resource_templates)} templates found')
+ except WorkspaceError:
+ raise
except Exception as e:
self.ap.logger.debug(
f'MCP server {self.server_name} does not support resource templates or failed to list: {e}'
@@ -992,17 +1118,20 @@ class RuntimeMCPSession:
arguments: dict,
query: pipeline_query.Query | None = None,
) -> list[provider_message.ContentElement]:
+ await self._assert_execution_active()
for attempt in range(2):
if not self.session:
raise Exception('MCP session is not connected')
try:
+ await self._assert_execution_active()
read_timeout = timedelta(seconds=self.tool_call_timeout_sec) if self.tool_call_timeout_sec > 0 else None
result = await self.session.call_tool(
tool_name,
arguments,
read_timeout_seconds=read_timeout,
)
+ await self._assert_execution_active()
except Exception as e:
if self._is_tool_call_timeout(e):
self.ap.logger.warning(
@@ -1087,6 +1216,7 @@ class RuntimeMCPSession:
query: pipeline_query.Query | None = None,
) -> dict:
"""Read a resource by URI with safety limits and audit metadata."""
+ await self._assert_execution_active()
if not self.session:
raise Exception('MCP session is not connected')
@@ -1098,6 +1228,9 @@ class RuntimeMCPSession:
cache_key = (uri, max_bytes, max_tokens, include_blob)
now = time.time()
+ for expired_key, entry in tuple(self._resource_cache.items()):
+ if now - entry.get('cached_at', 0) > MCP_RESOURCE_CACHE_TTL_SECONDS:
+ self._resource_cache.pop(expired_key, None)
cached = self._resource_cache.get(cache_key)
if cached and now - cached.get('cached_at', 0) <= MCP_RESOURCE_CACHE_TTL_SECONDS:
envelope = {
@@ -1113,7 +1246,9 @@ class RuntimeMCPSession:
if not self.session:
raise Exception('MCP session is not connected')
try:
+ await self._assert_execution_active()
result = await self.session.read_resource(AnyUrl(uri))
+ await self._assert_execution_active()
break
except Exception as e:
if attempt == 0 and self._is_session_terminated(e):
@@ -1194,6 +1329,13 @@ class RuntimeMCPSession:
'cache_hit': False,
'warnings': warnings,
}
+ await self._assert_execution_active()
+ if cache_key not in self._resource_cache and len(self._resource_cache) >= MCP_RESOURCE_CACHE_MAX_ENTRIES:
+ oldest_key = min(
+ self._resource_cache,
+ key=lambda key: self._resource_cache[key].get('cached_at', 0),
+ )
+ self._resource_cache.pop(oldest_key, None)
self._resource_cache[cache_key] = {'cached_at': now, 'envelope': envelope}
self._record_resource_read_trace(query, envelope)
return envelope
@@ -1228,7 +1370,11 @@ class RuntimeMCPSession:
def get_runtime_info_dict(self) -> dict:
info = {
'status': self.status.value,
- 'error_message': self.error_message,
+ # Raw transport exceptions may echo command arguments, headers, or
+ # environment values. Detailed diagnostics belong in AUDIT_VIEW
+ # logs; resource-list responses expose only a stable status.
+ 'error_message': 'MCP runtime failed' if self.error_message else None,
+ 'error_code': 'runtime_error' if self.error_message else None,
'error_phase': self.error_phase.value if self.error_phase else None,
'retry_count': self.retry_count,
'tool_count': len(self.get_tools()),
@@ -1336,6 +1482,37 @@ class RuntimeMCPSession:
await self._box_stdio_runtime.cleanup_session()
+def _execution_context_from_tenant(context: TenantContext) -> ExecutionContext:
+ workspace_uuid = require_workspace_uuid(context)
+ instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
+ generation = getattr(context, 'placement_generation', None)
+ if not instance_uuid:
+ raise ValueError('MCP runtime requires an explicit instance UUID')
+ if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
+ raise ValueError('MCP runtime requires a positive placement generation')
+ return ExecutionContext(
+ instance_uuid=instance_uuid,
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ bot_uuid=getattr(context, 'bot_uuid', None),
+ pipeline_uuid=getattr(context, 'pipeline_uuid', None),
+ query_uuid=getattr(context, 'query_uuid', None),
+ )
+
+
+def _execution_context_from_query(query: pipeline_query.Query) -> ExecutionContext:
+ return _execution_context_from_tenant(
+ ExecutionContext(
+ instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
+ workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
+ placement_generation=getattr(query, 'placement_generation', 0) or 0,
+ bot_uuid=getattr(query, 'bot_uuid', None),
+ pipeline_uuid=getattr(query, 'pipeline_uuid', None),
+ query_uuid=getattr(query, 'query_uuid', None),
+ )
+ )
+
+
# @loader.loader_class('mcp')
class MCPLoader(loader.ToolLoader):
"""MCP 工具加载器。
@@ -1343,17 +1520,326 @@ class MCPLoader(loader.ToolLoader):
在此加载器中管理所有与 MCP Server 的连接。
"""
- sessions: dict[str, RuntimeMCPSession]
-
- _last_listed_functions: list[resource_tool.LLMTool]
+ _sessions: dict[tuple[str, str, int, str], RuntimeMCPSession]
_hosted_mcp_tasks: list[asyncio.Task]
def __init__(self, ap: app.Application):
super().__init__(ap)
self.sessions = {}
- self._last_listed_functions = []
self._hosted_mcp_tasks = []
+ self._hosted_mcp_tasks_by_scope: dict[
+ tuple[str, str, int],
+ set[asyncio.Task],
+ ] = {}
+ self._host_dispatch_tasks: set[asyncio.Task] = set()
+ self._pending_projection_retirements: set[tuple[str, str, int]] = set()
+ self._projection_reconcile_task: asyncio.Task[None] | None = None
+ config = getattr(getattr(ap, 'instance_config', None), 'data', {})
+ mcp_config = config.get('mcp', {}) if isinstance(config, dict) else {}
+ raw_lifecycle_concurrency = mcp_config.get('lifecycle_concurrency', 16) if isinstance(mcp_config, dict) else 16
+ if (
+ isinstance(raw_lifecycle_concurrency, bool)
+ or not isinstance(raw_lifecycle_concurrency, int)
+ or raw_lifecycle_concurrency < 1
+ ):
+ raw_lifecycle_concurrency = 16
+ self._lifecycle_concurrency = min(
+ raw_lifecycle_concurrency,
+ 128,
+ )
+ self._lifecycle_semaphore = asyncio.Semaphore(self._lifecycle_concurrency)
+
+ @property
+ def sessions(
+ self,
+ ) -> dict[tuple[str, str, int, str], RuntimeMCPSession]:
+ return self._sessions
+
+ @sessions.setter
+ def sessions(self, sessions: dict) -> None:
+ """Compatibility setter that rebuilds the per-scope session index."""
+
+ self._sessions = sessions
+ self._session_keys_by_scope: dict[
+ tuple[str, str, int],
+ set[tuple[str, str, int, str]],
+ ] = {}
+ self._scope_generations: dict[tuple[str, str], int] = {}
+ for key in sessions:
+ if not isinstance(key, tuple) or len(key) != 4:
+ continue
+ scope_key = key[:3]
+ self._session_keys_by_scope.setdefault(scope_key, set()).add(key)
+ self._scope_generations[scope_key[:2]] = scope_key[2]
+
+ def _register_session(
+ self,
+ context: TenantContext,
+ server_name: str,
+ session: RuntimeMCPSession,
+ ) -> None:
+ scope_key = self._scope_key(context)
+ workspace_scope = scope_key[:2]
+ previous_generation = self._scope_generations.get(workspace_scope)
+ if previous_generation is not None and previous_generation != scope_key[2]:
+ raise WorkspaceInvariantError('MCP session registration crossed a Workspace generation')
+ key = (*scope_key, server_name)
+ self._sessions[key] = session
+ self._session_keys_by_scope.setdefault(scope_key, set()).add(key)
+ self._scope_generations[workspace_scope] = scope_key[2]
+
+ def _pop_session(
+ self,
+ context: TenantContext,
+ server_name: str,
+ ) -> RuntimeMCPSession | None:
+ scope_key = self._scope_key(context)
+ key = (*scope_key, server_name)
+ session = self._sessions.pop(key, None)
+ keys = self._session_keys_by_scope.get(scope_key)
+ if keys is not None:
+ keys.discard(key)
+ if not keys:
+ self._session_keys_by_scope.pop(scope_key, None)
+ self._drop_empty_scope(scope_key)
+ return session
+
+ def _drop_empty_scope(self, scope_key: tuple[str, str, int]) -> None:
+ if (
+ scope_key not in self._session_keys_by_scope
+ and scope_key not in self._hosted_mcp_tasks_by_scope
+ and self._scope_generations.get(scope_key[:2]) == scope_key[2]
+ ):
+ self._scope_generations.pop(scope_key[:2], None)
+
+ def track_hosted_task(
+ self,
+ task: asyncio.Task,
+ context: TenantContext,
+ ) -> asyncio.Task:
+ """Track a host task without retaining it after completion."""
+
+ scope_key = self._scope_key(context)
+ workspace_scope = scope_key[:2]
+ previous_generation = self._scope_generations.get(workspace_scope)
+ if previous_generation is not None and previous_generation != scope_key[2]:
+ task.cancel()
+ raise WorkspaceInvariantError('MCP host task crossed a Workspace generation')
+ self._scope_generations[workspace_scope] = scope_key[2]
+ self._hosted_mcp_tasks.append(task)
+ self._hosted_mcp_tasks_by_scope.setdefault(scope_key, set()).add(task)
+
+ def discard(completed: asyncio.Task) -> None:
+ try:
+ self._hosted_mcp_tasks.remove(completed)
+ except ValueError:
+ pass
+ tasks = self._hosted_mcp_tasks_by_scope.get(scope_key)
+ if tasks is not None:
+ tasks.discard(completed)
+ if not tasks:
+ self._hosted_mcp_tasks_by_scope.pop(scope_key, None)
+ self._drop_empty_scope(scope_key)
+
+ task.add_done_callback(discard)
+ return task
+
+ def _track_host_dispatch_task(self, task: asyncio.Task) -> None:
+ """Track the bounded startup dispatcher without retaining it."""
+
+ self._host_dispatch_tasks.add(task)
+
+ def discard(completed: asyncio.Task) -> None:
+ self._host_dispatch_tasks.discard(completed)
+ if completed.cancelled():
+ return
+ exception = completed.exception()
+ if exception is not None:
+ self.ap.logger.error(
+ f'MCP startup dispatcher failed: {exception}',
+ )
+
+ task.add_done_callback(discard)
+
+ async def _retire_runtime_scope(
+ self,
+ scope_key: tuple[str, str, int],
+ ) -> None:
+ tasks = tuple(self._hosted_mcp_tasks_by_scope.pop(scope_key, ()))
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ keys = tuple(self._session_keys_by_scope.pop(scope_key, ()))
+ sessions = [session for key in keys if (session := self._sessions.pop(key, None)) is not None]
+ await self._shutdown_sessions(sessions)
+ if self._scope_generations.get(scope_key[:2]) == scope_key[2]:
+ self._scope_generations.pop(scope_key[:2], None)
+
+ def reconcile_execution_projection(
+ self,
+ instance_uuid: str,
+ active_generations: typing.Mapping[str, int],
+ *,
+ affected_workspace_uuids: typing.Iterable[str] | None = None,
+ ) -> None:
+ """Queue stale MCP scopes for one coalesced, bounded cleanup worker."""
+
+ affected = None if affected_workspace_uuids is None else set(affected_workspace_uuids)
+ for workspace_scope, generation in tuple(self._scope_generations.items()):
+ scoped_instance_uuid, workspace_uuid = workspace_scope
+ if scoped_instance_uuid != instance_uuid:
+ continue
+ if affected is not None and workspace_uuid not in affected:
+ continue
+ if active_generations.get(workspace_uuid) == generation:
+ continue
+ self._pending_projection_retirements.add((*workspace_scope, generation))
+
+ if not self._pending_projection_retirements:
+ return
+ if self._projection_reconcile_task is not None and not self._projection_reconcile_task.done():
+ return
+ task = asyncio.create_task(
+ self._drain_projection_retirements(),
+ name='mcp-projection-reconcile',
+ )
+ self._projection_reconcile_task = task
+ task.add_done_callback(self._projection_reconcile_done)
+
+ async def _drain_projection_retirements(self) -> None:
+ while self._pending_projection_retirements:
+ scope_key = next(iter(self._pending_projection_retirements))
+ self._pending_projection_retirements.discard(scope_key)
+ await self._retire_runtime_scope(scope_key)
+
+ def _projection_reconcile_done(
+ self,
+ completed: asyncio.Task[None],
+ ) -> None:
+ if self._projection_reconcile_task is completed:
+ self._projection_reconcile_task = None
+ if completed.cancelled():
+ return
+ exception = completed.exception()
+ if exception is not None:
+ self.ap.logger.error(
+ f'MCP projection reconciliation failed: {exception}',
+ )
+
+ async def _observe_execution_context(
+ self,
+ context: ExecutionContext,
+ ) -> None:
+ workspace_scope = (
+ context.instance_uuid,
+ context.workspace_uuid,
+ )
+ previous_generation = self._scope_generations.get(workspace_scope)
+ if previous_generation is None:
+ return
+ if context.placement_generation < previous_generation:
+ raise WorkspaceInvariantError('MCP runtime placement generation rolled back')
+ if context.placement_generation == previous_generation:
+ return
+ await self._retire_runtime_scope((*workspace_scope, previous_generation))
+
+ async def _reset_runtime_state(self) -> None:
+ """Cancel host tasks and close sessions before reload or shutdown."""
+
+ projection_task = self._projection_reconcile_task
+ self._projection_reconcile_task = None
+ self._pending_projection_retirements.clear()
+ if projection_task is not None and not projection_task.done():
+ projection_task.cancel()
+ await asyncio.gather(projection_task, return_exceptions=True)
+
+ dispatch_tasks = tuple(self._host_dispatch_tasks)
+ self._host_dispatch_tasks.clear()
+ for task in dispatch_tasks:
+ if not task.done():
+ task.cancel()
+ if dispatch_tasks:
+ await asyncio.gather(*dispatch_tasks, return_exceptions=True)
+
+ tasks = tuple(self._hosted_mcp_tasks)
+ self._hosted_mcp_tasks.clear()
+ self._hosted_mcp_tasks_by_scope.clear()
+ for task in tasks:
+ if not task.done():
+ task.cancel()
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ sessions = tuple(self._sessions.values())
+ self.sessions = {}
+ await self._shutdown_sessions(sessions)
+
+ async def _shutdown_sessions(
+ self,
+ sessions: typing.Iterable[RuntimeMCPSession],
+ ) -> None:
+ """Close MCP sessions in bounded batches to avoid shutdown storms."""
+
+ session_list = list(sessions)
+ for offset in range(0, len(session_list), self._lifecycle_concurrency):
+ batch = session_list[offset : offset + self._lifecycle_concurrency]
+ results = await asyncio.gather(
+ *(session.shutdown() for session in batch),
+ return_exceptions=True,
+ )
+ for session, result in zip(batch, results, strict=True):
+ if isinstance(result, BaseException):
+ self.ap.logger.error(f'Error shutting down MCP session {session.server_name}: {result}')
+
+ async def _host_server_configs_bounded(
+ self,
+ server_configs: typing.Sequence[tuple[ExecutionContext, dict],],
+ ) -> None:
+ """Create at most one lifecycle batch of MCP host tasks at a time."""
+
+ for offset in range(0, len(server_configs), self._lifecycle_concurrency):
+ batch = server_configs[offset : offset + self._lifecycle_concurrency]
+ tasks: list[asyncio.Task] = []
+ for execution_context, config in batch:
+ task = create_detached_task(
+ self.host_mcp_server(execution_context, config),
+ after_commit_manager=getattr(
+ self.ap,
+ 'persistence_mgr',
+ None,
+ ),
+ workspace_uuid=execution_context.workspace_uuid,
+ )
+ tasks.append(task)
+ try:
+ self.track_hosted_task(task, execution_context)
+ except WorkspaceInvariantError as exc:
+ self.ap.logger.warning(
+ f'Skipping stale MCP startup task for {execution_context.workspace_uuid}: {exc}'
+ )
+ continue
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ async def _assert_execution_active(
+ self,
+ context: TenantContext,
+ ) -> ExecutionContext:
+ """Validate a caller's placement before accessing an MCP session."""
+
+ execution_context = _execution_context_from_tenant(context)
+ binding = await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceInvariantError('MCP caller instance does not match the active Workspace binding')
+ await self._observe_execution_context(execution_context)
+ return execution_context
async def initialize(self):
await self.load_mcp_servers_from_db()
@@ -1361,22 +1847,124 @@ class MCPLoader(loader.ToolLoader):
async def load_mcp_servers_from_db(self):
self.ap.logger.info('Loading MCP servers from db...')
- self.sessions = {}
+ await self._reset_runtime_state()
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
- servers = result.all()
+ pending_hosts: list[tuple[ExecutionContext, dict]] = []
- for server in servers:
- config = self.ap.persistence_mgr.serialize_model(persistence_mcp.MCPServer, server)
+ async def queue_server(binding, server) -> None:
+ config = self.ap.persistence_mgr.serialize_model(
+ persistence_mcp.MCPServer,
+ server,
+ )
+ if config.get('mode') == 'stdio' and not stdio_mcp_enabled(self.ap):
+ self.ap.logger.info(
+ f'Skipping disabled stdio MCP server {server.uuid}; '
+ 'the persisted configuration is retained but no process is launched'
+ )
+ return
+ try:
+ if binding is None:
+ binding = await self.ap.workspace_service.get_execution_binding(server.workspace_uuid)
+ execution_context = ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ )
+ except Exception as exc:
+ self.ap.logger.warning(
+ f'Skipping MCP server {server.uuid}: Workspace execution binding is unavailable: {exc}'
+ )
+ return
+ pending_hosts.append((execution_context, config))
- task = asyncio.create_task(self.host_mcp_server(config))
- self._hosted_mcp_tasks.append(task)
+ list_bindings = getattr(self.ap.workspace_service, 'list_active_execution_bindings', None)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ if not callable(list_bindings) or not callable(tenant_uow):
+ raise RuntimeError('Cloud MCP loading requires explicit instance discovery and tenant UoWs')
+ for binding in await list_bindings():
+ async with tenant_uow(binding.workspace_uuid):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_mcp.MCPServer)
+ .where(persistence_mcp.MCPServer.workspace_uuid == binding.workspace_uuid)
+ .order_by(persistence_mcp.MCPServer.uuid)
+ )
+ for server in result.all():
+ await queue_server(binding, server)
+ else:
+ # Compatibility path for isolated loader tests and older embedders.
+ result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_mcp.MCPServer))
+ for server in result.all():
+ await queue_server(None, server)
- async def host_mcp_server(self, server_config: dict):
+ if pending_hosts:
+ dispatch_task = create_detached_task(
+ self._host_server_configs_bounded(pending_hosts),
+ after_commit_manager=getattr(self.ap, 'persistence_mgr', None),
+ )
+ self._track_host_dispatch_task(dispatch_task)
+
+ @staticmethod
+ def _scope_key(context: TenantContext) -> tuple[str, str, int]:
+ execution_context = _execution_context_from_tenant(context)
+ return (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ )
+
+ @classmethod
+ def _session_key(cls, context: TenantContext, server_name: str) -> tuple[str, str, int, str]:
+ return (*cls._scope_key(context), server_name)
+
+ def _sessions_for_context(self, context: TenantContext) -> list[RuntimeMCPSession]:
+ scope_key = self._scope_key(context)
+ return [
+ session
+ for key in self._session_keys_by_scope.get(scope_key, ())
+ if (session := self._sessions.get(key)) is not None
+ ]
+
+ async def host_mcp_server(
+ self,
+ context: TenantContext,
+ server_config: dict,
+ ) -> None:
+ async with self._lifecycle_semaphore:
+ await self._host_mcp_server(context, server_config)
+
+ async def _host_mcp_server(
+ self,
+ context: TenantContext,
+ server_config: dict,
+ ) -> None:
+ requested_context = _execution_context_from_tenant(context)
+ execution_context = await run_in_workspace_uow(
+ self.ap,
+ requested_context.workspace_uuid,
+ lambda: self._assert_execution_active(requested_context),
+ )
+ configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
+ if configured_workspace and configured_workspace != execution_context.workspace_uuid:
+ raise ValueError('MCP server configuration belongs to another Workspace')
+ server_config = dict(server_config)
+ server_config['workspace_uuid'] = execution_context.workspace_uuid
self.ap.logger.debug(f'Loading MCP server {server_config}')
try:
- session = await self.load_mcp_server(server_config)
- self.sessions[server_config['name']] = session
+ session = await self.load_mcp_server(execution_context, server_config)
+ await self._assert_execution_active(execution_context)
+ old_session = self._pop_session(
+ execution_context,
+ server_config['name'],
+ )
+ if old_session is not None:
+ await old_session.shutdown()
+ self._register_session(
+ execution_context,
+ server_config['name'],
+ session,
+ )
except Exception as e:
self.ap.logger.error(
f'Failed to load MCP server from db: {server_config["name"]}({server_config["uuid"]}): {e}\n{traceback.format_exc()}'
@@ -1385,6 +1973,7 @@ class MCPLoader(loader.ToolLoader):
self.ap.logger.debug(f'Starting MCP server {server_config["name"]}({server_config["uuid"]})')
try:
+ await self._assert_execution_active(execution_context)
await session.start()
except Exception as e:
self.ap.logger.error(
@@ -1394,7 +1983,7 @@ class MCPLoader(loader.ToolLoader):
self.ap.logger.debug(f'Started MCP server {server_config["name"]}({server_config["uuid"]})')
- async def load_mcp_server(self, server_config: dict) -> RuntimeMCPSession:
+ async def load_mcp_server(self, context: TenantContext, server_config: dict) -> RuntimeMCPSession:
"""加载 MCP 服务器到运行时
Args:
@@ -1404,6 +1993,14 @@ class MCPLoader(loader.ToolLoader):
- enable: 是否启用
- extra_args: 额外的配置参数 (可选)
"""
+ execution_context = await self._assert_execution_active(context)
+ server_config = dict(server_config)
+ require_stdio_mcp_enabled(self.ap, server_config)
+ configured_workspace = str(server_config.get('workspace_uuid') or '').strip()
+ if configured_workspace and configured_workspace != execution_context.workspace_uuid:
+ raise ValueError('MCP server configuration belongs to another Workspace')
+ server_config['workspace_uuid'] = execution_context.workspace_uuid
+
uuid_ = server_config.get('uuid')
is_transient = False
if not uuid_:
@@ -1429,7 +2026,7 @@ class MCPLoader(loader.ToolLoader):
**extra_args,
}
- session = RuntimeMCPSession(name, mixed_config, enable, self.ap)
+ session = RuntimeMCPSession(name, mixed_config, enable, self.ap, execution_context)
return session
@@ -1438,9 +2035,13 @@ class MCPLoader(loader.ToolLoader):
v = getattr(query, 'variables', None) or {}
return v.get('_pipeline_bound_mcp_servers', None)
- def _eligible_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]:
+ def _eligible_sessions_for_bound(
+ self,
+ context: TenantContext,
+ bound_mcp_servers: list[str] | None,
+ ) -> list[RuntimeMCPSession]:
out: list[RuntimeMCPSession] = []
- for session in self.sessions.values():
+ for session in self._sessions_for_context(context):
if not session.enable:
continue
if session.status != MCPSessionStatus.CONNECTED:
@@ -1452,10 +2053,14 @@ class MCPLoader(loader.ToolLoader):
out.append(session)
return out
- def _eligible_resource_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]:
+ def _eligible_resource_sessions_for_bound(
+ self,
+ context: TenantContext,
+ bound_mcp_servers: list[str] | None,
+ ) -> list[RuntimeMCPSession]:
return [
session
- for session in self._eligible_sessions_for_bound(bound_mcp_servers)
+ for session in self._eligible_sessions_for_bound(context, bound_mcp_servers)
if session.has_resource_support()
]
@@ -1486,12 +2091,13 @@ class MCPLoader(loader.ToolLoader):
]
async def _invoke_mcp_list_resources(self, parameters: dict, query: pipeline_query.Query) -> typing.Any:
+ execution_context = _execution_context_from_query(query)
server_name = parameters.get('server_name') if parameters else None
if not server_name or not isinstance(server_name, str):
return [provider_message.ContentElement.from_text('Error: "server_name" (string) is required.')]
bound = self._get_bound_mcp_from_query(query)
- allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(bound)}
+ allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(execution_context, bound)}
if server_name not in allowed:
return [
provider_message.ContentElement.from_text(
@@ -1501,7 +2107,7 @@ class MCPLoader(loader.ToolLoader):
)
]
- session = self.get_session(server_name)
+ session = self.get_session(execution_context, server_name)
if session is None or session.status != MCPSessionStatus.CONNECTED:
return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')]
@@ -1518,6 +2124,7 @@ class MCPLoader(loader.ToolLoader):
return [provider_message.ContentElement.from_text(json.dumps(body, ensure_ascii=False, indent=2))]
async def _invoke_mcp_read_resource(self, parameters: dict, query: pipeline_query.Query) -> typing.Any:
+ execution_context = _execution_context_from_query(query)
server_name = parameters.get('server_name') if parameters else None
uri = parameters.get('uri') if parameters else None
if not server_name or not isinstance(server_name, str):
@@ -1526,7 +2133,7 @@ class MCPLoader(loader.ToolLoader):
return [provider_message.ContentElement.from_text('Error: "uri" (string) is required.')]
bound = self._get_bound_mcp_from_query(query)
- allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(bound)}
+ allowed = {s.server_name for s in self._eligible_resource_sessions_for_bound(execution_context, bound)}
if server_name not in allowed:
return [
provider_message.ContentElement.from_text(
@@ -1535,7 +2142,7 @@ class MCPLoader(loader.ToolLoader):
)
]
- session = self.get_session(server_name)
+ session = self.get_session(execution_context, server_name)
if session is None or session.status != MCPSessionStatus.CONNECTED:
return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')]
@@ -1586,13 +2193,15 @@ class MCPLoader(loader.ToolLoader):
async def get_tools(
self,
+ context: TenantContext,
bound_mcp_servers: list[str] | None = None,
*,
include_resource_tools: bool = True,
) -> list[resource_tool.LLMTool]:
+ await self._assert_execution_active(context)
all_functions: list[resource_tool.LLMTool] = []
- for session in self.sessions.values():
+ for session in self._sessions_for_context(context):
# If bound_mcp_servers is specified, only include tools from those servers
if bound_mcp_servers is not None:
if session.server_uuid in bound_mcp_servers:
@@ -1601,22 +2210,22 @@ class MCPLoader(loader.ToolLoader):
# If no bound servers specified, include all tools
all_functions.extend(session.get_tools())
- if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers):
+ if include_resource_tools and self._eligible_resource_sessions_for_bound(context, bound_mcp_servers):
all_functions.extend(self._mcp_synthetic_resource_tools())
- self._last_listed_functions = all_functions
-
return all_functions
async def get_tool_catalog(
self,
+ context: TenantContext,
bound_mcp_servers: list[str] | None = None,
*,
include_resource_tools: bool = False,
) -> list[dict[str, typing.Any]]:
+ await self._assert_execution_active(context)
items: list[dict[str, typing.Any]] = []
- for session in self.sessions.values():
+ for session in self._sessions_for_context(context):
if bound_mcp_servers is not None and session.server_uuid not in bound_mcp_servers:
continue
for tool in session.get_tools():
@@ -1632,7 +2241,7 @@ class MCPLoader(loader.ToolLoader):
}
)
- if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers):
+ if include_resource_tools and self._eligible_resource_sessions_for_bound(context, bound_mcp_servers):
for tool in self._mcp_synthetic_resource_tools():
items.append(
{
@@ -1648,18 +2257,20 @@ class MCPLoader(loader.ToolLoader):
return items
- async def has_tool(self, name: str) -> bool:
+ async def has_tool(self, context: TenantContext, name: str) -> bool:
"""检查工具是否存在"""
+ await self._assert_execution_active(context)
if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE):
- return bool(self._eligible_resource_sessions_for_bound(None))
- for session in self.sessions.values():
+ return bool(self._eligible_resource_sessions_for_bound(context, None))
+ for session in self._sessions_for_context(context):
for function in session.get_tools():
if function.name == name:
return True
return False
- async def get_tool(self, name: str) -> resource_tool.LLMTool | None:
- for session in self.sessions.values():
+ async def get_tool(self, context: TenantContext, name: str) -> resource_tool.LLMTool | None:
+ await self._assert_execution_active(context)
+ for session in self._sessions_for_context(context):
for function in session.get_tools():
if function.name == name:
return function
@@ -1667,6 +2278,7 @@ class MCPLoader(loader.ToolLoader):
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
"""执行工具调用"""
+ execution_context = await self._assert_execution_active(_execution_context_from_query(query))
if name == MCP_TOOL_LIST_RESOURCES:
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')]
@@ -1676,7 +2288,7 @@ class MCPLoader(loader.ToolLoader):
return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')]
return await self._invoke_mcp_read_resource(parameters, query)
- for session in self.sessions.values():
+ for session in self._sessions_for_context(execution_context):
for function in session.get_tools():
if function.name == name:
self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}')
@@ -1690,22 +2302,25 @@ class MCPLoader(loader.ToolLoader):
raise ValueError(f'Tool not found: {name}')
- async def get_resources(self, server_name: str) -> list[dict]:
+ async def get_resources(self, context: TenantContext, server_name: str) -> list[dict]:
"""Get resources from a specific MCP server."""
- session = self.get_session(server_name)
+ await self._assert_execution_active(context)
+ session = self.get_session(context, server_name)
if session is None:
raise ValueError(f'MCP server not found: {server_name}')
return session.get_resources()
- async def get_resource_templates(self, server_name: str) -> list[dict]:
+ async def get_resource_templates(self, context: TenantContext, server_name: str) -> list[dict]:
"""Get resource templates from a specific MCP server."""
- session = self.get_session(server_name)
+ await self._assert_execution_active(context)
+ session = self.get_session(context, server_name)
if session is None:
raise ValueError(f'MCP server not found: {server_name}')
return session.get_resource_templates()
async def read_resource_envelope(
self,
+ context: TenantContext,
server_name: str,
uri: str,
*,
@@ -1716,7 +2331,8 @@ class MCPLoader(loader.ToolLoader):
query: pipeline_query.Query | None = None,
) -> dict:
"""Read a resource from a specific MCP server and return metadata plus contents."""
- session = self.get_session(server_name)
+ await self._assert_execution_active(context)
+ session = self.get_session(context, server_name)
if session is None:
raise ValueError(f'MCP server not found: {server_name}')
return await session.read_resource_envelope(
@@ -1728,24 +2344,28 @@ class MCPLoader(loader.ToolLoader):
query=query,
)
- async def read_resource(self, server_name: str, uri: str) -> list[dict]:
+ async def read_resource(self, context: TenantContext, server_name: str, uri: str) -> list[dict]:
"""Read a resource from a specific MCP server."""
- envelope = await self.read_resource_envelope(server_name, uri)
+ envelope = await self.read_resource_envelope(context, server_name, uri)
return envelope['contents']
- def get_session_by_uuid(self, server_uuid: str) -> RuntimeMCPSession | None:
- for session in self.sessions.values():
+ def get_session_by_uuid(self, context: TenantContext, server_uuid: str) -> RuntimeMCPSession | None:
+ for session in self._sessions_for_context(context):
if session.server_uuid == server_uuid:
return session
return None
- def _resolve_attachment_session(self, attachment: dict) -> RuntimeMCPSession | None:
+ def _resolve_attachment_session(
+ self,
+ context: TenantContext,
+ attachment: dict,
+ ) -> RuntimeMCPSession | None:
server_uuid = attachment.get('server_uuid') or attachment.get('server_id')
server_name = attachment.get('server_name')
if server_uuid:
- return self.get_session_by_uuid(server_uuid)
+ return self.get_session_by_uuid(context, server_uuid)
if server_name:
- return self.get_session(server_name)
+ return self.get_session(context, server_name)
return None
async def build_resource_context_for_query(
@@ -1756,6 +2376,7 @@ class MCPLoader(loader.ToolLoader):
default_max_bytes: int = MCP_RESOURCE_CONTEXT_MAX_BYTES,
) -> str:
"""Build host-controlled MCP resource context for the current query."""
+ execution_context = await self._assert_execution_active(_execution_context_from_query(query))
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False:
return ''
@@ -1764,7 +2385,7 @@ class MCPLoader(loader.ToolLoader):
return ''
bound = self._get_bound_mcp_from_query(query)
- eligible = self._eligible_resource_sessions_for_bound(bound)
+ eligible = self._eligible_resource_sessions_for_bound(execution_context, bound)
eligible_by_uuid = {session.server_uuid: session for session in eligible}
eligible_by_name = {session.server_name: session for session in eligible}
@@ -1772,6 +2393,7 @@ class MCPLoader(loader.ToolLoader):
remaining_tokens = default_max_tokens
for raw_attachment in attachments:
+ await self._assert_execution_active(execution_context)
if remaining_tokens <= 0:
break
if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled') is False:
@@ -1786,7 +2408,7 @@ class MCPLoader(loader.ToolLoader):
if not uri or not isinstance(uri, str):
continue
- session = self._resolve_attachment_session(attachment)
+ session = self._resolve_attachment_session(execution_context, attachment)
if session is None:
continue
if session.server_uuid not in eligible_by_uuid and session.server_name not in eligible_by_name:
@@ -1804,6 +2426,8 @@ class MCPLoader(loader.ToolLoader):
source='preloaded',
query=query,
)
+ except WorkspaceError:
+ raise
except Exception as e:
self.ap.logger.warning(f'Failed to preload MCP resource {uri!r} from {session.server_name!r}: {e}')
continue
@@ -1843,37 +2467,42 @@ class MCPLoader(loader.ToolLoader):
pass
return context
- async def remove_mcp_server(self, server_name: str):
+ async def remove_mcp_server(self, context: TenantContext, server_name: str):
"""移除 MCP 服务器"""
- if server_name not in self.sessions:
+ await self._assert_execution_active(context)
+ key = self._session_key(context, server_name)
+ if key not in self.sessions:
self.ap.logger.warning(f'MCP server {server_name} not found in sessions, skipping removal')
return
- session = self.sessions.pop(server_name)
+ session = self._pop_session(context, server_name)
+ if session is None:
+ return
await session.shutdown()
self.ap.logger.info(f'Removed MCP server: {server_name}')
- def get_session(self, server_name: str) -> RuntimeMCPSession | None:
+ def get_session(self, context: TenantContext, server_name: str) -> RuntimeMCPSession | None:
"""获取指定名称的 MCP 会话"""
- return self.sessions.get(server_name)
+ return self.sessions.get(self._session_key(context, server_name))
- def has_session(self, server_name: str) -> bool:
+ def has_session(self, context: TenantContext, server_name: str) -> bool:
"""检查是否存在指定名称的 MCP 会话"""
- return server_name in self.sessions
+ return self._session_key(context, server_name) in self.sessions
- def get_all_server_names(self) -> list[str]:
+ def get_all_server_names(self, context: TenantContext) -> list[str]:
"""获取所有已加载的 MCP 服务器名称"""
- return list(self.sessions.keys())
+ return [session.server_name for session in self._sessions_for_context(context)]
- def get_server_tool_count(self, server_name: str) -> int:
+ def get_server_tool_count(self, context: TenantContext, server_name: str) -> int:
"""获取指定服务器的工具数量"""
- session = self.get_session(server_name)
+ session = self.get_session(context, server_name)
return len(session.get_tools()) if session else 0
- def get_all_servers_info(self) -> dict[str, dict]:
+ def get_all_servers_info(self, context: TenantContext) -> dict[str, dict]:
"""获取所有服务器的信息"""
info = {}
- for server_name, session in self.sessions.items():
+ for session in self._sessions_for_context(context):
+ server_name = session.server_name
tools = session.get_tools()
info[server_name] = {
'name': server_name,
@@ -1888,22 +2517,5 @@ class MCPLoader(loader.ToolLoader):
"""关闭所有工具"""
self.ap.logger.info('Shutting down all MCP sessions...')
- hosted_tasks = [task for task in self._hosted_mcp_tasks if not task.done()]
- for task in hosted_tasks:
- task.cancel()
- if hosted_tasks:
- await asyncio.gather(*hosted_tasks, return_exceptions=True)
- self._hosted_mcp_tasks.clear()
-
- async def shutdown_session(server_name: str, session: RuntimeMCPSession) -> None:
- try:
- await session.shutdown()
- self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
- except Exception as e:
- self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
-
- await asyncio.gather(
- *(shutdown_session(server_name, session) for server_name, session in list(self.sessions.items()))
- )
- self.sessions.clear()
+ await self._reset_runtime_state()
self.ap.logger.info('All MCP sessions shutdown complete')
diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_policy.py b/src/langbot/pkg/provider/tools/loaders/mcp_policy.py
new file mode 100644
index 000000000..629de3d1b
--- /dev/null
+++ b/src/langbot/pkg/provider/tools/loaders/mcp_policy.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from typing import Any
+
+
+MCP_STDIO_DISABLED_CODE = 'mcp_stdio_disabled'
+MCP_STDIO_DISABLED_MESSAGE = 'Stdio MCP is disabled by instance policy'
+
+
+class MCPStdioDisabledError(RuntimeError):
+ """Raised when an instance-level policy refuses stdio MCP execution."""
+
+ code = MCP_STDIO_DISABLED_CODE
+
+ def __init__(self) -> None:
+ super().__init__(MCP_STDIO_DISABLED_MESSAGE)
+
+
+def stdio_mcp_enabled(ap: Any) -> bool:
+ """Return the independent instance-level stdio MCP feature gate.
+
+ The open-source default remains enabled for backwards compatibility. A
+ deployment can disable it with ``mcp.stdio.enabled: false`` (or
+ ``MCP__STDIO__ENABLED=false``). Invalid values fail closed instead of
+ accidentally enabling local process execution.
+ """
+
+ instance_config = getattr(ap, 'instance_config', None)
+ config = getattr(instance_config, 'data', None)
+ if not isinstance(config, dict):
+ return False
+ mcp_config = config.get('mcp', {})
+ if not isinstance(mcp_config, dict):
+ return False
+ stdio_config = mcp_config.get('stdio', {})
+ if not isinstance(stdio_config, dict):
+ return False
+ enabled = stdio_config.get('enabled', True)
+ return enabled if isinstance(enabled, bool) else False
+
+
+def is_stdio_server(server_config: dict[str, Any] | None) -> bool:
+ return isinstance(server_config, dict) and str(server_config.get('mode') or '').strip().lower() == 'stdio'
+
+
+def require_stdio_mcp_enabled(ap: Any, server_config: dict[str, Any] | None) -> None:
+ """Fail closed for a stdio server before choosing Box or host transport."""
+
+ if is_stdio_server(server_config) and not stdio_mcp_enabled(ap):
+ raise MCPStdioDisabledError()
diff --git a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py
index 8c956fda3..110fff431 100644
--- a/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py
+++ b/src/langbot/pkg/provider/tools/loaders/mcp_stdio.py
@@ -6,10 +6,12 @@ import os
import shutil
import shlex
import threading
-from contextlib import suppress, AsyncExitStack
+import weakref
+from contextlib import suppress, AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, Any
import pydantic
+from ....utils import bounded_executor
from mcp import ClientSession
from mcp.client.websocket import websocket_client
from ....box.workspace import (
@@ -27,7 +29,7 @@ if TYPE_CHECKING:
from .mcp import RuntimeMCPSession
-_WORKSPACE_COPY_LOCKS: dict[str, threading.Lock] = {}
+_WORKSPACE_COPY_LOCKS: weakref.WeakValueDictionary[str, threading.Lock] = weakref.WeakValueDictionary()
_WORKSPACE_COPY_LOCKS_GUARD = threading.Lock()
@@ -94,6 +96,60 @@ class MCPServerBoxConfig(pydantic.BaseModel):
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
+@asynccontextmanager
+async def authenticated_websocket_client(url: str, headers: dict[str, str]):
+ """MCP WebSocket transport with host-only Box relay headers.
+
+ The upstream MCP helper does not expose WebSocket handshake headers. This
+ mirrors that transport while keeping the Box control token out of the URL,
+ JSON-RPC payloads, and logs.
+ """
+
+ import json
+
+ import anyio
+ import mcp.types as mcp_types
+ from mcp.shared.message import SessionMessage
+ from pydantic import ValidationError
+ from websockets.asyncio.client import connect as ws_connect
+ from websockets.typing import Subprotocol
+
+ read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
+ write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
+
+ async with ws_connect(
+ url,
+ subprotocols=[Subprotocol('mcp')],
+ additional_headers=dict(headers),
+ proxy=None,
+ ) as websocket:
+
+ async def ws_reader():
+ async with read_stream_writer:
+ async for raw_text in websocket:
+ try:
+ message = mcp_types.JSONRPCMessage.model_validate_json(raw_text)
+ await read_stream_writer.send(SessionMessage(message))
+ except ValidationError as exc: # pragma: no cover - upstream parity
+ await read_stream_writer.send(exc)
+
+ async def ws_writer():
+ async with write_stream_reader:
+ async for session_message in write_stream_reader:
+ payload = session_message.message.model_dump(
+ by_alias=True,
+ mode='json',
+ exclude_none=True,
+ )
+ await websocket.send(json.dumps(payload))
+
+ async with anyio.create_task_group() as task_group:
+ task_group.start_soon(ws_reader)
+ task_group.start_soon(ws_writer)
+ yield read_stream, write_stream
+ task_group.cancel_scope.cancel()
+
+
class _TransferredStack:
"""Adapts an already-populated AsyncExitStack into an async context manager
so ownership of its resources can be transferred into another exit stack.
@@ -149,6 +205,7 @@ class BoxStdioSessionRuntime:
resolved_host_path = self.resolve_host_path() if host_path is ... else host_path
return BoxWorkspaceSession(
self.ap.box_service,
+ self.owner.execution_context,
self.owner._build_box_session_id(),
host_path=resolved_host_path,
host_path_mode=self.config.host_path_mode,
@@ -249,7 +306,11 @@ class BoxStdioSessionRuntime:
if install_cmd:
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
payload['process_id'] = self.process_id
- await workspace.box_service.start_managed_process(workspace.session_id, payload)
+ await workspace.box_service.start_managed_process(
+ workspace.execution_context,
+ workspace.session_id,
+ payload,
+ )
except Exception:
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
raise
@@ -259,7 +320,10 @@ class BoxStdioSessionRuntime:
f'process_id={self.process_id} (transport reconnect)'
)
- websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
+ (
+ websocket_url,
+ websocket_headers,
+ ) = await workspace.get_managed_process_websocket_connection(self.process_id)
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
# in the same task as the serve loop that follows. websocket_client and
@@ -277,7 +341,12 @@ class BoxStdioSessionRuntime:
# attempt re-attaches to the same live process; once it has finished
# cold start the handshake succeeds and stays healthy.
try:
- transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
+ transport_context = (
+ authenticated_websocket_client(websocket_url, websocket_headers)
+ if websocket_headers
+ else websocket_client(websocket_url)
+ )
+ transport = await self.owner.exit_stack.enter_async_context(transport_context)
read_stream, write_stream = transport
self.owner.session = await self.owner.exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
@@ -469,7 +538,11 @@ class BoxStdioSessionRuntime:
return
try:
process_host_root = os.path.join(self._shared_workspace_host_path(), '.mcp', self.process_id)
- await asyncio.to_thread(shutil.rmtree, process_host_root, True)
+ await bounded_executor.run_blocking_cleanup(
+ shutil.rmtree,
+ process_host_root,
+ True,
+ )
except Exception as exc:
self.ap.logger.warning(
f'MCP server {self.server_name}: failed to clean staged workspace '
diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py
index a1a8c027b..d2dfdc969 100644
--- a/src/langbot/pkg/provider/tools/loaders/native.py
+++ b/src/langbot/pkg/provider/tools/loaders/native.py
@@ -1,16 +1,29 @@
from __future__ import annotations
+import asyncio
import base64
+import contextlib
+import errno
+import heapq
import json
import os
+import posixpath
+import stat
+import time
+from collections.abc import Iterator
+from dataclasses import dataclass
+from pathlib import PurePosixPath
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot_plugin.api.entities.events import pipeline_query
+import regex
from .. import loader
from ..errors import ToolNotFoundError
from .availability import is_box_backend_available
from . import skill as skill_loader
+from ....api.http.context import ExecutionContext
+from ....utils.bounded_executor import run_blocking_atomic
EXEC_TOOL_NAME = 'exec'
READ_TOOL_NAME = 'read'
@@ -28,10 +41,170 @@ _DEFAULT_READ_MAX_LINES = 2000
_MAX_READ_MAX_LINES = 10000
_DEFAULT_TOOL_RESULT_MAX_BYTES = 50 * 1024
_BOX_FILE_SCRIPT_MAX_BYTES = 2048
+_MAX_HOST_EDIT_FILE_BYTES = 1024 * 1024
_GLOB_MAX_MATCHES = 100
+_FILE_WALK_MAX_ENTRIES = 100_000
+_DIRECTORY_MAX_ENTRIES = 10_000
_GREP_MAX_MATCHES = 200
_GREP_MAX_FILES = 5000
_GREP_MAX_LINE_CHARS = 500
+_GREP_MAX_SCAN_LINE_CHARS = 1024 * 1024
+_GREP_MAX_FILE_SCAN_CHARS = 10 * 1024 * 1024
+_GREP_MAX_TOTAL_SCAN_CHARS = 50 * 1024 * 1024
+_GREP_MAX_PATTERN_CHARS = 1024
+_GREP_REGEX_TIMEOUT_SECONDS = 0.25
+
+_DIRECTORY_OPEN_FLAGS = (
+ os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
+)
+_FILE_OPEN_FLAGS = getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0)
+_SECURE_HOST_FILE_OPS_AVAILABLE = bool(
+ getattr(os, 'O_NOFOLLOW', 0)
+ and os.open in os.supports_dir_fd
+ and os.stat in os.supports_dir_fd
+ and os.mkdir in os.supports_dir_fd
+ and os.listdir in os.supports_fd
+ and os.scandir in os.supports_fd
+)
+
+
+@dataclass(frozen=True)
+class _HostLocation:
+ root: str
+ relative_parts: tuple[str, ...]
+ selected_skill: dict | None
+ workspace_anchor: str | None = None
+
+
+def _unsafe_host_path(path: str, exc: BaseException | None = None) -> ValueError:
+ error = ValueError(f'Path escapes the workspace boundary or contains a symbolic link: {path}')
+ if exc is not None:
+ error.__cause__ = exc
+ return error
+
+
+def _relative_workspace_parts(path: str) -> tuple[str, ...]:
+ normalized = posixpath.normpath(str(path or '/workspace').strip() or '/workspace')
+ if normalized == '/workspace':
+ return ()
+ if not normalized.startswith('/workspace/'):
+ raise ValueError('Path escapes the workspace boundary.')
+
+ parts = tuple(part for part in normalized.removeprefix('/workspace/').split('/') if part)
+ if any(part in {'.', '..'} or '\x00' in part for part in parts):
+ raise ValueError('Path escapes the workspace boundary.')
+ return parts
+
+
+def _is_symlink_at(parent_fd: int, name: str) -> bool:
+ try:
+ return stat.S_ISLNK(os.stat(name, dir_fd=parent_fd, follow_symlinks=False).st_mode)
+ except FileNotFoundError:
+ return False
+
+
+def _open_directory_at(parent_fd: int, name: str, *, create: bool) -> int:
+ if create:
+ try:
+ os.mkdir(name, mode=0o777, dir_fd=parent_fd)
+ except FileExistsError:
+ pass
+
+ try:
+ directory_fd = os.open(name, _DIRECTORY_OPEN_FLAGS, dir_fd=parent_fd)
+ except OSError as exc:
+ if exc.errno == errno.ELOOP or _is_symlink_at(parent_fd, name):
+ raise _unsafe_host_path(name, exc)
+ raise
+ if not stat.S_ISDIR(os.fstat(directory_fd).st_mode):
+ os.close(directory_fd)
+ raise NotADirectoryError(name)
+ return directory_fd
+
+
+def _open_directory_parts(root_fd: int, parts: tuple[str, ...], *, create: bool) -> int:
+ current_fd = os.dup(root_fd)
+ try:
+ for part in parts:
+ next_fd = _open_directory_at(current_fd, part, create=create)
+ os.close(current_fd)
+ current_fd = next_fd
+ return current_fd
+ except BaseException:
+ os.close(current_fd)
+ raise
+
+
+@contextlib.contextmanager
+def _open_host_root(location: _HostLocation, *, create: bool) -> Iterator[int]:
+ """Open and pin the tenant root before resolving tenant-controlled names."""
+
+ if location.workspace_anchor is None:
+ root_path = os.path.realpath(location.root)
+ try:
+ root_fd = os.open(root_path, _DIRECTORY_OPEN_FLAGS)
+ except OSError as exc:
+ if exc.errno == errno.ELOOP:
+ raise _unsafe_host_path(location.root, exc)
+ raise
+ else:
+ anchor_path = os.path.abspath(location.workspace_anchor)
+ root_path = os.path.abspath(location.root)
+ try:
+ if os.path.commonpath((anchor_path, root_path)) != anchor_path:
+ raise _unsafe_host_path(location.root)
+ except ValueError as exc:
+ raise _unsafe_host_path(location.root, exc)
+
+ anchor_real_path = os.path.realpath(anchor_path)
+ try:
+ anchor_fd = os.open(anchor_real_path, _DIRECTORY_OPEN_FLAGS)
+ except OSError as exc:
+ if exc.errno == errno.ELOOP:
+ raise _unsafe_host_path(location.workspace_anchor, exc)
+ raise
+ try:
+ root_relative = os.path.relpath(root_path, anchor_path)
+ root_parts = () if root_relative == '.' else tuple(root_relative.split(os.sep))
+ root_fd = _open_directory_parts(anchor_fd, root_parts, create=create)
+ finally:
+ os.close(anchor_fd)
+
+ try:
+ if not stat.S_ISDIR(os.fstat(root_fd).st_mode):
+ raise _unsafe_host_path(location.root)
+ yield root_fd
+ finally:
+ os.close(root_fd)
+
+
+@contextlib.contextmanager
+def _open_location_fd(
+ root_fd: int,
+ relative_parts: tuple[str, ...],
+ flags: int,
+ *,
+ create_parents: bool = False,
+ mode: int = 0o666,
+) -> Iterator[int]:
+ if not relative_parts:
+ target_fd = os.dup(root_fd)
+ else:
+ parent_fd = _open_directory_parts(root_fd, relative_parts[:-1], create=create_parents)
+ try:
+ try:
+ target_fd = os.open(relative_parts[-1], flags | _FILE_OPEN_FLAGS, mode, dir_fd=parent_fd)
+ except OSError as exc:
+ if exc.errno == errno.ELOOP or _is_symlink_at(parent_fd, relative_parts[-1]):
+ raise _unsafe_host_path(relative_parts[-1], exc)
+ raise
+ finally:
+ os.close(parent_fd)
+
+ try:
+ yield target_fd
+ finally:
+ os.close(target_fd)
class NativeToolLoader(loader.ToolLoader):
@@ -56,6 +229,21 @@ class NativeToolLoader(loader.ToolLoader):
"""Check if the box backend is truly available (not just the runtime)."""
return await is_box_backend_available(self.ap)
+ @staticmethod
+ def _execution_context(query: pipeline_query.Query) -> ExecutionContext:
+ attached_context = getattr(query, '_execution_context', None)
+ if isinstance(attached_context, ExecutionContext):
+ return attached_context
+ return ExecutionContext(
+ instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
+ workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
+ placement_generation=getattr(query, 'placement_generation', 0) or 0,
+ bot_uuid=getattr(query, 'bot_uuid', None),
+ pipeline_uuid=getattr(query, 'pipeline_uuid', None),
+ query_uuid=getattr(query, 'query_uuid', None),
+ entitlement_revision=getattr(query, 'entitlement_revision', 0),
+ )
+
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not await self._is_sandbox_available():
return []
@@ -74,6 +262,13 @@ class NativeToolLoader(loader.ToolLoader):
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
+ require_sandbox = getattr(
+ getattr(self.ap, 'box_service', None),
+ 'require_workspace_sandbox',
+ None,
+ )
+ if callable(require_sandbox):
+ await require_sandbox(self._execution_context(query))
if name == EXEC_TOOL_NAME:
self.ap.logger.info(
'exec tool invoked: '
@@ -99,6 +294,7 @@ class NativeToolLoader(loader.ToolLoader):
async def _invoke_exec(self, parameters: dict, query: pipeline_query.Query) -> dict:
command = str(parameters['command'])
workdir = str(parameters.get('workdir', '/workspace') or '/workspace')
+ selected_skill_name: str | None = None
# Validate that skill references target activated skills.
selected_skill, _ = skill_loader.resolve_virtual_skill_path(
@@ -128,31 +324,51 @@ class NativeToolLoader(loader.ToolLoader):
if not package_root:
raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.')
+ # Pass only the logical name across the authenticated Core→Runtime
+ # boundary. In Cloud mode the shared Box Runtime resolves the
+ # Workspace-scoped package root and constructs the read-only mount;
+ # Core host paths are never accepted as mount authority.
# Wrap command with Python venv bootstrap if the skill has a Python project.
# The venv is created inside the skill's mount path.
skill_mount = f'/workspace/.skills/{selected_skill_name}'
- if skill_loader.should_prepare_skill_python_env(package_root):
+ python_project = selected_skill.get('python_project') is True
+ if 'python_project' not in selected_skill and bool(
+ getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
+ ):
+ # Backward compatibility for a same-process OSS Runtime that
+ # predates trusted Box metadata. Never probe a path reported by
+ # an external Runtime from the Core filesystem.
+ python_project = skill_loader.should_prepare_skill_python_env(package_root)
+ if python_project:
parameters = dict(parameters)
- parameters['command'] = skill_loader.wrap_skill_command_with_python_env(command, mount_path=skill_mount)
+ parameters['command'] = skill_loader.wrap_skill_command_with_python_env(
+ command,
+ mount_path=skill_mount,
+ state_path=f'/workspace/.skill-envs/{selected_skill_name}',
+ )
# All exec calls (with or without skills) go through the same container
# via execute_tool. Skills are mounted at /workspace/.skills/{name}/
# via extra_mounts built by BoxService.
- result = await self.ap.box_service.execute_tool(parameters, query)
+ result = await self.ap.box_service.execute_tool(
+ parameters,
+ query,
+ skill_name=selected_skill_name,
+ )
result = self._normalize_exec_result(result)
if selected_skill is not None:
- self._refresh_skill_from_disk(selected_skill)
+ self._refresh_skill_from_disk(query, selected_skill)
return result
- def _resolve_host_path(
+ def _resolve_host_location(
self,
query: pipeline_query.Query,
sandbox_path: str,
*,
include_visible: bool,
include_activated: bool,
- ) -> tuple[str, dict | None]:
+ ) -> _HostLocation:
selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path(
self.ap,
query,
@@ -162,22 +378,26 @@ class NativeToolLoader(loader.ToolLoader):
)
box_service = self.ap.box_service
- host_root = selected_skill.get('package_root') if selected_skill is not None else box_service.default_workspace
+ if selected_skill is not None:
+ if not self._can_interpret_skill_host_paths():
+ raise ValueError(
+ 'Skill package paths are owned by the Box Runtime; '
+ 'this operation requires a Runtime skill-file API.'
+ )
+ host_root = selected_skill.get('package_root')
+ workspace_anchor = None
+ else:
+ host_root = box_service._tenant_workspace(self._execution_context(query))
+ workspace_anchor = getattr(box_service, 'default_workspace', None)
if not host_root:
raise ValueError('No host workspace configured for file operations.')
- mount_path = '/workspace'
- if not rewritten_path.startswith(mount_path):
- raise ValueError(f'Path must be under {mount_path}.')
-
- relative = rewritten_path[len(mount_path) :].lstrip('/')
- host_path = os.path.realpath(os.path.join(host_root, relative))
- host_root = os.path.realpath(host_root)
-
- if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
- raise ValueError('Path escapes the workspace boundary.')
-
- return host_path, selected_skill
+ return _HostLocation(
+ root=str(host_root),
+ relative_parts=_relative_workspace_parts(rewritten_path),
+ selected_skill=selected_skill,
+ workspace_anchor=str(workspace_anchor) if workspace_anchor else None,
+ )
def _resolve_skill_relative_path(
self,
@@ -197,21 +417,329 @@ class NativeToolLoader(loader.ToolLoader):
if selected_skill is None:
return None
- mount_path = '/workspace'
- if not rewritten_path.startswith(mount_path):
- raise ValueError(f'Path must be under {mount_path}.')
- relative = rewritten_path[len(mount_path) :].lstrip('/') or '.'
+ relative = '/'.join(_relative_workspace_parts(rewritten_path)) or '.'
return selected_skill, relative
+ def _can_interpret_skill_host_paths(self) -> bool:
+ """Require an explicitly proven shared Core/Runtime filesystem view."""
+
+ return _SECURE_HOST_FILE_OPS_AVAILABLE and bool(
+ getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
+ )
+
def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool:
if selected_skill is not None:
return False
box_service = getattr(self.ap, 'box_service', None)
if box_service is None or not hasattr(box_service, 'execute_tool'):
return False
+ if not _SECURE_HOST_FILE_OPS_AVAILABLE:
+ # Preserve the OSS API on platforms without openat/O_NOFOLLOW by
+ # running inside the tenant-scoped Box mount, never via a racy
+ # host-path fallback.
+ return True
default_workspace = getattr(box_service, 'default_workspace', None)
return bool(default_workspace and not os.path.isdir(os.path.realpath(default_workspace)))
+ def _read_host_location(self, location: _HostLocation, parameters: dict) -> dict:
+ with _open_host_root(location, create=False) as root_fd:
+ with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
+ metadata = os.fstat(target_fd)
+ if stat.S_ISDIR(metadata.st_mode):
+ entries: list[str] = []
+ truncated = False
+ with os.scandir(target_fd) as iterator:
+ for entry in iterator:
+ if len(entries) >= _DIRECTORY_MAX_ENTRIES:
+ truncated = True
+ break
+ entries.append(entry.name)
+ return self._build_directory_result(
+ entries,
+ total=len(entries) + int(truncated),
+ force_truncated_by='entries' if truncated else None,
+ )
+ if not stat.S_ISREG(metadata.st_mode):
+ raise ValueError('Path must reference a regular file or directory.')
+ return self._read_text_file_preview(target_fd, parameters, metadata=metadata)
+
+ def _write_host_location(self, location: _HostLocation, content: str, parameters: dict) -> None:
+ if not location.relative_parts:
+ raise ValueError('Path must reference a file under /workspace.')
+
+ encoding, mode = self._write_options(parameters)
+ if encoding == 'base64':
+ try:
+ payload = base64.b64decode(content, validate=True)
+ except Exception as exc:
+ raise ValueError(f'invalid base64 content: {exc}') from exc
+ else:
+ payload = content.encode('utf-8')
+
+ flags = os.O_WRONLY | os.O_CREAT
+ if mode == 'append':
+ flags |= os.O_APPEND
+ with _open_host_root(location, create=True) as root_fd:
+ with _open_location_fd(
+ root_fd,
+ location.relative_parts,
+ flags,
+ create_parents=True,
+ ) as target_fd:
+ if not stat.S_ISREG(os.fstat(target_fd).st_mode):
+ raise ValueError('Path must reference a regular file.')
+ if mode != 'append':
+ os.ftruncate(target_fd, 0)
+ os.lseek(target_fd, 0, os.SEEK_SET)
+ self._write_all(target_fd, payload)
+
+ def _edit_host_location(
+ self,
+ location: _HostLocation,
+ old_string: str,
+ new_string: str,
+ ) -> tuple[bool, str | None]:
+ if not location.relative_parts:
+ raise ValueError('Path must reference a file under /workspace.')
+
+ with _open_host_root(location, create=False) as root_fd:
+ with _open_location_fd(root_fd, location.relative_parts, os.O_RDWR) as target_fd:
+ metadata = os.fstat(target_fd)
+ if not stat.S_ISREG(metadata.st_mode):
+ return False, 'File not found.'
+ if metadata.st_size > _MAX_HOST_EDIT_FILE_BYTES:
+ return False, f'File exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte edit limit.'
+ with os.fdopen(os.dup(target_fd), 'rb') as file_obj:
+ raw_content = file_obj.read(_MAX_HOST_EDIT_FILE_BYTES + 1)
+ if len(raw_content) > _MAX_HOST_EDIT_FILE_BYTES:
+ return False, f'File exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte edit limit.'
+ content = raw_content.decode('utf-8', errors='replace')
+ count = content.count(old_string)
+ if count == 0:
+ return False, 'old_string not found in file.'
+ if count > 1:
+ return False, f'old_string matches {count} locations; provide a more unique string.'
+
+ payload = content.replace(old_string, new_string, 1).encode('utf-8')
+ if len(payload) > _MAX_HOST_EDIT_FILE_BYTES:
+ return False, f'Edited file exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte limit.'
+ os.ftruncate(target_fd, 0)
+ os.lseek(target_fd, 0, os.SEEK_SET)
+ self._write_all(target_fd, payload)
+ return True, None
+
+ @staticmethod
+ def _write_all(file_fd: int, payload: bytes) -> None:
+ view = memoryview(payload)
+ while view:
+ written = os.write(file_fd, view)
+ if written <= 0:
+ raise OSError('Could not write the complete workspace file.')
+ view = view[written:]
+
+ @staticmethod
+ def _rglob_matches(relative_path: str, pattern: str) -> bool:
+ candidates = {pattern}
+ pending = [pattern]
+ while pending:
+ candidate = pending.pop()
+ marker = candidate.find('**/')
+ while marker >= 0:
+ without_recursive_segment = candidate[:marker] + candidate[marker + 3 :]
+ if without_recursive_segment not in candidates:
+ candidates.add(without_recursive_segment)
+ pending.append(without_recursive_segment)
+ marker = candidate.find('**/', marker + 3)
+ return any(candidate and PurePosixPath(relative_path).match(candidate) for candidate in candidates)
+
+ def _glob_host_location(self, location: _HostLocation, pattern: str, sandbox_base: str) -> dict:
+ newest_hits: list[tuple[float, str]] = []
+ total = 0
+ entries_seen = 0
+ scan_truncated = False
+
+ def walk(directory_fd: int, prefix: str) -> bool:
+ nonlocal entries_seen, scan_truncated, total
+ with os.scandir(directory_fd) as entries:
+ for entry in entries:
+ entries_seen += 1
+ if entries_seen > _FILE_WALK_MAX_ENTRIES:
+ scan_truncated = True
+ return True
+ name = entry.name
+ if name in _SKIP_DIRS:
+ continue
+ try:
+ child_fd = os.open(name, os.O_RDONLY | _FILE_OPEN_FLAGS, dir_fd=directory_fd)
+ except OSError:
+ continue
+ try:
+ metadata = os.fstat(child_fd)
+ relative = f'{prefix}/{name}' if prefix else name
+ if self._rglob_matches(relative, pattern):
+ total += 1
+ candidate = (metadata.st_mtime, relative)
+ if len(newest_hits) < _GLOB_MAX_MATCHES:
+ heapq.heappush(newest_hits, candidate)
+ elif candidate > newest_hits[0]:
+ heapq.heapreplace(newest_hits, candidate)
+ if stat.S_ISDIR(metadata.st_mode) and walk(child_fd, relative):
+ return True
+ finally:
+ os.close(child_fd)
+ return False
+
+ with _open_host_root(location, create=False) as root_fd:
+ with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
+ if not stat.S_ISDIR(os.fstat(target_fd).st_mode):
+ return {'ok': False, 'error': f'Path is not a directory: {sandbox_base}'}
+ walk(target_fd, '')
+
+ hits = sorted(newest_hits, reverse=True)
+ sandbox_paths: list[str] = []
+ output_bytes = 0
+ truncated_by_bytes = False
+ for _mtime, relative in hits:
+ sandbox_path = self._sandbox_child_path(sandbox_base, relative)
+ entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if sandbox_paths else 0)
+ if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
+ truncated_by_bytes = True
+ break
+ sandbox_paths.append(sandbox_path)
+ output_bytes += entry_bytes
+
+ return {
+ 'ok': True,
+ 'matches': sandbox_paths,
+ 'preview': '\n'.join(sandbox_paths),
+ 'total': total,
+ 'truncated': scan_truncated or total > len(sandbox_paths) or truncated_by_bytes,
+ 'truncated_by': (
+ 'scan'
+ if scan_truncated
+ else ('bytes' if truncated_by_bytes else ('matches' if total > len(sandbox_paths) else None))
+ ),
+ }
+
+ def _grep_host_location(
+ self,
+ location: _HostLocation,
+ pattern: str,
+ include: str | None,
+ sandbox_base: str,
+ ) -> dict:
+ try:
+ compiled = regex.compile(pattern)
+ except regex.error as exc:
+ return {'ok': False, 'error': f'Invalid regex: {exc}'}
+
+ matches: list[dict] = []
+ output_bytes = 0
+ truncated_by: str | None = None
+ files_seen = 0
+ entries_seen = 0
+ total_chars_seen = 0
+ deadline = time.monotonic() + _GREP_REGEX_TIMEOUT_SECONDS
+
+ def grep_file(file_fd: int, sandbox_path: str) -> bool:
+ nonlocal output_bytes, total_chars_seen, truncated_by
+ file_chars_seen = 0
+ with os.fdopen(os.dup(file_fd), 'r', encoding='utf-8', errors='ignore') as handle:
+ lineno = 0
+ while True:
+ line = handle.readline(_GREP_MAX_SCAN_LINE_CHARS + 1)
+ if not line:
+ break
+ lineno += 1
+ line_chars = len(line)
+ file_chars_seen += line_chars
+ total_chars_seen += line_chars
+ if file_chars_seen > _GREP_MAX_FILE_SCAN_CHARS or total_chars_seen > _GREP_MAX_TOTAL_SCAN_CHARS:
+ truncated_by = 'scan'
+ return True
+ if line_chars > _GREP_MAX_SCAN_LINE_CHARS and not line.endswith('\n'):
+ truncated_by = truncated_by or 'line'
+ return False
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError
+ if not compiled.search(line, timeout=remaining, concurrent=True):
+ continue
+ content, line_truncated = self._truncate_grep_line(line.rstrip())
+ entry = {'file': sandbox_path, 'line': lineno, 'content': content}
+ entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
+ if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
+ truncated_by = 'bytes'
+ return True
+ if line_truncated and truncated_by is None:
+ truncated_by = 'line'
+ matches.append(entry)
+ output_bytes += entry_bytes
+ if len(matches) >= _GREP_MAX_MATCHES:
+ truncated_by = truncated_by or 'matches'
+ return True
+ return False
+
+ def walk(directory_fd: int, prefix: str) -> bool:
+ nonlocal entries_seen, files_seen, truncated_by
+ with os.scandir(directory_fd) as entries:
+ for entry in entries:
+ entries_seen += 1
+ if entries_seen > _FILE_WALK_MAX_ENTRIES:
+ truncated_by = 'scan'
+ return True
+ name = entry.name
+ if name in _SKIP_DIRS:
+ continue
+ try:
+ child_fd = os.open(name, os.O_RDONLY | _FILE_OPEN_FLAGS, dir_fd=directory_fd)
+ except OSError:
+ continue
+ try:
+ metadata = os.fstat(child_fd)
+ relative = f'{prefix}/{name}' if prefix else name
+ if stat.S_ISDIR(metadata.st_mode):
+ if walk(child_fd, relative):
+ return True
+ continue
+ if not stat.S_ISREG(metadata.st_mode):
+ continue
+ if include and not self._rglob_matches(relative, include):
+ continue
+ files_seen += 1
+ if grep_file(child_fd, self._sandbox_child_path(sandbox_base, relative)):
+ return True
+ if files_seen >= _GREP_MAX_FILES:
+ return True
+ finally:
+ os.close(child_fd)
+ return False
+
+ with _open_host_root(location, create=False) as root_fd:
+ with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
+ try:
+ metadata = os.fstat(target_fd)
+ if stat.S_ISREG(metadata.st_mode):
+ grep_file(target_fd, sandbox_base)
+ elif stat.S_ISDIR(metadata.st_mode):
+ walk(target_fd, '')
+ else:
+ return {'ok': False, 'error': f'Path not found: {sandbox_base}'}
+ except TimeoutError:
+ return {'ok': False, 'error': 'Regex search timed out'}
+
+ return {
+ 'ok': True,
+ 'matches': matches,
+ 'total': len(matches),
+ 'truncated': truncated_by is not None,
+ 'truncated_by': truncated_by,
+ }
+
+ @staticmethod
+ def _sandbox_child_path(base: str, relative: str) -> str:
+ return f'{str(base).rstrip("/")}/{relative}'
+
async def _run_workspace_file_script(self, script: str, query: pipeline_query.Query) -> dict:
result = await self.ap.box_service.execute_tool(
{
@@ -256,9 +784,24 @@ if not path.startswith('/workspace'):
elif not os.path.exists(path):
print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}}))
elif os.path.isdir(path):
- entries = sorted(os.listdir(path))
+ entries = []
+ directory_truncated = False
+ with os.scandir(path) as iterator:
+ for entry in iterator:
+ if len(entries) >= {_DIRECTORY_MAX_ENTRIES}:
+ directory_truncated = True
+ break
+ entries.append(entry.name)
+ entries.sort()
content = '\\n'.join(entries)
- print(json.dumps({{'ok': True, 'content': content, 'is_directory': True, 'total': len(entries), 'truncated': False}}))
+ print(json.dumps({{
+ 'ok': True,
+ 'content': content,
+ 'is_directory': True,
+ 'total': len(entries) + int(directory_truncated),
+ 'truncated': directory_truncated,
+ 'truncated_by': 'entries' if directory_truncated else None,
+ }}))
elif encoding == 'base64':
size_bytes = os.path.getsize(path)
with open(path, 'rb') as f:
@@ -362,24 +905,34 @@ if not path.startswith('/workspace'):
print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
elif not os.path.isfile(path):
print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}}))
+elif os.path.getsize(path) > {_MAX_HOST_EDIT_FILE_BYTES}:
+ print(json.dumps({{'ok': False, 'error': 'File exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte edit limit.'}}))
else:
- with open(path, 'r', encoding='utf-8', errors='replace') as f:
- content = f.read()
+ with open(path, 'rb') as f:
+ raw_content = f.read({_MAX_HOST_EDIT_FILE_BYTES + 1})
+ if len(raw_content) > {_MAX_HOST_EDIT_FILE_BYTES}:
+ print(json.dumps({{'ok': False, 'error': 'File exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte edit limit.'}}))
+ raise SystemExit(0)
+ content = raw_content.decode('utf-8', errors='replace')
count = content.count(old_string)
if count == 0:
print(json.dumps({{'ok': False, 'error': 'old_string not found in file.'}}))
elif count > 1:
print(json.dumps({{'ok': False, 'error': f'old_string matches {{count}} locations; provide a more unique string.'}}))
else:
+ new_content = content.replace(old_string, new_string, 1)
+ if len(new_content.encode('utf-8')) > {_MAX_HOST_EDIT_FILE_BYTES}:
+ print(json.dumps({{'ok': False, 'error': 'Edited file exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte limit.'}}))
+ raise SystemExit(0)
with open(path, 'w', encoding='utf-8') as f:
- f.write(content.replace(old_string, new_string, 1))
+ f.write(new_content)
print(json.dumps({{'ok': True, 'path': path}}))
""".strip()
return await self._run_workspace_file_script(script, query)
async def _glob_workspace_via_box(self, path: str, pattern: str, query: pipeline_query.Query) -> dict:
script = f"""
-import json, os
+import heapq, json, os
from pathlib import Path
path = {json.dumps(path)}
pattern = {json.dumps(pattern)}
@@ -390,12 +943,28 @@ elif not os.path.isdir(path):
print(json.dumps({{'ok': False, 'error': f'Path is not a directory: {{path}}'}}))
else:
base = Path(path)
- hits = [
- item for item in base.rglob(pattern)
- if not any(part in skip_dirs for part in item.parts)
- ]
- hits.sort(key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
- shown = hits[:{_GLOB_MAX_MATCHES}]
+ newest_hits = []
+ total = 0
+ entries_seen = 0
+ scan_truncated = False
+ for item in base.rglob(pattern):
+ entries_seen += 1
+ if entries_seen > {_FILE_WALK_MAX_ENTRIES}:
+ scan_truncated = True
+ break
+ if any(part in skip_dirs for part in item.parts):
+ continue
+ total += 1
+ try:
+ mtime = item.stat().st_mtime
+ except OSError:
+ mtime = 0
+ candidate = (mtime, str(item))
+ if len(newest_hits) < {_GLOB_MAX_MATCHES}:
+ heapq.heappush(newest_hits, candidate)
+ elif candidate > newest_hits[0]:
+ heapq.heapreplace(newest_hits, candidate)
+ shown = [Path(item_path) for _mtime, item_path in sorted(newest_hits, reverse=True)]
matches = []
output_bytes = 0
truncated_by_bytes = False
@@ -412,9 +981,12 @@ else:
'ok': True,
'matches': matches,
'preview': '\\n'.join(matches),
- 'total': len(hits),
- 'truncated': len(hits) > len(matches) or truncated_by_bytes,
- 'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if len(hits) > len(matches) else None),
+ 'total': total,
+ 'truncated': scan_truncated or total > len(matches) or truncated_by_bytes,
+ 'truncated_by': (
+ 'scan' if scan_truncated
+ else ('bytes' if truncated_by_bytes else ('matches' if total > len(matches) else None))
+ ),
}}))
""".strip()
return await self._run_workspace_file_script(script, query)
@@ -427,12 +999,15 @@ else:
query: pipeline_query.Query,
) -> dict:
script = f"""
-import json, os, re
+import json, os, re, signal, time
from pathlib import Path
path = {json.dumps(path)}
pattern = {json.dumps(pattern)}
include = {json.dumps(include)}
skip_dirs = {json.dumps(sorted(_SKIP_DIRS))}
+def regex_timeout(_signum, _frame):
+ raise TimeoutError
+signal.signal(signal.SIGALRM, regex_timeout)
try:
regex = re.compile(pattern)
except re.error as exc:
@@ -443,6 +1018,17 @@ else:
elif not os.path.exists(path):
print(json.dumps({{'ok': False, 'error': f'Path not found: {{path}}'}}))
else:
+ regex_deadline = time.monotonic() + {_GREP_REGEX_TIMEOUT_SECONDS}
+ def bounded_search(value):
+ remaining = regex_deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError
+ signal.setitimer(signal.ITIMER_REAL, remaining)
+ try:
+ return regex.search(value)
+ finally:
+ signal.setitimer(signal.ITIMER_REAL, 0)
+
base = Path(path)
if base.is_file():
files = [base]
@@ -459,14 +1045,37 @@ else:
matches = []
output_bytes = 0
truncated_by = None
+ total_chars_seen = 0
for fp in files:
try:
handle = fp.open('r', encoding='utf-8', errors='ignore')
except OSError:
continue
+ file_chars_seen = 0
with handle:
- for lineno, line in enumerate(handle, 1):
- if regex.search(line):
+ lineno = 0
+ while True:
+ line = handle.readline({_GREP_MAX_SCAN_LINE_CHARS + 1})
+ if not line:
+ break
+ lineno += 1
+ file_chars_seen += len(line)
+ total_chars_seen += len(line)
+ if (
+ file_chars_seen > {_GREP_MAX_FILE_SCAN_CHARS}
+ or total_chars_seen > {_GREP_MAX_TOTAL_SCAN_CHARS}
+ ):
+ truncated_by = 'scan'
+ break
+ if len(line) > {_GREP_MAX_SCAN_LINE_CHARS} and not line.endswith('\\n'):
+ truncated_by = truncated_by or 'line'
+ break
+ try:
+ matched = bounded_search(line)
+ except TimeoutError:
+ print(json.dumps({{'ok': False, 'error': 'Regex search timed out'}}))
+ raise SystemExit(0)
+ if matched:
if base.is_file():
file_path = path
else:
@@ -489,9 +1098,9 @@ else:
if len(matches) >= {_GREP_MAX_MATCHES}:
truncated_by = truncated_by or 'matches'
break
- if truncated_by == 'bytes' or len(matches) >= {_GREP_MAX_MATCHES}:
+ if truncated_by in ('bytes', 'scan') or len(matches) >= {_GREP_MAX_MATCHES}:
break
- if truncated_by == 'bytes' or len(matches) >= {_GREP_MAX_MATCHES}:
+ if truncated_by in ('bytes', 'scan') or len(matches) >= {_GREP_MAX_MATCHES}:
break
print(json.dumps({{
@@ -515,37 +1124,47 @@ else:
)
if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'):
selected_skill, relative = skill_request
- host_path = self._resolve_skill_host_path(selected_skill, relative)
- if host_path and os.path.exists(host_path):
- if os.path.isdir(host_path):
- return self._build_directory_result(os.listdir(host_path))
- return self._read_text_file_preview(host_path, parameters)
+ if self._can_interpret_skill_host_paths():
+ host_location = self._resolve_skill_host_location(selected_skill, relative)
+ else:
+ host_location = None
+ if host_location is not None:
+ try:
+ return await asyncio.to_thread(self._read_host_location, host_location, parameters)
+ except FileNotFoundError:
+ pass
try:
- result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative)
+ result = await self.ap.box_service.read_skill_file(
+ self._execution_context(query),
+ selected_skill['name'],
+ relative,
+ )
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
except Exception:
try:
- result = await self.ap.box_service.list_skill_files(selected_skill['name'], relative)
+ result = await self.ap.box_service.list_skill_files(
+ self._execution_context(query),
+ selected_skill['name'],
+ relative,
+ )
entries = [entry['name'] for entry in result.get('entries', [])]
return self._build_directory_result(entries)
except Exception as exc:
return {'ok': False, 'error': str(exc)}
- host_path, selected_skill = self._resolve_host_path(
+ host_location = self._resolve_host_location(
query,
path,
include_visible=True,
include_activated=True,
)
- if self._should_use_box_workspace_files(selected_skill):
+ if self._should_use_box_workspace_files(host_location.selected_skill):
return await self._read_workspace_via_box(path, parameters, query)
- if not os.path.exists(host_path):
+ try:
+ return await asyncio.to_thread(self._read_host_location, host_location, parameters)
+ except (FileNotFoundError, NotADirectoryError):
return {'ok': False, 'error': f'File not found: {path}'}
- if os.path.isdir(host_path):
- entries = os.listdir(host_path)
- return self._build_directory_result(entries)
- return self._read_text_file_preview(host_path, parameters)
async def _invoke_write(self, parameters: dict, query: pipeline_query.Query) -> dict:
path = parameters['path']
@@ -562,24 +1181,24 @@ else:
if encoding != 'text':
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
selected_skill, relative = skill_request
- await self.ap.box_service.write_skill_file(selected_skill['name'], relative, content)
- await self.ap.skill_mgr.reload_skills()
+ execution_context = self._execution_context(query)
+ await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content)
+ await self.ap.skill_mgr.reload_skills(execution_context)
return {'ok': True, 'path': path}
- host_path, selected_skill = self._resolve_host_path(
+ host_location = self._resolve_host_location(
query,
path,
include_visible=False,
include_activated=True,
)
- if self._should_use_box_workspace_files(selected_skill):
+ if self._should_use_box_workspace_files(host_location.selected_skill):
return await self._write_workspace_via_box(path, content, parameters, query)
- os.makedirs(os.path.dirname(host_path), exist_ok=True)
try:
- self._write_host_file(host_path, content, parameters)
+ await run_blocking_atomic(self._write_host_location, host_location, content, parameters)
except ValueError as exc:
return {'ok': False, 'error': str(exc)}
- self._refresh_skill_from_disk(selected_skill)
+ self._refresh_skill_from_disk(query, host_location.selected_skill)
return {'ok': True, 'path': path}
async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict:
@@ -603,7 +1222,11 @@ else:
):
selected_skill, relative = skill_request
try:
- result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative)
+ result = await self.ap.box_service.read_skill_file(
+ self._execution_context(query),
+ selected_skill['name'],
+ relative,
+ )
except Exception:
return {'ok': False, 'error': f'File not found: {path}'}
content = result.get('content', '')
@@ -613,34 +1236,39 @@ else:
if count > 1:
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
new_content = content.replace(old_string, new_string, 1)
- await self.ap.box_service.write_skill_file(selected_skill['name'], relative, new_content)
- await self.ap.skill_mgr.reload_skills()
+ execution_context = self._execution_context(query)
+ await self.ap.box_service.write_skill_file(
+ execution_context,
+ selected_skill['name'],
+ relative,
+ new_content,
+ )
+ await self.ap.skill_mgr.reload_skills(execution_context)
return {'ok': True, 'path': path}
- host_path, selected_skill = self._resolve_host_path(
+ host_location = self._resolve_host_location(
query,
path,
include_visible=False,
include_activated=True,
)
- if self._should_use_box_workspace_files(selected_skill):
+ if self._should_use_box_workspace_files(host_location.selected_skill):
return await self._edit_workspace_via_box(path, old_string, new_string, query)
- if not os.path.isfile(host_path):
+ try:
+ changed, error = await run_blocking_atomic(
+ self._edit_host_location,
+ host_location,
+ old_string,
+ new_string,
+ )
+ except (FileNotFoundError, NotADirectoryError):
return {'ok': False, 'error': f'File not found: {path}'}
- with open(host_path, 'r', encoding='utf-8', errors='replace') as f:
- content = f.read()
- count = content.count(old_string)
- if count == 0:
- return {'ok': False, 'error': 'old_string not found in file.'}
- if count > 1:
- return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
- new_content = content.replace(old_string, new_string, 1)
- with open(host_path, 'w', encoding='utf-8') as f:
- f.write(new_content)
- self._refresh_skill_from_disk(selected_skill)
+ if not changed:
+ return {'ok': False, 'error': error or f'File not found: {path}'}
+ self._refresh_skill_from_disk(query, host_location.selected_skill)
return {'ok': True, 'path': path}
- def _refresh_skill_from_disk(self, selected_skill: dict | None) -> None:
+ def _refresh_skill_from_disk(self, query: pipeline_query.Query, selected_skill: dict | None) -> None:
if selected_skill is None:
return
@@ -650,7 +1278,7 @@ else:
refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None)
if callable(refresh_skill):
- refresh_skill(selected_skill.get('name', ''))
+ refresh_skill(self._execution_context(query), selected_skill.get('name', ''))
async def _is_sandbox_available(self) -> bool:
"""Refresh backend availability so Box reconnects restore tool exposure."""
@@ -896,155 +1524,58 @@ else:
path = str(parameters.get('path', '/workspace') or '/workspace')
self.ap.logger.info(f'glob tool invoked: query_id={query.query_id} pattern={pattern} path={path}')
- host_path, selected_skill = self._resolve_host_path(
+ host_location = self._resolve_host_location(
query,
path,
include_visible=True,
include_activated=True,
)
- if self._should_use_box_workspace_files(selected_skill):
+ if self._should_use_box_workspace_files(host_location.selected_skill):
return await self._glob_workspace_via_box(path, pattern, query)
-
- if not os.path.isdir(host_path):
+ try:
+ return await asyncio.to_thread(self._glob_host_location, host_location, pattern, path)
+ except (FileNotFoundError, NotADirectoryError):
return {'ok': False, 'error': f'Path is not a directory: {path}'}
- from pathlib import Path
-
- base = Path(host_path)
- hits = list(base.rglob(pattern))
-
- # Filter out skipped directories
- hits = [h for h in hits if not any(skip in h.parts for skip in _SKIP_DIRS)]
-
- # Sort by mtime, newest first
- hits.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
-
- total = len(hits)
- shown = hits[:_GLOB_MAX_MATCHES]
-
- # Convert back to sandbox paths
- sandbox_paths = []
- output_bytes = 0
- truncated_by_bytes = False
- for h in shown:
- rel = os.path.relpath(str(h), host_path)
- sandbox_path = os.path.join(path, rel)
- entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if sandbox_paths else 0)
- if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
- truncated_by_bytes = True
- break
- sandbox_paths.append(sandbox_path)
- output_bytes += entry_bytes
-
- return {
- 'ok': True,
- 'matches': sandbox_paths,
- 'preview': '\n'.join(sandbox_paths),
- 'total': total,
- 'truncated': total > len(sandbox_paths) or truncated_by_bytes,
- 'truncated_by': 'bytes' if truncated_by_bytes else ('matches' if total > len(sandbox_paths) else None),
- }
-
async def _invoke_grep(self, parameters: dict, query: pipeline_query.Query) -> dict:
pattern = parameters['pattern']
path = str(parameters.get('path', '/workspace') or '/workspace')
include = parameters.get('include')
self.ap.logger.info(f'grep tool invoked: query_id={query.query_id} pattern={pattern} path={path}')
- import re
- from pathlib import Path
+ if not isinstance(pattern, str) or len(pattern) > _GREP_MAX_PATTERN_CHARS:
+ return {'ok': False, 'error': f'Regex patterns may contain at most {_GREP_MAX_PATTERN_CHARS} characters'}
- try:
- regex = re.compile(pattern)
- except re.error as e:
- return {'ok': False, 'error': f'Invalid regex: {e}'}
-
- host_path, selected_skill = self._resolve_host_path(
+ host_location = self._resolve_host_location(
query,
path,
include_visible=True,
include_activated=True,
)
- if self._should_use_box_workspace_files(selected_skill):
+ if self._should_use_box_workspace_files(host_location.selected_skill):
return await self._grep_workspace_via_box(path, pattern, include, query)
-
- if not os.path.exists(host_path):
+ try:
+ return await asyncio.to_thread(
+ self._grep_host_location,
+ host_location,
+ pattern,
+ include,
+ path,
+ )
+ except (FileNotFoundError, NotADirectoryError):
return {'ok': False, 'error': f'Path not found: {path}'}
- base = Path(host_path)
-
- if base.is_file():
- files = [base]
- else:
- files = self._grep_walk(base, include)
-
- matches = []
- output_bytes = 0
- truncated_by = None
- for fp in files:
- try:
- handle = fp.open('r', encoding='utf-8', errors='ignore')
- except OSError:
- continue
- with handle:
- for lineno, line in enumerate(handle, 1):
- if regex.search(line):
- rel = os.path.relpath(str(fp), host_path)
- sandbox_path = os.path.join(path, rel)
- content, line_truncated = self._truncate_grep_line(line.rstrip())
- entry = {
- 'file': sandbox_path,
- 'line': lineno,
- 'content': content,
- }
- entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
- if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
- truncated_by = 'bytes'
- break
- if line_truncated and truncated_by is None:
- truncated_by = 'line'
- matches.append(entry)
- output_bytes += entry_bytes
- if len(matches) >= _GREP_MAX_MATCHES:
- truncated_by = truncated_by or 'matches'
- break
- if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES:
- break
- if truncated_by == 'bytes' or len(matches) >= _GREP_MAX_MATCHES:
- break
-
- return {
- 'ok': True,
- 'matches': matches,
- 'total': len(matches),
- 'truncated': truncated_by is not None,
- 'truncated_by': truncated_by,
- }
-
@staticmethod
- def _grep_walk(root, include: str | None) -> list:
- """Walk dir tree for grep, skipping junk dirs."""
- results = []
- for item in root.rglob(include or '*'):
- if any(skip in item.parts for skip in _SKIP_DIRS):
- continue
- if item.is_file():
- results.append(item)
- if len(results) >= _GREP_MAX_FILES:
- break
- return results
-
- @staticmethod
- def _resolve_skill_host_path(selected_skill: dict, relative: str) -> str | None:
+ def _resolve_skill_host_location(selected_skill: dict, relative: str) -> _HostLocation | None:
package_root = str(selected_skill.get('package_root', '') or '').strip()
if not package_root:
return None
-
- host_root = os.path.realpath(package_root)
- host_path = os.path.realpath(os.path.join(host_root, relative))
- if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
- raise ValueError('Path escapes the skill package boundary.')
- return host_path
+ relative_path = '/workspace' if relative in {'', '.'} else f'/workspace/{relative}'
+ return _HostLocation(
+ root=package_root,
+ relative_parts=_relative_workspace_parts(relative_path),
+ selected_skill=selected_skill,
+ )
def _normalize_exec_result(self, result: dict) -> dict:
normalized = dict(result)
@@ -1070,23 +1601,29 @@ else:
normalized['truncated_by'] = 'bytes'
return normalized
- def _build_directory_result(self, entries: list[str]) -> dict:
+ def _build_directory_result(
+ self,
+ entries: list[str],
+ *,
+ total: int | None = None,
+ force_truncated_by: str | None = None,
+ ) -> dict:
sorted_entries = sorted(str(entry) for entry in entries)
content = '\n'.join(sorted_entries)
preview = self._truncate_text_to_bytes(content, _DEFAULT_TOOL_RESULT_MAX_BYTES)
- truncated = preview != content
+ truncated_by = force_truncated_by or ('bytes' if preview != content else None)
return {
'ok': True,
'content': preview,
'is_directory': True,
- 'total': len(sorted_entries),
- 'truncated': truncated,
- 'truncated_by': 'bytes' if truncated else None,
+ 'total': len(sorted_entries) if total is None else total,
+ 'truncated': truncated_by is not None,
+ 'truncated_by': truncated_by,
}
- def _read_text_file_preview(self, host_path: str, parameters: dict) -> dict:
+ def _read_text_file_preview(self, file_fd: int, parameters: dict, *, metadata: os.stat_result) -> dict:
if self._read_encoding(parameters) == 'base64':
- return self._read_binary_file_chunk(host_path, parameters)
+ return self._read_binary_file_chunk(file_fd, parameters, metadata=metadata)
offset = self._positive_int(parameters.get('offset'), default=1)
max_lines = self._positive_int(
@@ -1106,7 +1643,7 @@ else:
truncated_by: str | None = None
next_offset: int | None = None
- with open(host_path, 'r', encoding='utf-8', errors='replace') as f:
+ with os.fdopen(os.dup(file_fd), 'r', encoding='utf-8', errors='replace') as f:
for line_number, line in enumerate(f, 1):
if line_number < offset:
continue
@@ -1147,15 +1684,15 @@ else:
'max_bytes': max_bytes,
}
- def _read_binary_file_chunk(self, host_path: str, parameters: dict) -> dict:
+ def _read_binary_file_chunk(self, file_fd: int, parameters: dict, *, metadata: os.stat_result) -> dict:
byte_offset = self._non_negative_int(parameters.get('byte_offset'), default=0)
max_bytes = self._positive_int(
parameters.get('max_bytes'),
default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
)
- size_bytes = os.path.getsize(host_path)
- with open(host_path, 'rb') as f:
+ size_bytes = metadata.st_size
+ with os.fdopen(os.dup(file_fd), 'rb') as f:
f.seek(byte_offset)
data = f.read(max_bytes + 1)
chunk = data[:max_bytes]
@@ -1172,19 +1709,6 @@ else:
'max_bytes': max_bytes,
}
- def _write_host_file(self, host_path: str, content: str, parameters: dict) -> None:
- encoding, mode = self._write_options(parameters)
- if encoding == 'base64':
- try:
- data = base64.b64decode(content, validate=True)
- except Exception as exc:
- raise ValueError(f'invalid base64 content: {exc}') from exc
- with open(host_path, 'ab' if mode == 'append' else 'wb') as f:
- f.write(data)
- return
- with open(host_path, 'a' if mode == 'append' else 'w', encoding='utf-8') as f:
- f.write(content)
-
@staticmethod
def _read_encoding(parameters: dict) -> str:
return 'base64' if parameters.get('encoding') == 'base64' else 'text'
diff --git a/src/langbot/pkg/provider/tools/loaders/plugin.py b/src/langbot/pkg/provider/tools/loaders/plugin.py
index baac91d1d..80595049c 100644
--- a/src/langbot/pkg/provider/tools/loaders/plugin.py
+++ b/src/langbot/pkg/provider/tools/loaders/plugin.py
@@ -67,7 +67,11 @@ class PluginToolLoader(loader.ToolLoader):
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
try:
return await self.ap.plugin_connector.call_tool(
- name, parameters, session=query.session, query_id=query.query_id
+ name,
+ parameters,
+ session=query.session,
+ query_id=query.query_id,
+ query_uuid=query.query_uuid,
)
except Exception as e:
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
diff --git a/src/langbot/pkg/provider/tools/loaders/skill.py b/src/langbot/pkg/provider/tools/loaders/skill.py
index b62f3e7d5..82f93278d 100644
--- a/src/langbot/pkg/provider/tools/loaders/skill.py
+++ b/src/langbot/pkg/provider/tools/loaders/skill.py
@@ -4,6 +4,7 @@ import re
import typing
from ....box import workspace as box_workspace
+from ....api.http.context import ExecutionContext
if typing.TYPE_CHECKING:
from ....core import app
@@ -36,7 +37,15 @@ def get_visible_skills(ap: app.Application, query: pipeline_query.Query) -> dict
if skill_mgr is None:
return {}
- visible_skills = getattr(skill_mgr, 'skills', {})
+ execution_context = ExecutionContext(
+ instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
+ workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
+ placement_generation=getattr(query, 'placement_generation', 0) or 0,
+ bot_uuid=getattr(query, 'bot_uuid', None),
+ pipeline_uuid=getattr(query, 'pipeline_uuid', None),
+ query_uuid=getattr(query, 'query_uuid', None),
+ )
+ visible_skills = skill_mgr.get_skills(execution_context)
bound_skills = get_bound_skill_names(query)
if bound_skills is None:
return visible_skills
@@ -192,5 +201,14 @@ def should_prepare_skill_python_env(package_root: str | None) -> bool:
return box_workspace.should_prepare_python_env(package_root)
-def wrap_skill_command_with_python_env(command: str, *, mount_path: str = '/workspace') -> str:
- return box_workspace.wrap_python_command_with_env(command, mount_path=mount_path).rstrip()
+def wrap_skill_command_with_python_env(
+ command: str,
+ *,
+ mount_path: str = '/workspace',
+ state_path: str | None = None,
+) -> str:
+ return box_workspace.wrap_python_command_with_env(
+ command,
+ mount_path=mount_path,
+ state_path=state_path,
+ ).rstrip()
diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py
index fe0f65620..01e297842 100644
--- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py
+++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py
@@ -7,6 +7,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from .. import loader
from .availability import is_box_backend_available
+from ....api.http.context import ExecutionContext
# Align with Claude Code's Skill tool design:
# - activate: Activate a skill via Tool Call, returns SKILL.md content
@@ -72,12 +73,34 @@ class SkillToolLoader(loader.ToolLoader):
return self._sandbox_available
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
+ require_sandbox = getattr(
+ getattr(self.ap, 'box_service', None),
+ 'require_workspace_sandbox',
+ None,
+ )
+ if callable(require_sandbox):
+ await require_sandbox(self._execution_context(query))
if name == ACTIVATE_SKILL_TOOL_NAME:
return await self._invoke_activate_skill(parameters, query)
if name == REGISTER_SKILL_TOOL_NAME:
- return await self._invoke_register_skill(parameters)
+ return await self._invoke_register_skill(parameters, query)
raise ValueError(f'Unknown skill tool: {name}')
+ @staticmethod
+ def _execution_context(query) -> ExecutionContext:
+ attached_context = getattr(query, '_execution_context', None)
+ if isinstance(attached_context, ExecutionContext):
+ return attached_context
+ return ExecutionContext(
+ instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
+ workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
+ placement_generation=getattr(query, 'placement_generation', 0) or 0,
+ bot_uuid=getattr(query, 'bot_uuid', None),
+ pipeline_uuid=getattr(query, 'pipeline_uuid', None),
+ query_uuid=getattr(query, 'query_uuid', None),
+ entitlement_revision=getattr(query, 'entitlement_revision', 0),
+ )
+
async def shutdown(self):
pass
@@ -128,14 +151,15 @@ class SkillToolLoader(loader.ToolLoader):
'content': result_content,
}
- async def _invoke_register_skill(self, parameters: dict) -> typing.Any:
+ async def _invoke_register_skill(self, parameters: dict, query) -> typing.Any:
"""Register a skill from sandbox directory to data/skills/."""
sandbox_path = str(parameters.get('path', '') or '').strip()
if not sandbox_path:
raise ValueError('path is required')
# Resolve sandbox path to host path
- host_path = self._resolve_workspace_directory(sandbox_path)
+ execution_context = self._execution_context(query)
+ host_path = self._resolve_workspace_directory(sandbox_path, execution_context)
# Get or create skill service
skill_service = getattr(self.ap, 'skill_service', None)
@@ -143,7 +167,7 @@ class SkillToolLoader(loader.ToolLoader):
raise ValueError('Skill service not available')
# Scan and register the skill
- scanned = await skill_service.scan_directory_async(host_path)
+ scanned = await skill_service.scan_directory_async(execution_context, host_path)
# Override name if provided
skill_name = str(parameters.get('name') or scanned['name']).strip()
@@ -152,13 +176,14 @@ class SkillToolLoader(loader.ToolLoader):
# Create the skill
created = await skill_service.create_skill(
+ execution_context,
{
'name': skill_name,
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
'package_root': host_path,
- }
+ },
)
return {
@@ -168,10 +193,19 @@ class SkillToolLoader(loader.ToolLoader):
'skill': created,
}
- def _resolve_workspace_directory(self, sandbox_path: str) -> str:
+ def _resolve_workspace_directory(
+ self,
+ sandbox_path: str,
+ execution_context: ExecutionContext,
+ ) -> str:
"""Resolve sandbox path to host filesystem path."""
box_service = getattr(self.ap, 'box_service', None)
- workspace_root = getattr(box_service, 'default_workspace', None)
+ tenant_workspace = getattr(box_service, '_tenant_workspace', None)
+ workspace_root = (
+ tenant_workspace(execution_context)
+ if callable(tenant_workspace)
+ else getattr(box_service, 'default_workspace', None)
+ )
if not workspace_root:
raise ValueError('No default workspace configured')
diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py
index 60a16ce7e..1e4acbbcf 100644
--- a/src/langbot/pkg/provider/tools/toolmgr.py
+++ b/src/langbot/pkg/provider/tools/toolmgr.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import typing
import time
+import inspect
from typing import TYPE_CHECKING
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@@ -9,6 +10,8 @@ from langbot_plugin.api.entities.events import pipeline_query
from . import loader as tool_loader
from .errors import ToolNotFoundError
+from ...pipeline.pool import get_query_execution_context
+from ...api.http.service.tenant import TenantContext
if TYPE_CHECKING:
from ...core import app
@@ -33,6 +36,36 @@ class ToolManager:
def __init__(self, ap: app.Application):
self.ap = ap
+ async def _bind_plugin_workspace(self, context: TenantContext) -> None:
+ """Select the tenant before any plugin catalog lookup.
+
+ Tool discovery happens before invocation, so relying on ``call_tool``
+ to bind the Workspace is too late and can expose another task's
+ catalog in a shared Runtime.
+ """
+
+ connector = getattr(self.ap, 'plugin_connector', None)
+ require_context = getattr(connector, 'require_workspace_context', None)
+ if require_context is None:
+ return
+ result = require_context(context)
+ if inspect.isawaitable(result):
+ await result
+
+ async def _workspace_sandbox_available(self, context: TenantContext) -> bool:
+ """Resolve the Workspace capability before exposing sandbox tools."""
+
+ box_service = getattr(self.ap, 'box_service', None)
+ checker = getattr(box_service, 'is_workspace_sandbox_available', None)
+ if not callable(checker):
+ # Compatibility for OSS embedders and isolated manager tests. The
+ # BoxService execution path remains the final authority.
+ return True
+ try:
+ return bool(await checker(context))
+ except Exception:
+ return False
+
async def initialize(self):
from langbot.pkg.utils import importutil
from langbot.pkg.provider.tools import loaders
@@ -57,19 +90,24 @@ class ToolManager:
async def get_all_tools(
self,
+ context: TenantContext,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = True,
) -> list[resource_tool.LLMTool]:
+ await self._bind_plugin_workspace(context)
all_functions: list[resource_tool.LLMTool] = []
- all_functions.extend(await self.native_tool_loader.get_tools())
- if include_skill_authoring:
+ sandbox_available = await self._workspace_sandbox_available(context)
+ if sandbox_available:
+ all_functions.extend(await self.native_tool_loader.get_tools())
+ if include_skill_authoring and sandbox_available:
all_functions.extend(await self.skill_tool_loader.get_tools())
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
all_functions.extend(
await self.mcp_tool_loader.get_tools(
+ context,
bound_mcp_servers,
include_resource_tools=include_mcp_resource_tools,
)
@@ -79,11 +117,13 @@ class ToolManager:
async def get_tool_catalog(
self,
+ context: TenantContext,
bound_plugins: list[str] | None = None,
bound_mcp_servers: list[str] | None = None,
include_skill_authoring: bool = False,
include_mcp_resource_tools: bool = False,
) -> list[dict[str, typing.Any]]:
+ await self._bind_plugin_workspace(context)
catalog: list[dict[str, typing.Any]] = []
def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMTool]) -> None:
@@ -99,13 +139,16 @@ class ToolManager:
}
)
- append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
- if include_skill_authoring:
+ sandbox_available = await self._workspace_sandbox_available(context)
+ if sandbox_available:
+ append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
+ if include_skill_authoring and sandbox_available:
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools())
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
if self.mcp_tool_loader:
for item in await self.mcp_tool_loader.get_tool_catalog(
+ context,
bound_mcp_servers,
include_resource_tools=include_mcp_resource_tools,
):
@@ -113,19 +156,24 @@ class ToolManager:
return catalog
- async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
+ async def get_tool_by_name(self, context: TenantContext, name: str) -> tool_loader.ToolLookupResult | None:
"""Get tool by name from any active loader."""
- for active_loader in (
- self.native_tool_loader,
- self.plugin_tool_loader,
- self.mcp_tool_loader,
- self.skill_tool_loader,
- ):
+ await self._bind_plugin_workspace(context)
+ sandbox_available = await self._workspace_sandbox_available(context)
+ if sandbox_available:
+ tool = await self.native_tool_loader.get_tool(name)
+ if tool:
+ return tool
+ for active_loader in (self.plugin_tool_loader,):
tool = await active_loader.get_tool(name)
if tool:
return tool
+ if sandbox_available:
+ tool = await self.skill_tool_loader.get_tool(name)
+ if tool:
+ return tool
- return None
+ return await self.mcp_tool_loader.get_tool(context, name)
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
tools = []
@@ -175,6 +223,7 @@ class ToolManager:
try:
await monitoring_service.record_tool_call(
+ get_query_execution_context(query),
tool_name=name,
tool_source=source,
duration=duration_ms,
@@ -231,7 +280,10 @@ class ToolManager:
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
from langbot.pkg.telemetry import features as telemetry_features
- if await self.native_tool_loader.has_tool(name):
+ execution_context = get_query_execution_context(query)
+ await self._bind_plugin_workspace(execution_context)
+ sandbox_available = await self._workspace_sandbox_available(execution_context)
+ if sandbox_available and await self.native_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'native')
return await self._invoke_tool_with_monitoring(
source='native',
@@ -249,7 +301,7 @@ class ToolManager:
query=query,
invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query),
)
- if await self.mcp_tool_loader.has_tool(name):
+ if await self.mcp_tool_loader.has_tool(execution_context, name):
telemetry_features.increment(query, 'tool_calls', 'mcp')
return await self._invoke_tool_with_monitoring(
source='mcp',
@@ -258,7 +310,7 @@ class ToolManager:
query=query,
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
)
- if await self.skill_tool_loader.has_tool(name):
+ if sandbox_available and await self.skill_tool_loader.has_tool(name):
telemetry_features.increment(query, 'tool_calls', 'skill')
return await self._invoke_tool_with_monitoring(
source='skill',
diff --git a/src/langbot/pkg/rag/knowledge/base.py b/src/langbot/pkg/rag/knowledge/base.py
index 28d010fef..16d383989 100644
--- a/src/langbot/pkg/rag/knowledge/base.py
+++ b/src/langbot/pkg/rag/knowledge/base.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import abc
from langbot.pkg.core import app
+from langbot.pkg.api.http.context import ExecutionContext
from langbot_plugin.api.entities.builtin.rag import context as rag_context
@@ -22,10 +23,16 @@ class KnowledgeBaseInterface(metaclass=abc.ABCMeta):
pass
@abc.abstractmethod
- async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]:
+ async def retrieve(
+ self,
+ execution_context: ExecutionContext,
+ query: str,
+ settings: dict | None = None,
+ ) -> list[rag_context.RetrievalResultEntry]:
"""Retrieve relevant documents from the knowledge base
Args:
+ execution_context: Trusted active Workspace placement.
query: The query string
settings: Optional per-request retrieval settings overrides
@@ -50,6 +57,6 @@ class KnowledgeBaseInterface(metaclass=abc.ABCMeta):
pass
@abc.abstractmethod
- async def dispose(self):
+ async def dispose(self, execution_context: ExecutionContext):
"""Clean up resources"""
pass
diff --git a/src/langbot/pkg/rag/knowledge/kbmgr.py b/src/langbot/pkg/rag/knowledge/kbmgr.py
index cd37994c4..8042b3271 100644
--- a/src/langbot/pkg/rag/knowledge/kbmgr.py
+++ b/src/langbot/pkg/rag/knowledge/kbmgr.py
@@ -1,48 +1,130 @@
from __future__ import annotations
+import asyncio
+import io
import mimetypes
import os.path
import traceback
import uuid
import zipfile
-import io
from typing import Any
-from langbot.pkg.core import app
+
import sqlalchemy
-
-
-from langbot.pkg.entity.persistence import rag as persistence_rag
-from langbot.pkg.core import taskmgr
from langbot_plugin.api.entities.builtin.rag import context as rag_context
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.api.http.context import ExecutionContext, RequestContext
+from langbot.pkg.api.http.service.tenant import TenantContext, require_workspace_uuid
+from langbot.pkg.core import app, taskmgr
+from langbot.pkg.core.task_boundary import run_in_workspace_uow
+from langbot.pkg.entity.persistence import rag as persistence_rag
+from langbot.pkg.workspace.errors import WorkspaceInvariantError, WorkspaceNotFoundError
+
from .base import KnowledgeBaseInterface
+_MAX_ZIP_ARCHIVE_ENTRIES = 1024
+_MAX_ZIP_DOCUMENTS = 8
+_MAX_ZIP_FILE_BYTES = 10 * 1024 * 1024
+_MAX_ZIP_UNCOMPRESSED_BYTES = 40 * 1024 * 1024
+_MAX_ZIP_COMPRESSION_RATIO = 100
+
+
class RuntimeKnowledgeBase(KnowledgeBaseInterface):
ap: app.Application
knowledge_base_entity: persistence_rag.KnowledgeBase
- def __init__(self, ap: app.Application, knowledge_base_entity: persistence_rag.KnowledgeBase):
+ def __init__(
+ self,
+ ap: app.Application,
+ knowledge_base_entity: persistence_rag.KnowledgeBase,
+ execution_context: ExecutionContext,
+ ):
super().__init__(ap)
self.knowledge_base_entity = knowledge_base_entity
+ self.execution_context = execution_context
async def initialize(self):
pass
+ async def _assert_execution_context(self, execution_context: ExecutionContext) -> None:
+ """Reject stale or cross-Workspace runtime access."""
+
+ if not isinstance(execution_context, ExecutionContext):
+ raise WorkspaceRequiredError('ExecutionContext is required for knowledge runtime access')
+ if (
+ execution_context.instance_uuid != self.execution_context.instance_uuid
+ or execution_context.workspace_uuid != self.execution_context.workspace_uuid
+ or execution_context.placement_generation != self.execution_context.placement_generation
+ ):
+ raise WorkspaceNotFoundError('Knowledge base not found')
+ if self.knowledge_base_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceNotFoundError('Knowledge base not found')
+ binding = await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceNotFoundError('Knowledge base not found')
+
+ async def _require_plugin_runtime_context(
+ self,
+ execution_context: ExecutionContext,
+ ) -> ExecutionContext:
+ """Fence every singleton Plugin Runtime call to this runtime KB."""
+
+ await self._assert_execution_context(execution_context)
+ return await self.ap.plugin_connector.require_workspace_context(execution_context)
+
+ def _require_upload_object_key(
+ self,
+ execution_context: ExecutionContext,
+ object_key: str,
+ ) -> None:
+ """Reject raw, cross-Workspace, stale, or non-upload object keys."""
+
+ try:
+ self.ap.storage_mgr.require_scoped_object_key(
+ execution_context,
+ object_key,
+ expected_owner_type='upload_document',
+ )
+ except (WorkspaceRequiredError, ValueError) as exc:
+ raise WorkspaceNotFoundError('Upload not found') from exc
+
async def _store_file_task(
- self, file: persistence_rag.File, task_context: taskmgr.TaskContext, parser_plugin_id: str | None = None
+ self,
+ execution_context: ExecutionContext,
+ file: persistence_rag.File,
+ task_context: taskmgr.TaskContext,
+ parser_plugin_id: str | None = None,
):
+ await run_in_workspace_uow(
+ self.ap,
+ execution_context.workspace_uuid,
+ lambda: self._assert_execution_context(execution_context),
+ )
+ self._require_upload_object_key(execution_context, file.file_name)
try:
# set file status to processing
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_rag.File)
- .where(persistence_rag.File.uuid == file.uuid)
- .values(status='processing')
- )
+ status_visible = False
+ for retry_delay in (0.0, 0.01, 0.05, 0.1):
+ if retry_delay:
+ await asyncio.sleep(retry_delay)
+ if await self._set_file_status(execution_context, file.uuid, 'processing'):
+ status_visible = True
+ break
+ if not status_visible:
+ raise WorkspaceNotFoundError('Knowledge file was not committed before its background task started')
task_context.set_current_action('Processing file')
# Get file size from storage
- file_size = await self.ap.storage_mgr.storage_provider.size(file.file_name)
+ file_size = await self.ap.storage_mgr.size_scoped_object_key(
+ execution_context,
+ file.file_name,
+ expected_owner_type='upload_document',
+ )
# Detect MIME type from extension
mime_type, _ = mimetypes.guess_type(file.file_name)
@@ -53,16 +135,22 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
parsed_content = None
if parser_plugin_id:
task_context.set_current_action('Parsing file')
- file_bytes = await self.ap.storage_mgr.storage_provider.load(file.file_name)
+ file_bytes = await self.ap.storage_mgr.load_scoped_object_key(
+ execution_context,
+ file.file_name,
+ expected_owner_type='upload_document',
+ )
parse_context = {
'mime_type': mime_type,
'filename': file.file_name,
'metadata': {},
}
+ await self._require_plugin_runtime_context(execution_context)
parsed_content = await self.ap.plugin_connector.call_parser(parser_plugin_id, parse_context, file_bytes)
# Call plugin to ingest document
result = await self._ingest_document(
+ execution_context,
{
'document_id': file.uuid,
'filename': file.file_name,
@@ -80,44 +168,94 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
raise Exception(error_msg)
# set file status to completed
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_rag.File)
- .where(persistence_rag.File.uuid == file.uuid)
- .values(status='completed')
- )
+ if not await self._set_file_status(execution_context, file.uuid, 'completed'):
+ raise WorkspaceNotFoundError('Knowledge file not found')
except Exception as e:
self.ap.logger.error(f'Error storing file {file.uuid}: {e}')
traceback.print_exc()
- # set file status to failed
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(persistence_rag.File)
- .where(persistence_rag.File.uuid == file.uuid)
- .values(status='failed')
- )
+ # A stale placement is fenced from all writes, including failure
+ # status updates from an old background task.
+ try:
+ if not await self._set_file_status(execution_context, file.uuid, 'failed'):
+ raise WorkspaceNotFoundError('Knowledge file not found')
+ except Exception:
+ self.ap.logger.warning(f'Skipping stale RAG task status update for file {file.uuid}')
raise
finally:
- # delete file from storage
- await self.ap.storage_mgr.storage_provider.delete(file.file_name)
+ # An old background task must not touch an upload after its
+ # placement generation has been fenced off.
+ try:
+ await self._assert_execution_context(execution_context)
+ await self.ap.storage_mgr.delete_scoped_object_key(
+ execution_context,
+ file.file_name,
+ expected_owner_type='upload_document',
+ )
+ except (WorkspaceRequiredError, WorkspaceNotFoundError):
+ self.ap.logger.warning(f'Skipping stale RAG upload cleanup for file {file.uuid}')
- async def store_file(self, file_id: str, parser_plugin_id: str | None = None) -> str:
+ async def _set_file_status(
+ self,
+ execution_context: ExecutionContext,
+ file_uuid: str,
+ status: str,
+ ) -> bool:
+ """Commit one detached-task status transition in its own tenant UoW."""
+
+ async def update() -> bool:
+ await self._assert_execution_context(execution_context)
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_rag.File)
+ .where(persistence_rag.File.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_rag.File.uuid == file_uuid)
+ .values(status=status)
+ )
+ return getattr(result, 'rowcount', 0) > 0
+
+ persistence_mgr = self.ap.persistence_mgr
+ managed_mode = getattr(getattr(persistence_mgr, 'mode', None), 'value', None) in {
+ 'cloud_runtime',
+ 'oss_compat',
+ }
+ tenant_uow = getattr(persistence_mgr, 'tenant_uow', None)
+ if managed_mode:
+ if not callable(tenant_uow):
+ raise RuntimeError('Knowledge tasks require an explicit tenant UoW')
+ async with tenant_uow(execution_context.workspace_uuid):
+ return await update()
+ return await update()
+
+ async def store_file(
+ self,
+ execution_context: ExecutionContext,
+ file_id: str,
+ parser_plugin_id: str | None = None,
+ ) -> str:
+ await self._assert_execution_context(execution_context)
+ self._require_upload_object_key(execution_context, file_id)
# pre checking
- if not await self.ap.storage_mgr.storage_provider.exists(file_id):
- raise Exception(f'File {file_id} not found')
+ if not await self.ap.storage_mgr.exists_scoped_object_key(
+ execution_context,
+ file_id,
+ expected_owner_type='upload_document',
+ ):
+ raise WorkspaceNotFoundError('Upload not found')
file_name = file_id
_, ext = os.path.splitext(file_name)
extension = ext.lstrip('.').lower() if ext else ''
if extension == 'zip':
- return await self._store_zip_file(file_id, parser_plugin_id=parser_plugin_id)
+ return await self._store_zip_file(execution_context, file_id, parser_plugin_id=parser_plugin_id)
file_uuid = str(uuid.uuid4())
kb_id = self.knowledge_base_entity.uuid
file_obj_data = {
'uuid': file_uuid,
+ 'workspace_uuid': execution_context.workspace_uuid,
'kb_id': kb_id,
'file_name': file_name,
'extension': extension,
@@ -130,20 +268,47 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
# run background task asynchronously
ctx = taskmgr.TaskContext.new()
- wrapper = self.ap.task_mgr.create_user_task(
- self._store_file_task(file_obj, task_context=ctx, parser_plugin_id=parser_plugin_id),
- kind='knowledge-operation',
- name=f'knowledge-store-file-{file_id}',
- label=f'Store file {file_id}',
- context=ctx,
- )
+ try:
+ wrapper = self.ap.task_mgr.create_user_task(
+ self._store_file_task(
+ execution_context,
+ file_obj,
+ task_context=ctx,
+ parser_plugin_id=parser_plugin_id,
+ ),
+ kind='knowledge-operation',
+ name=f'knowledge-store-file-{file_id}',
+ label=f'Store file {file_id}',
+ context=ctx,
+ instance_uuid=execution_context.instance_uuid,
+ workspace_uuid=execution_context.workspace_uuid,
+ placement_generation=execution_context.placement_generation,
+ )
+ except taskmgr.TaskCapacityError:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.delete(persistence_rag.File)
+ .where(persistence_rag.File.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_rag.File.uuid == file_uuid)
+ )
+ raise
return wrapper.id
- async def _store_zip_file(self, zip_file_id: str, parser_plugin_id: str | None = None) -> str:
+ async def _store_zip_file(
+ self,
+ execution_context: ExecutionContext,
+ zip_file_id: str,
+ parser_plugin_id: str | None = None,
+ ) -> str:
"""Handle ZIP file by extracting each document and storing them separately."""
+ await self._assert_execution_context(execution_context)
+ self._require_upload_object_key(execution_context, zip_file_id)
self.ap.logger.info(f'Processing ZIP file: {zip_file_id}')
- zip_bytes = await self.ap.storage_mgr.storage_provider.load(zip_file_id)
+ zip_bytes = await self.ap.storage_mgr.load_scoped_object_key(
+ execution_context,
+ zip_file_id,
+ expected_owner_type='upload_document',
+ )
supported_extensions = {'txt', 'pdf', 'docx', 'md', 'html'}
stored_file_tasks = []
@@ -151,9 +316,21 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
try:
# use utf-8 encoding
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref:
+ if len(zip_ref.filelist) > _MAX_ZIP_ARCHIVE_ENTRIES:
+ raise ValueError('ZIP archive contains too many entries')
+
+ supported_files: list[zipfile.ZipInfo] = []
+ total_uncompressed_bytes = 0
for file_info in zip_ref.filelist:
# skip directories and hidden files
- if file_info.is_dir() or file_info.filename.startswith('.'):
+ normalized_name = file_info.filename.replace('\\', '/').strip('/')
+ path_parts = normalized_name.split('/')
+ if (
+ file_info.is_dir()
+ or not normalized_name
+ or any(part.startswith('.') for part in path_parts)
+ or '__MACOSX' in path_parts
+ ):
continue
_, file_ext = os.path.splitext(file_info.filename)
@@ -161,29 +338,60 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
if file_extension not in supported_extensions:
self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}')
continue
+ if file_info.flag_bits & 0x1:
+ raise ValueError('Encrypted ZIP entries are not supported')
+ if file_info.file_size > _MAX_ZIP_FILE_BYTES:
+ raise ValueError(f'ZIP document exceeds the file size limit: {file_info.filename}')
+ if (
+ file_info.file_size
+ and file_info.file_size > max(file_info.compress_size, 1) * _MAX_ZIP_COMPRESSION_RATIO
+ ):
+ raise ValueError(f'ZIP document exceeds the compression-ratio limit: {file_info.filename}')
+ total_uncompressed_bytes += file_info.file_size
+ if total_uncompressed_bytes > _MAX_ZIP_UNCOMPRESSED_BYTES:
+ raise ValueError('ZIP documents exceed the uncompressed size limit')
+ supported_files.append(file_info)
+ if len(supported_files) > _MAX_ZIP_DOCUMENTS:
+ raise ValueError('ZIP archive contains too many supported documents')
+ for file_info in supported_files:
try:
- file_content = zip_ref.read(file_info.filename)
+ file_content = await asyncio.to_thread(zip_ref.read, file_info)
base_name = file_info.filename.replace('/', '_').replace('\\', '_')
file_stem, file_ext = os.path.splitext(base_name)
extension = file_ext.lstrip('.')
- if file_stem.startswith('__MACOSX'):
- continue
-
extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension
- # save file to storage
+ extracted_object_key = await self.ap.storage_mgr.save_scoped(
+ execution_context,
+ owner_type='upload_document',
+ owner=f'knowledge-base:{self.knowledge_base_entity.uuid}',
+ key=extracted_file_id,
+ value=file_content,
+ )
- await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content)
-
- task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id)
+ try:
+ task_id = await self.store_file(
+ execution_context,
+ extracted_object_key,
+ parser_plugin_id=parser_plugin_id,
+ )
+ except Exception:
+ await self.ap.storage_mgr.delete_scoped_object_key(
+ execution_context,
+ extracted_object_key,
+ expected_owner_type='upload_document',
+ )
+ raise
stored_file_tasks.append(task_id)
self.ap.logger.info(
- f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_file_id}'
+ f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_object_key}'
)
+ except taskmgr.TaskCapacityError:
+ raise
except Exception as e:
self.ap.logger.warning(f'Failed to extract file {file_info.filename} from ZIP: {e}')
continue
@@ -197,20 +405,33 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
return stored_file_tasks[0] if stored_file_tasks else ''
finally:
try:
- await self.ap.storage_mgr.storage_provider.delete(zip_file_id)
+ await self._assert_execution_context(execution_context)
+ await self.ap.storage_mgr.delete_scoped_object_key(
+ execution_context,
+ zip_file_id,
+ expected_owner_type='upload_document',
+ )
except FileNotFoundError:
pass
+ except (WorkspaceRequiredError, WorkspaceNotFoundError):
+ self.ap.logger.warning(f'Skipping stale RAG ZIP cleanup for upload {zip_file_id}')
except Exception as e:
self.ap.logger.warning(f'Failed to cleanup ZIP file {zip_file_id}: {e}')
- async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]:
+ async def retrieve(
+ self,
+ execution_context: ExecutionContext,
+ query: str,
+ settings: dict | None = None,
+ ) -> list[rag_context.RetrievalResultEntry]:
+ await self._assert_execution_context(execution_context)
# Merge stored retrieval_settings with per-request overrides
stored = self.knowledge_base_entity.retrieval_settings or {}
merged = {**stored, **(settings or {})}
if 'top_k' not in merged:
merged['top_k'] = 5 # fallback default
- response = await self._retrieve(query, merged)
+ response = await self._retrieve(execution_context, query, merged)
results_data = response.get('results', [])
entries = []
@@ -221,12 +442,25 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
entries.append(r)
return entries
- async def delete_file(self, file_id: str):
- await self._delete_document(file_id)
+ async def delete_file(self, execution_context: ExecutionContext, file_id: str):
+ await self._assert_execution_context(execution_context)
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_rag.File.uuid)
+ .where(persistence_rag.File.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_rag.File.kb_id == self.knowledge_base_entity.uuid)
+ .where(persistence_rag.File.uuid == file_id)
+ .limit(1)
+ )
+ if result.first() is None:
+ raise WorkspaceNotFoundError('Knowledge file not found')
+ await self._delete_document(execution_context, file_id)
# Also cleanup DB record
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_rag.File).where(persistence_rag.File.uuid == file_id)
+ sqlalchemy.delete(persistence_rag.File)
+ .where(persistence_rag.File.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_rag.File.kb_id == self.knowledge_base_entity.uuid)
+ .where(persistence_rag.File.uuid == file_id)
)
def get_uuid(self) -> str:
@@ -241,14 +475,16 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
"""Get the Knowledge Engine plugin ID"""
return self.knowledge_base_entity.knowledge_engine_plugin_id or ''
- async def dispose(self):
+ async def dispose(self, execution_context: ExecutionContext):
"""Dispose the knowledge base, notifying the plugin to cleanup."""
- await self._on_kb_delete()
+ await self._assert_execution_context(execution_context)
+ await self._on_kb_delete(execution_context)
# ========== Plugin Communication Methods ==========
- async def _on_kb_create(self) -> None:
+ async def _on_kb_create(self, execution_context: ExecutionContext) -> None:
"""Notify plugin about KB creation."""
+ await self._assert_execution_context(execution_context)
plugin_id = self.knowledge_base_entity.knowledge_engine_plugin_id
if not plugin_id:
return
@@ -258,17 +494,20 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
self.ap.logger.info(
f'Calling RAG plugin {plugin_id}: on_knowledge_base_create(kb_id={self.knowledge_base_entity.uuid})'
)
+ await self._require_plugin_runtime_context(execution_context)
await self.ap.plugin_connector.rag_on_kb_create(plugin_id, self.knowledge_base_entity.uuid, config)
except Exception as e:
self.ap.logger.error(f'Failed to notify plugin {plugin_id} on KB create: {e}')
raise
- async def _on_kb_delete(self) -> None:
+ async def _on_kb_delete(self, execution_context: ExecutionContext) -> None:
"""Notify plugin about KB deletion."""
+ await self._assert_execution_context(execution_context)
plugin_id = self.knowledge_base_entity.knowledge_engine_plugin_id
if not plugin_id:
return
+ await self._require_plugin_runtime_context(execution_context)
try:
self.ap.logger.info(
f'Calling RAG plugin {plugin_id}: on_knowledge_base_delete(kb_id={self.knowledge_base_entity.uuid})'
@@ -279,11 +518,13 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
async def _ingest_document(
self,
+ execution_context: ExecutionContext,
file_metadata: dict[str, Any],
storage_path: str,
parsed_content: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Call plugin to ingest document."""
+ await self._assert_execution_context(execution_context)
kb = self.knowledge_base_entity
plugin_id = kb.knowledge_engine_plugin_id
if not plugin_id:
@@ -306,6 +547,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
'parsed_content': parsed_content,
}
+ await self._require_plugin_runtime_context(execution_context)
try:
result = await self.ap.plugin_connector.call_rag_ingest(plugin_id, context_data)
return result
@@ -315,6 +557,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
async def _retrieve(
self,
+ execution_context: ExecutionContext,
query: str,
settings: dict[str, Any],
) -> dict[str, Any]:
@@ -324,6 +567,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
ValueError: If no RAG plugin is configured for this KB.
Exception: If the plugin retrieval call fails.
"""
+ await self._assert_execution_context(execution_context)
kb = self.knowledge_base_entity
plugin_id = kb.knowledge_engine_plugin_id
if not plugin_id:
@@ -333,25 +577,28 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
# for plugins that need it. Do NOT move them into filters, as filters
# are passed directly to vector_search by some plugins (e.g. LangRAG)
# and would cause empty results when the metadata field doesn't exist.
- filters = settings.pop('filters', {})
+ plugin_settings = dict(settings)
+ filters = plugin_settings.pop('filters', {})
retrieval_context = {
'query': query,
'knowledge_base_id': kb.uuid,
'collection_id': kb.collection_id or kb.uuid,
- 'retrieval_settings': settings,
+ 'retrieval_settings': plugin_settings,
'creation_settings': kb.creation_settings or {},
'filters': filters,
}
+ await self._require_plugin_runtime_context(execution_context)
result = await self.ap.plugin_connector.call_rag_retrieve(
plugin_id,
retrieval_context,
)
return result
- async def _delete_document(self, document_id: str) -> bool:
+ async def _delete_document(self, execution_context: ExecutionContext, document_id: str) -> bool:
"""Call plugin to delete document."""
+ await self._assert_execution_context(execution_context)
kb = self.knowledge_base_entity
plugin_id = kb.knowledge_engine_plugin_id
if not plugin_id:
@@ -359,6 +606,7 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
self.ap.logger.info(f'Calling RAG plugin {plugin_id}: delete_document(doc_id={document_id})')
+ await self._require_plugin_runtime_context(execution_context)
try:
return await self.ap.plugin_connector.call_rag_delete_document(plugin_id, document_id, kb.uuid)
except Exception as e:
@@ -369,29 +617,115 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
class RAGManager:
ap: app.Application
- knowledge_bases: dict[str, KnowledgeBaseInterface]
+ knowledge_bases: dict[tuple[str, str], RuntimeKnowledgeBase]
def __init__(self, ap: app.Application):
self.ap = ap
self.knowledge_bases = {}
+ self._scope_generations: dict[tuple[str, str], int] = {}
+ self._knowledge_keys_by_scope: dict[
+ tuple[str, str],
+ set[tuple[str, str]],
+ ] = {}
+
+ def _cache_runtime(
+ self,
+ runtime: RuntimeKnowledgeBase,
+ ) -> None:
+ context = runtime.execution_context
+ self._observe_execution_context(context)
+ key = (
+ context.workspace_uuid,
+ runtime.get_uuid(),
+ )
+ self.knowledge_bases[key] = runtime
+ scope = (context.instance_uuid, context.workspace_uuid)
+ self._knowledge_keys_by_scope.setdefault(scope, set()).add(key)
+
+ def _pop_runtime(
+ self,
+ context: ExecutionContext,
+ kb_uuid: str,
+ ) -> RuntimeKnowledgeBase | None:
+ key = (context.workspace_uuid, kb_uuid)
+ runtime = self.knowledge_bases.pop(key, None)
+ scope = (context.instance_uuid, context.workspace_uuid)
+ keys = self._knowledge_keys_by_scope.get(scope)
+ if keys is not None:
+ keys.discard(key)
+ if not keys:
+ self._knowledge_keys_by_scope.pop(scope, None)
+ self._scope_generations.pop(scope, None)
+ return runtime
+
+ def _observe_execution_context(self, context: ExecutionContext) -> None:
+ scope = (context.instance_uuid, context.workspace_uuid)
+ previous_generation = self._scope_generations.get(scope)
+ if previous_generation is not None and context.placement_generation < previous_generation:
+ raise WorkspaceInvariantError('RAG runtime placement generation rolled back')
+ if previous_generation == context.placement_generation:
+ return
+ if previous_generation is not None:
+ for key in self._knowledge_keys_by_scope.pop(scope, ()):
+ self.knowledge_bases.pop(key, None)
+ self._scope_generations[scope] = context.placement_generation
async def initialize(self):
await self.load_knowledge_bases_from_db()
- async def get_all_knowledge_base_details(self) -> list[dict]:
+ async def _to_execution_context(
+ self,
+ context: RequestContext | ExecutionContext,
+ *,
+ _binding_validated: bool = False,
+ ) -> ExecutionContext:
+ if isinstance(context, RequestContext):
+ execution_context = ExecutionContext.from_request(context)
+ elif isinstance(context, ExecutionContext):
+ execution_context = context
+ else:
+ raise WorkspaceRequiredError('RequestContext or ExecutionContext is required')
+
+ if not _binding_validated:
+ binding = await self.ap.workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceNotFoundError('Workspace not found')
+ scope = (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ )
+ if scope in self._scope_generations:
+ self._observe_execution_context(execution_context)
+ return execution_context
+
+ async def _get_engine_map(self, context: TenantContext) -> dict[str, dict]:
+ engine_map: dict[str, dict] = {}
+ connector = getattr(self.ap, 'plugin_connector', None)
+ if connector is not None and connector.is_enable_plugin:
+ await connector.require_workspace_context(context)
+ try:
+ engines = await connector.list_knowledge_engines()
+ engine_map = {engine['plugin_id']: engine for engine in engines}
+ except Exception as e:
+ self.ap.logger.warning(f'Failed to list Knowledge Engines: {e}')
+ return engine_map
+
+ async def get_all_knowledge_base_details(self, context: TenantContext) -> list[dict]:
"""Get all knowledge bases with enriched Knowledge Engine details."""
+ workspace_uuid = require_workspace_uuid(context)
# 1. Get raw KBs from DB
- result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase))
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_rag.KnowledgeBase).where(
+ persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid
+ )
+ )
knowledge_bases = result.all()
# 2. Get all available Knowledge Engines for enrichment
- engine_map = {}
- if self.ap.plugin_connector.is_enable_plugin:
- try:
- engines = await self.ap.plugin_connector.list_knowledge_engines()
- engine_map = {e['plugin_id']: e for e in engines}
- except Exception as e:
- self.ap.logger.warning(f'Failed to list Knowledge Engines: {e}')
+ engine_map = await self._get_engine_map(context)
# 3. Serialize and enrich
kb_list = []
@@ -402,10 +736,13 @@ class RAGManager:
return kb_list
- async def get_knowledge_base_details(self, kb_uuid: str) -> dict | None:
+ async def get_knowledge_base_details(self, context: TenantContext, kb_uuid: str) -> dict | None:
"""Get specific knowledge base with enriched Knowledge Engine details."""
+ workspace_uuid = require_workspace_uuid(context)
result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
+ sqlalchemy.select(persistence_rag.KnowledgeBase)
+ .where(persistence_rag.KnowledgeBase.workspace_uuid == workspace_uuid)
+ .where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
kb = result.first()
if not kb:
@@ -414,13 +751,7 @@ class RAGManager:
kb_dict = self.ap.persistence_mgr.serialize_model(persistence_rag.KnowledgeBase, kb)
# Fetch engines
- engine_map = {}
- if self.ap.plugin_connector.is_enable_plugin:
- try:
- engines = await self.ap.plugin_connector.list_knowledge_engines()
- engine_map = {e['plugin_id']: e for e in engines}
- except Exception as e:
- self.ap.logger.warning(f'Failed to list Knowledge Engines: {e}')
+ engine_map = await self._get_engine_map(context)
self._enrich_kb_dict(kb_dict, engine_map)
return kb_dict
@@ -465,6 +796,7 @@ class RAGManager:
async def create_knowledge_base(
self,
+ context: RequestContext | ExecutionContext,
name: str,
knowledge_engine_plugin_id: str,
creation_settings: dict,
@@ -472,8 +804,10 @@ class RAGManager:
description: str = '',
) -> persistence_rag.KnowledgeBase:
"""Create a new knowledge base using a RAG plugin."""
+ execution_context = await self._to_execution_context(context)
# Validate that the Knowledge Engine plugin exists
if self.ap.plugin_connector.is_enable_plugin:
+ await self.ap.plugin_connector.require_workspace_context(execution_context)
try:
engines = await self.ap.plugin_connector.list_knowledge_engines()
engine_ids = [e.get('plugin_id') for e in engines]
@@ -490,6 +824,7 @@ class RAGManager:
kb_data = {
'uuid': kb_uuid,
+ 'workspace_uuid': execution_context.workspace_uuid,
'name': name,
'description': description,
'knowledge_engine_plugin_id': knowledge_engine_plugin_id,
@@ -505,15 +840,17 @@ class RAGManager:
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_rag.KnowledgeBase).values(kb_data))
# Load into Runtime
- runtime_kb = await self.load_knowledge_base(kb)
+ runtime_kb = await self.load_knowledge_base(execution_context, kb)
# Notify Plugin — rollback DB record and runtime entry on failure
try:
- await runtime_kb._on_kb_create()
+ await runtime_kb._on_kb_create(execution_context)
except Exception:
- self.knowledge_bases.pop(kb_uuid, None)
+ self._pop_runtime(execution_context, kb_uuid)
await self.ap.persistence_mgr.execute_async(
- sqlalchemy.delete(persistence_rag.KnowledgeBase).where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
+ sqlalchemy.delete(persistence_rag.KnowledgeBase)
+ .where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_rag.KnowledgeBase.uuid == kb_uuid)
)
raise
@@ -524,14 +861,52 @@ class RAGManager:
self.ap.logger.info('Loading knowledge bases from db...')
self.knowledge_bases = {}
+ self._scope_generations = {}
+ self._knowledge_keys_by_scope = {}
- # Load knowledge bases
+ list_bindings = getattr(self.ap.workspace_service, 'list_active_execution_bindings', None)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ if not callable(list_bindings) or not callable(tenant_uow):
+ raise RuntimeError('Cloud knowledge loading requires explicit instance discovery and tenant UoWs')
+ for binding in await list_bindings():
+ async with tenant_uow(binding.workspace_uuid):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_rag.KnowledgeBase)
+ .where(persistence_rag.KnowledgeBase.workspace_uuid == binding.workspace_uuid)
+ .order_by(persistence_rag.KnowledgeBase.uuid)
+ )
+ for knowledge_base in result.all():
+ try:
+ await self.load_knowledge_base(
+ ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ ),
+ knowledge_base,
+ _binding_validated=True,
+ )
+ except Exception as e:
+ self.ap.logger.error(
+ f'Error loading knowledge base {knowledge_base.uuid}: {e}\n{traceback.format_exc()}'
+ )
+ return
+
+ # Compatibility path for isolated manager tests and older embedders.
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_rag.KnowledgeBase))
knowledge_bases = result.all()
for knowledge_base in knowledge_bases:
try:
- await self.load_knowledge_base(knowledge_base)
+ binding = await self.ap.workspace_service.get_execution_binding(knowledge_base.workspace_uuid)
+ execution_context = ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ )
+ await self.load_knowledge_base(execution_context, knowledge_base)
except Exception as e:
self.ap.logger.error(
f'Error loading knowledge base {knowledge_base.uuid}: {e}\n{traceback.format_exc()}'
@@ -539,7 +914,10 @@ class RAGManager:
async def load_knowledge_base(
self,
+ context: RequestContext | ExecutionContext,
knowledge_base_entity: persistence_rag.KnowledgeBase | sqlalchemy.Row | dict,
+ *,
+ _binding_validated: bool = False,
) -> RuntimeKnowledgeBase:
if isinstance(knowledge_base_entity, sqlalchemy.Row):
# Safe access to _mapping for SQLAlchemy 1.4+
@@ -551,23 +929,48 @@ class RAGManager:
}
knowledge_base_entity = persistence_rag.KnowledgeBase(**filtered_dict)
- runtime_knowledge_base = RuntimeKnowledgeBase(ap=self.ap, knowledge_base_entity=knowledge_base_entity)
+ execution_context = await self._to_execution_context(
+ context,
+ _binding_validated=_binding_validated,
+ )
+ if knowledge_base_entity.workspace_uuid != execution_context.workspace_uuid:
+ raise WorkspaceNotFoundError('Knowledge base not found')
+ runtime_knowledge_base = RuntimeKnowledgeBase(
+ ap=self.ap,
+ knowledge_base_entity=knowledge_base_entity,
+ execution_context=execution_context,
+ )
await runtime_knowledge_base.initialize()
- self.knowledge_bases[runtime_knowledge_base.get_uuid()] = runtime_knowledge_base
+ self._cache_runtime(runtime_knowledge_base)
return runtime_knowledge_base
- async def get_knowledge_base_by_uuid(self, kb_uuid: str) -> KnowledgeBaseInterface | None:
- return self.knowledge_bases.get(kb_uuid)
+ async def get_knowledge_base_by_uuid(
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_uuid: str,
+ ) -> RuntimeKnowledgeBase | None:
+ execution_context = await self._to_execution_context(context)
+ return self.knowledge_bases.get((execution_context.workspace_uuid, kb_uuid))
- async def remove_knowledge_base_from_runtime(self, kb_uuid: str):
- self.knowledge_bases.pop(kb_uuid, None)
+ async def remove_knowledge_base_from_runtime(
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_uuid: str,
+ ) -> None:
+ execution_context = await self._to_execution_context(context)
+ self._pop_runtime(execution_context, kb_uuid)
- async def delete_knowledge_base(self, kb_uuid: str):
- kb = self.knowledge_bases.pop(kb_uuid, None)
+ async def delete_knowledge_base(
+ self,
+ context: RequestContext | ExecutionContext,
+ kb_uuid: str,
+ ) -> None:
+ execution_context = await self._to_execution_context(context)
+ kb = self._pop_runtime(execution_context, kb_uuid)
if kb is not None:
- await kb.dispose()
+ await kb.dispose(execution_context)
else:
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found in runtime, skipping plugin notification')
diff --git a/src/langbot/pkg/rag/service/runtime.py b/src/langbot/pkg/rag/service/runtime.py
index 0de1ae885..a685dc521 100644
--- a/src/langbot/pkg/rag/service/runtime.py
+++ b/src/langbot/pkg/rag/service/runtime.py
@@ -5,6 +5,13 @@ import re
from typing import TYPE_CHECKING, Any
from urllib.parse import unquote
+import sqlalchemy
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.entity.persistence import rag as persistence_rag
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
+
if TYPE_CHECKING:
from langbot.pkg.core import app
@@ -19,8 +26,54 @@ class RAGRuntimeService:
def __init__(self, ap: app.Application):
self.ap = ap
+ async def _validate_execution_context(self, execution_context: ExecutionContext) -> None:
+ if not isinstance(execution_context, ExecutionContext):
+ raise WorkspaceRequiredError('ExecutionContext is required for RAG runtime access')
+ if (
+ not execution_context.instance_uuid.strip()
+ or not execution_context.workspace_uuid.strip()
+ or execution_context.placement_generation <= 0
+ ):
+ raise WorkspaceRequiredError('A complete active ExecutionContext is required')
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ if workspace_service is None:
+ raise WorkspaceRequiredError('Workspace execution service is unavailable')
+ binding = await workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceRequiredError('ExecutionContext belongs to another LangBot instance')
+
+ async def _resolve_knowledge_base_uuid(
+ self,
+ execution_context: ExecutionContext,
+ collection_id: str,
+ ) -> str:
+ """Resolve a plugin logical handle to a Workspace-owned KB UUID."""
+
+ await self._validate_execution_context(execution_context)
+ if not isinstance(collection_id, str) or not collection_id.strip():
+ raise WorkspaceNotFoundError('Knowledge base not found')
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_rag.KnowledgeBase.uuid)
+ .where(persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid)
+ .where(
+ sqlalchemy.or_(
+ persistence_rag.KnowledgeBase.uuid == collection_id,
+ persistence_rag.KnowledgeBase.collection_id == collection_id,
+ )
+ )
+ .limit(1)
+ )
+ kb_uuid = result.scalar_one_or_none()
+ if kb_uuid is None:
+ raise WorkspaceNotFoundError('Knowledge base not found')
+ return kb_uuid
+
async def vector_upsert(
self,
+ execution_context: ExecutionContext,
collection_id: str,
vectors: list[list[float]],
ids: list[str],
@@ -28,9 +81,17 @@ class RAGRuntimeService:
documents: list[str] | None = None,
) -> None:
"""Handle VECTOR_UPSERT action."""
+ knowledge_base_uuid = await self._resolve_knowledge_base_uuid(execution_context, collection_id)
+ if len(vectors) != len(ids):
+ raise ValueError('vectors and ids must have the same length')
+ if metadata is not None and len(metadata) != len(vectors):
+ raise ValueError('metadata must have the same length as vectors')
+ if documents is not None and len(documents) != len(vectors):
+ raise ValueError('documents must have the same length as vectors')
metadatas = metadata if metadata else [{} for _ in vectors]
await self.ap.vector_db_mgr.upsert(
- collection_name=collection_id,
+ execution_context=execution_context,
+ knowledge_base_uuid=knowledge_base_uuid,
vectors=vectors,
ids=ids,
metadata=metadatas,
@@ -39,6 +100,7 @@ class RAGRuntimeService:
async def vector_search(
self,
+ execution_context: ExecutionContext,
collection_id: str,
query_vector: list[float],
top_k: int,
@@ -48,8 +110,10 @@ class RAGRuntimeService:
vector_weight: float | None = None,
) -> list[dict[str, Any]]:
"""Handle VECTOR_SEARCH action."""
+ knowledge_base_uuid = await self._resolve_knowledge_base_uuid(execution_context, collection_id)
return await self.ap.vector_db_mgr.search(
- collection_name=collection_id,
+ execution_context=execution_context,
+ knowledge_base_uuid=knowledge_base_uuid,
query_vector=query_vector,
limit=top_k,
filter=filters,
@@ -59,7 +123,11 @@ class RAGRuntimeService:
)
async def vector_delete(
- self, collection_id: str, file_ids: list[str] | None = None, filters: dict[str, Any] | None = None
+ self,
+ execution_context: ExecutionContext,
+ collection_id: str,
+ file_ids: list[str] | None = None,
+ filters: dict[str, Any] | None = None,
) -> int:
"""Handle VECTOR_DELETE action.
@@ -73,16 +141,26 @@ class RAGRuntimeService:
in their metadata.
filters: Filter-based deletion (not yet supported, will raise).
"""
+ knowledge_base_uuid = await self._resolve_knowledge_base_uuid(execution_context, collection_id)
count = 0
if file_ids:
- await self.ap.vector_db_mgr.delete_by_file_id(collection_name=collection_id, file_ids=file_ids)
+ await self.ap.vector_db_mgr.delete_by_file_id(
+ execution_context=execution_context,
+ knowledge_base_uuid=knowledge_base_uuid,
+ file_ids=file_ids,
+ )
count = len(file_ids)
elif filters:
- count = await self.ap.vector_db_mgr.delete_by_filter(collection_name=collection_id, filter=filters)
+ count = await self.ap.vector_db_mgr.delete_by_filter(
+ execution_context=execution_context,
+ knowledge_base_uuid=knowledge_base_uuid,
+ filter=filters,
+ )
return count
async def vector_list(
self,
+ execution_context: ExecutionContext,
collection_id: str,
filters: dict[str, Any] | None = None,
limit: int = 20,
@@ -99,14 +177,20 @@ class RAGRuntimeService:
Returns:
Tuple of (items, total).
"""
+ knowledge_base_uuid = await self._resolve_knowledge_base_uuid(execution_context, collection_id)
return await self.ap.vector_db_mgr.list_by_filter(
- collection_name=collection_id,
+ execution_context=execution_context,
+ knowledge_base_uuid=knowledge_base_uuid,
filter=filters,
limit=limit,
offset=offset,
)
- async def get_file_stream(self, storage_path: str) -> bytes:
+ async def get_file_stream(
+ self,
+ execution_context: ExecutionContext,
+ storage_path: str,
+ ) -> bytes:
"""Handle GET_KNOWLEDEGE_FILE_STREAM action.
Uses the storage manager abstraction to load file content,
@@ -125,5 +209,18 @@ class RAGRuntimeService:
or re.match(r'^[A-Za-z]:/', normalized)
):
raise ValueError('Invalid storage path')
- content_bytes = await self.ap.storage_mgr.storage_provider.load(normalized)
+ await self._validate_execution_context(execution_context)
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(persistence_rag.File.uuid)
+ .where(persistence_rag.File.workspace_uuid == execution_context.workspace_uuid)
+ .where(persistence_rag.File.file_name == normalized)
+ .limit(1)
+ )
+ if result.first() is None:
+ raise WorkspaceNotFoundError('Knowledge file not found')
+ content_bytes = await self.ap.storage_mgr.load_scoped_object_key(
+ execution_context,
+ normalized,
+ expected_owner_type='upload_document',
+ )
return content_bytes if content_bytes else b''
diff --git a/src/langbot/pkg/skill/activation.py b/src/langbot/pkg/skill/activation.py
index 706747060..27ce1b3da 100644
--- a/src/langbot/pkg/skill/activation.py
+++ b/src/langbot/pkg/skill/activation.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import typing
from ..provider.tools.loaders import skill as skill_loader
+from ..api.http.context import ExecutionContext
if typing.TYPE_CHECKING:
from ..core import app
@@ -27,7 +28,15 @@ def register_activated_skill(
if skill_mgr is None:
return False
- skill_data = skill_mgr.get_skill_by_name(skill_name)
+ execution_context = ExecutionContext(
+ instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
+ workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
+ placement_generation=getattr(query, 'placement_generation', 0) or 0,
+ bot_uuid=getattr(query, 'bot_uuid', None),
+ pipeline_uuid=getattr(query, 'pipeline_uuid', None),
+ query_uuid=getattr(query, 'query_uuid', None),
+ )
+ skill_data = skill_mgr.get_skill_by_name(execution_context, skill_name)
if skill_data is None:
return False
diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py
index ddb2125c3..aeb688b7f 100644
--- a/src/langbot/pkg/skill/manager.py
+++ b/src/langbot/pkg/skill/manager.py
@@ -1,133 +1,129 @@
from __future__ import annotations
import os
-import typing
+from ..api.http.context import ExecutionContext
+from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..core import app
-if typing.TYPE_CHECKING:
- pass
-
class SkillManager:
- """Skill manager backed by Box-managed or local filesystem packages.
-
- In sandbox deployments, skills are loaded from the Box runtime. Local
- data/skills remains as the fallback for non-Box development.
-
- Skills are activated through the `activate` tool (Tool Call mechanism),
- aligned with Claude Code's design. This protects KV Cache and follows
- industry standard.
- """
+ """Workspace-scoped in-memory view of Box-managed skill packages."""
ap: app.Application
- skills: dict[str, dict]
def __init__(self, ap: app.Application):
self.ap = ap
- self.skills = {}
+ self._skills_by_scope: dict[tuple[str, str, int], dict[str, dict]] = {}
+
+ @staticmethod
+ def _execution_context(context: TenantContext) -> ExecutionContext:
+ workspace_uuid = require_workspace_uuid(context)
+ instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
+ generation = getattr(context, 'placement_generation', None)
+ if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
+ raise ValueError('Skill cache requires an explicit fenced execution context')
+ return ExecutionContext(
+ instance_uuid=instance_uuid,
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ bot_uuid=getattr(context, 'bot_uuid', None),
+ pipeline_uuid=getattr(context, 'pipeline_uuid', None),
+ query_uuid=getattr(context, 'query_uuid', None),
+ )
+
+ @classmethod
+ def _scope_key(cls, context: TenantContext) -> tuple[str, str, int]:
+ execution_context = cls._execution_context(context)
+ return (
+ execution_context.instance_uuid,
+ execution_context.workspace_uuid,
+ execution_context.placement_generation,
+ )
async def initialize(self):
- await self.reload_skills()
+ try:
+ binding = await self.ap.workspace_service.get_execution_binding()
+ except Exception:
+ self.ap.logger.info('No unambiguous Workspace binding; skill caches will load on demand.')
+ return
+ await self.reload_skills(
+ ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ )
+ )
- async def reload_skills(self):
- """Reload all skills from the Box runtime.
-
- Box is the only source of truth for skills. When Box is unavailable
- (disabled in config or unreachable) the cache is emptied — there is
- no local filesystem fallback. Skills whose ``package_root`` is no
- longer visible on the LangBot-side filesystem are dropped so they
- don't surface as stale ``extra_mounts``.
- """
- self.skills = {}
+ async def reload_skills(self, context: TenantContext) -> None:
+ execution_context = self._execution_context(context)
+ key = self._scope_key(execution_context)
+ for existing_key in tuple(self._skills_by_scope):
+ if existing_key[:2] == key[:2] and existing_key != key:
+ self._skills_by_scope.pop(existing_key, None)
+ self._skills_by_scope[key] = {}
box_service = getattr(self.ap, 'box_service', None)
if box_service is None or not getattr(box_service, 'available', False):
- self.ap.logger.info('Box runtime unavailable; skill cache is empty.')
+ self.ap.logger.info(
+ f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.'
+ )
return
- # LangBot may only validate Box-reported paths against its own
- # filesystem when the two share one (local stdio mode). In separated
- # deployments (Docker Compose, k8s sidecar, --standalone-box, remote
- # endpoint) the package_root lives on the Box runtime's filesystem and
- # is not resolvable here, so we trust what Box reports.
validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False))
-
try:
dropped = 0
- for skill_data in await box_service.list_skills():
+ skills: dict[str, dict] = {}
+ for skill_data in await box_service.list_skills(execution_context):
skill_name = skill_data.get('name')
if not skill_name:
continue
package_root = str(skill_data.get('package_root', '') or '').strip()
if validate_locally and package_root and not os.path.isdir(package_root):
self.ap.logger.warning(
- f'Skill "{skill_name}" reported by Box runtime but '
- f'package_root missing on LangBot filesystem '
- f'({package_root}); dropping from in-memory cache.'
+ f'Skill "{skill_name}" reported by Box runtime but package_root '
+ f'missing on LangBot filesystem ({package_root}); dropping from cache.'
)
dropped += 1
continue
- self.skills[skill_name] = skill_data
- if dropped:
- self.ap.logger.warning(
- f'Loaded {len(self.skills)} skills from Box runtime '
- f'({dropped} dropped due to missing package_root).'
- )
- else:
- self.ap.logger.info(f'Loaded {len(self.skills)} skills from Box runtime')
+ skills[skill_name] = skill_data
+ self._skills_by_scope[key] = skills
+ suffix = f' ({dropped} dropped due to missing package_root)' if dropped else ''
+ self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}')
except Exception as exc:
- self.ap.logger.warning(f'Failed to load skills from Box runtime: {exc}')
+ self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')
- def refresh_skill_from_disk(self, skill_name: str) -> bool:
- """Confirm a single skill is present in the cache.
+ async def ensure_loaded(self, context: TenantContext) -> None:
+ key = self._scope_key(context)
+ if key not in self._skills_by_scope:
+ await self.reload_skills(context)
- With Box as the only source of truth, the actual reload is driven by
- SkillService callers awaiting ``reload_skills``; this method only
- reports whether the cache still has the skill.
- """
- if not skill_name:
- return False
- return skill_name in self.skills
+ def get_skills(self, context: TenantContext) -> dict[str, dict]:
+ return self._skills_by_scope.get(self._scope_key(context), {})
- def get_skill_by_name(self, name: str) -> dict | None:
- """Get skill data by name."""
- return self.skills.get(name)
+ def refresh_skill_from_disk(self, context: TenantContext, skill_name: str) -> bool:
+ return bool(skill_name) and skill_name in self.get_skills(context)
- def get_skill_index(self, bound_skills: list[str] | None = None) -> str:
- """Render the pipeline-visible skills as a short ``name: description``
- index suitable for the system prompt.
+ def get_skill_by_name(self, context: TenantContext, name: str) -> dict | None:
+ return self.get_skills(context).get(name)
- ``bound_skills`` follows the same convention as
- ``query.variables['_pipeline_bound_skills']``: ``None`` means every
- loaded skill is exposed; an explicit list filters to that subset.
- Returns an empty string when no skills are visible.
- """
+ def get_skill_index(self, context: TenantContext, bound_skills: list[str] | None = None) -> str:
lines: list[str] = []
- for skill in self.skills.values():
+ for skill in self.get_skills(context).values():
name = skill.get('name')
- if not name:
- continue
- if bound_skills is not None and name not in bound_skills:
+ if not name or (bound_skills is not None and name not in bound_skills):
continue
display = skill.get('display_name') or name
description = (skill.get('description') or '').strip().replace('\n', ' ')
lines.append(f'- {name} ({display}): {description}')
+ return 'Available Skills:\n' + '\n'.join(lines) if lines else ''
- if not lines:
- return ''
- return 'Available Skills:\n' + '\n'.join(lines)
-
- def build_skill_aware_prompt_addition(self, bound_skills: list[str] | None = None) -> str:
- """Build the system-prompt addendum that makes the LLM aware of the
- pipeline-visible skills.
-
- Only metadata (name + description) is injected — the full SKILL.md is
- loaded later via the ``activate`` Tool Call, protecting KV cache and
- matching Claude Code's progressive disclosure pattern. Returns an
- empty string when no skills are visible (no prompt change at all).
- """
- skill_index = self.get_skill_index(bound_skills)
+ def build_skill_aware_prompt_addition(
+ self,
+ context: TenantContext,
+ bound_skills: list[str] | None = None,
+ ) -> str:
+ skill_index = self.get_skill_index(context, bound_skills)
if not skill_index:
return ''
return (
@@ -140,3 +136,6 @@ class SkillManager:
'the tool result. If no skill is a clear match, respond normally '
'without activating any skill.'
)
+
+ def total_cached_skill_count(self) -> int:
+ return sum(len(skills) for skills in self._skills_by_scope.values())
diff --git a/src/langbot/pkg/storage/mgr.py b/src/langbot/pkg/storage/mgr.py
index 08f7c8d78..c6c7c8a93 100644
--- a/src/langbot/pkg/storage/mgr.py
+++ b/src/langbot/pkg/storage/mgr.py
@@ -1,11 +1,30 @@
from __future__ import annotations
+import hashlib
+import json
+import re
+from pathlib import PurePath
from ..core import app
+from ..utils import bounded_executor
+from ..api.http.authz import WorkspaceRequiredError
+from ..api.http.context import ExecutionContext, RequestContext
from . import provider
from .providers import localstorage
+_SAFE_OWNER_TYPE = re.compile(r'^[a-z][a-z0-9_-]{0,63}$')
+_DEFAULT_OBJECT_READ_BYTES = 10 * 1024 * 1024
+_SCOPED_KEY = re.compile(
+ r'^v1/(?P[a-f0-9]{24})/'
+ r'(?P[0-9a-fA-F-]{36})/'
+ r'(?P[1-9][0-9]*)/'
+ r'(?P[a-z][a-z0-9_-]{0,63})/'
+ r'(?P[a-f0-9]{32})/'
+ r'(?P[a-f0-9]{64})(?P\.[a-zA-Z0-9]{1,16})?$'
+)
+
+
class StorageMgr:
"""Storage manager"""
@@ -16,6 +35,323 @@ class StorageMgr:
def __init__(self, ap: app.Application):
self.ap = ap
+ def _object_read_limit(self) -> int:
+ config = getattr(getattr(self.ap, 'instance_config', None), 'data', {})
+ try:
+ configured = int(
+ config.get('storage', {}).get(
+ 'max_object_read_bytes',
+ _DEFAULT_OBJECT_READ_BYTES,
+ )
+ )
+ except (AttributeError, TypeError, ValueError):
+ configured = _DEFAULT_OBJECT_READ_BYTES
+ return min(max(configured, 1), provider.HARD_MAX_STORAGE_OBJECT_BYTES)
+
+ async def _load_object_bounded(self, object_key: str) -> bytes:
+ max_bytes = self._object_read_limit()
+ bounded_loader = getattr(self.storage_provider, 'load_bounded', None)
+ if callable(bounded_loader):
+ return await bounded_loader(object_key, max_bytes=max_bytes)
+
+ # Compatibility for lightweight and third-party providers. Built-in
+ # providers enforce the same bound in the actual read operation.
+ object_size = await self.storage_provider.size(object_key)
+ if object_size > max_bytes:
+ raise ValueError(f'Storage object exceeds the {max_bytes}-byte read limit')
+ value = await self.storage_provider.load(object_key)
+ if len(value) > max_bytes:
+ raise ValueError(f'Storage object exceeds the {max_bytes}-byte read limit')
+ return value
+
+ @staticmethod
+ def _require_execution_scope(
+ context: ExecutionContext | RequestContext,
+ ) -> tuple[str, str, int]:
+ if not isinstance(context, (ExecutionContext, RequestContext)):
+ raise WorkspaceRequiredError('Storage operations require an explicit Workspace context')
+ instance_uuid = context.instance_uuid.strip()
+ workspace_uuid = context.workspace_uuid.strip()
+ generation = context.placement_generation
+ if not instance_uuid or not workspace_uuid:
+ raise WorkspaceRequiredError('Storage operations require an instance and Workspace')
+ if generation <= 0:
+ raise WorkspaceRequiredError('Storage operations require a positive placement generation')
+ return instance_uuid, workspace_uuid, generation
+
+ @staticmethod
+ def _digest(value: str, length: int) -> str:
+ return hashlib.sha256(value.encode('utf-8')).hexdigest()[:length]
+
+ async def _require_active_execution_scope(
+ self,
+ context: ExecutionContext | RequestContext,
+ ) -> None:
+ """Revalidate the captured generation before touching object storage."""
+ instance_uuid, workspace_uuid, generation = self._require_execution_scope(context)
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ if workspace_service is None:
+ raise WorkspaceRequiredError('Storage execution scope is unavailable')
+ try:
+ binding = await workspace_service.get_execution_binding(
+ workspace_uuid,
+ expected_generation=generation,
+ )
+ except Exception as exc:
+ raise WorkspaceRequiredError('Storage execution scope is unavailable') from exc
+ if (
+ getattr(binding, 'instance_uuid', None) != instance_uuid
+ or getattr(binding, 'workspace_uuid', None) != workspace_uuid
+ or getattr(binding, 'placement_generation', None) != generation
+ ):
+ raise WorkspaceRequiredError('Storage execution scope is unavailable')
+
+ @classmethod
+ def canonical_binary_storage_key(
+ cls,
+ context: ExecutionContext | RequestContext,
+ *,
+ owner_type: str,
+ owner: str,
+ key: str,
+ ) -> str:
+ """Return a bounded canonical key over every BinaryStorage owner dimension."""
+
+ instance_uuid, workspace_uuid, _ = cls._require_execution_scope(context)
+ if not _SAFE_OWNER_TYPE.fullmatch(owner_type):
+ raise ValueError('Invalid storage owner_type')
+ if not owner or not key:
+ raise ValueError('Storage owner and key are required')
+ canonical = json.dumps(
+ [instance_uuid, workspace_uuid, owner_type, owner, key],
+ ensure_ascii=False,
+ separators=(',', ':'),
+ )
+ return f'v1:{cls._digest(instance_uuid, 24)}:{workspace_uuid}:{owner_type}:{hashlib.sha256(canonical.encode()).hexdigest()}'
+
+ @classmethod
+ def scoped_object_key(
+ cls,
+ context: ExecutionContext | RequestContext,
+ *,
+ owner_type: str,
+ owner: str,
+ key: str,
+ preserve_suffix: bool = True,
+ ) -> str:
+ """Build a non-enumerable object key with an explicit tenant boundary."""
+
+ instance_uuid, workspace_uuid, generation = cls._require_execution_scope(context)
+ if not _SAFE_OWNER_TYPE.fullmatch(owner_type):
+ raise ValueError('Invalid storage owner_type')
+ if not owner or not key:
+ raise ValueError('Storage owner and key are required')
+ suffix = PurePath(key).suffix.lower() if preserve_suffix else ''
+ if not re.fullmatch(r'\.[a-z0-9]{1,16}', suffix):
+ suffix = ''
+ return (
+ f'v1/{cls._digest(instance_uuid, 24)}/{workspace_uuid}/{generation}/'
+ f'{owner_type}/{cls._digest(owner, 32)}/{cls._digest(key, 64)}{suffix}'
+ )
+
+ @classmethod
+ def scoped_prefix(
+ cls,
+ context: ExecutionContext | RequestContext,
+ *,
+ owner_type: str | None = None,
+ ) -> str:
+ instance_uuid, workspace_uuid, generation = cls._require_execution_scope(context)
+ prefix = f'v1/{cls._digest(instance_uuid, 24)}/{workspace_uuid}/{generation}/'
+ if owner_type is not None:
+ if not _SAFE_OWNER_TYPE.fullmatch(owner_type):
+ raise ValueError('Invalid storage owner_type')
+ prefix += f'{owner_type}/'
+ return prefix
+
+ async def save_scoped(
+ self,
+ context: ExecutionContext | RequestContext,
+ *,
+ owner_type: str,
+ owner: str,
+ key: str,
+ value: bytes,
+ preserve_suffix: bool = True,
+ ) -> str:
+ await self._require_active_execution_scope(context)
+ max_bytes = self._object_read_limit()
+ if len(value) > max_bytes:
+ raise ValueError(f'Storage object exceeds the {max_bytes}-byte write limit')
+ object_key = self.scoped_object_key(
+ context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ preserve_suffix=preserve_suffix,
+ )
+ await self.storage_provider.save(object_key, value)
+ return object_key
+
+ async def load_scoped(
+ self,
+ context: ExecutionContext | RequestContext,
+ *,
+ owner_type: str,
+ owner: str,
+ key: str,
+ preserve_suffix: bool = True,
+ ) -> bytes:
+ await self._require_active_execution_scope(context)
+ object_key = self.scoped_object_key(
+ context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ preserve_suffix=preserve_suffix,
+ )
+ return await self._load_object_bounded(object_key)
+
+ async def delete_scoped(
+ self,
+ context: ExecutionContext | RequestContext,
+ *,
+ owner_type: str,
+ owner: str,
+ key: str,
+ preserve_suffix: bool = True,
+ ) -> None:
+ await self._require_active_execution_scope(context)
+ object_key = self.scoped_object_key(
+ context,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ preserve_suffix=preserve_suffix,
+ )
+ await self.storage_provider.delete(object_key)
+
+ async def resolve_public_object(
+ self,
+ object_key: str,
+ *,
+ expected_owner_type: str,
+ ) -> bytes | None:
+ """Load an opaque public object after validating its trusted scope."""
+
+ match = _SCOPED_KEY.fullmatch(object_key)
+ if match is None or match.group('owner_type') != expected_owner_type:
+ return None
+ if match.group('instance') != self._digest(self.ap.workspace_service.instance_uuid, 24):
+ return None
+ workspace_uuid = match.group('workspace')
+ generation = int(match.group('generation'))
+ with bounded_executor.blocking_work_scope(workspace_uuid):
+ try:
+ await self.ap.workspace_service.get_execution_binding(
+ workspace_uuid,
+ expected_generation=generation,
+ )
+ except Exception:
+ return None
+ if not await self.storage_provider.exists(object_key):
+ return None
+ return await self._load_object_bounded(object_key)
+
+ @classmethod
+ def require_scoped_object_key(
+ cls,
+ context: ExecutionContext | RequestContext,
+ object_key: str,
+ *,
+ expected_owner_type: str,
+ ) -> None:
+ """Validate an opaque object key against every captured scope field."""
+
+ instance_uuid, workspace_uuid, generation = cls._require_execution_scope(context)
+ match = _SCOPED_KEY.fullmatch(object_key)
+ if (
+ match is None
+ or match.group('instance') != cls._digest(instance_uuid, 24)
+ or match.group('workspace') != workspace_uuid
+ or int(match.group('generation')) != generation
+ or match.group('owner_type') != expected_owner_type
+ ):
+ raise WorkspaceRequiredError('Object key does not belong to the execution scope')
+
+ async def exists_scoped_object_key(
+ self,
+ context: ExecutionContext | RequestContext,
+ object_key: str,
+ *,
+ expected_owner_type: str,
+ ) -> bool:
+ await self._require_active_execution_scope(context)
+ self.require_scoped_object_key(
+ context,
+ object_key,
+ expected_owner_type=expected_owner_type,
+ )
+ return await self.storage_provider.exists(object_key)
+
+ async def load_scoped_object_key(
+ self,
+ context: ExecutionContext | RequestContext,
+ object_key: str,
+ *,
+ expected_owner_type: str,
+ ) -> bytes:
+ await self._require_active_execution_scope(context)
+ self.require_scoped_object_key(
+ context,
+ object_key,
+ expected_owner_type=expected_owner_type,
+ )
+ return await self._load_object_bounded(object_key)
+
+ async def size_scoped_object_key(
+ self,
+ context: ExecutionContext | RequestContext,
+ object_key: str,
+ *,
+ expected_owner_type: str,
+ ) -> int:
+ await self._require_active_execution_scope(context)
+ self.require_scoped_object_key(
+ context,
+ object_key,
+ expected_owner_type=expected_owner_type,
+ )
+ return await self.storage_provider.size(object_key)
+
+ async def delete_scoped_object_key(
+ self,
+ context: ExecutionContext | RequestContext,
+ object_key: str,
+ *,
+ expected_owner_type: str,
+ ) -> None:
+ """Delete a previously returned key only inside the captured scope."""
+
+ await self._require_active_execution_scope(context)
+ self.require_scoped_object_key(
+ context,
+ object_key,
+ expected_owner_type=expected_owner_type,
+ )
+ if await self.storage_provider.exists(object_key):
+ await self.storage_provider.delete(object_key)
+
+ @classmethod
+ def is_scoped_object_key(
+ cls,
+ object_key: str,
+ *,
+ expected_owner_type: str | None = None,
+ ) -> bool:
+ match = _SCOPED_KEY.fullmatch(object_key)
+ return match is not None and (expected_owner_type is None or match.group('owner_type') == expected_owner_type)
+
async def initialize(self):
storage_config = self.ap.instance_config.data.get('storage', {})
storage_type = storage_config.get('use', 'local')
@@ -30,3 +366,8 @@ class StorageMgr:
self.ap.logger.info('Initialized local storage backend.')
await self.storage_provider.initialize()
+
+ async def shutdown(self) -> None:
+ storage_provider = getattr(self, 'storage_provider', None)
+ if storage_provider is not None:
+ await storage_provider.shutdown()
diff --git a/src/langbot/pkg/storage/provider.py b/src/langbot/pkg/storage/provider.py
index e24dcbf97..1747d49d2 100644
--- a/src/langbot/pkg/storage/provider.py
+++ b/src/langbot/pkg/storage/provider.py
@@ -5,6 +5,19 @@ import abc
from ..core import app
+HARD_MAX_STORAGE_OBJECT_BYTES = 64 * 1024 * 1024
+
+
+def normalize_read_limit(max_bytes: int) -> int:
+ """Validate a provider read limit without allowing callers to bypass the hard cap."""
+
+ try:
+ normalized = int(max_bytes)
+ except (TypeError, ValueError):
+ normalized = HARD_MAX_STORAGE_OBJECT_BYTES
+ return min(max(normalized, 1), HARD_MAX_STORAGE_OBJECT_BYTES)
+
+
class StorageProvider(abc.ABC):
ap: app.Application
@@ -14,6 +27,11 @@ class StorageProvider(abc.ABC):
async def initialize(self):
pass
+ async def shutdown(self) -> None:
+ """Release provider-owned clients or pools."""
+
+ return None
+
@abc.abstractmethod
async def save(
self,
@@ -29,6 +47,23 @@ class StorageProvider(abc.ABC):
) -> bytes:
pass
+ async def load_bounded(self, key: str, *, max_bytes: int) -> bytes:
+ """Fallback for third-party providers that have not implemented streaming bounds.
+
+ Built-in providers override this method so the byte limit is enforced by
+ the actual read. The size check still protects compatible providers from
+ downloading a known oversized object.
+ """
+
+ max_bytes = normalize_read_limit(max_bytes)
+ object_size = await self.size(key)
+ if object_size > max_bytes:
+ raise ValueError(f'Storage object exceeds the {max_bytes}-byte read limit')
+ value = await self.load(key)
+ if len(value) > max_bytes:
+ raise ValueError(f'Storage object exceeds the {max_bytes}-byte read limit')
+ return value
+
@abc.abstractmethod
async def exists(
self,
diff --git a/src/langbot/pkg/storage/providers/localstorage.py b/src/langbot/pkg/storage/providers/localstorage.py
index 43566fc7b..9775badb5 100644
--- a/src/langbot/pkg/storage/providers/localstorage.py
+++ b/src/langbot/pkg/storage/providers/localstorage.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
import os
import aiofiles
import shutil
@@ -40,10 +41,9 @@ class LocalStorageProvider(provider.StorageProvider):
key: str,
value: bytes,
):
- resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
+ resolved = await asyncio.to_thread(_safe_resolve, LOCAL_STORAGE_PATH, key)
parent = os.path.dirname(resolved)
- if not os.path.exists(parent):
- os.makedirs(parent)
+ await asyncio.to_thread(os.makedirs, parent, exist_ok=True)
async with aiofiles.open(resolved, 'wb') as f:
await f.write(value)
@@ -51,36 +51,51 @@ class LocalStorageProvider(provider.StorageProvider):
self,
key: str,
) -> bytes:
- resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
+ return await self.load_bounded(key, max_bytes=provider.HARD_MAX_STORAGE_OBJECT_BYTES)
+
+ async def load_bounded(
+ self,
+ key: str,
+ *,
+ max_bytes: int,
+ ) -> bytes:
+ max_bytes = provider.normalize_read_limit(max_bytes)
+ resolved = await asyncio.to_thread(_safe_resolve, LOCAL_STORAGE_PATH, key)
async with aiofiles.open(resolved, 'rb') as f:
- return await f.read()
+ value = await f.read(max_bytes + 1)
+ if len(value) > max_bytes:
+ raise ValueError(f'Storage object exceeds the {max_bytes}-byte read limit')
+ return value
async def exists(
self,
key: str,
) -> bool:
- resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
- return os.path.exists(resolved)
+ resolved = await asyncio.to_thread(_safe_resolve, LOCAL_STORAGE_PATH, key)
+ return await asyncio.to_thread(os.path.exists, resolved)
async def delete(
self,
key: str,
):
- resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
- os.remove(resolved)
+ resolved = await asyncio.to_thread(_safe_resolve, LOCAL_STORAGE_PATH, key)
+ await asyncio.to_thread(os.remove, resolved)
async def size(
self,
key: str,
) -> int:
- resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
- return os.path.getsize(resolved)
+ resolved = await asyncio.to_thread(_safe_resolve, LOCAL_STORAGE_PATH, key)
+ return await asyncio.to_thread(os.path.getsize, resolved)
async def delete_dir_recursive(
self,
dir_path: str,
):
- resolved = _safe_resolve(LOCAL_STORAGE_PATH, dir_path)
- # 直接删除整个目录
- if os.path.exists(resolved):
- shutil.rmtree(resolved)
+ resolved = await asyncio.to_thread(
+ _safe_resolve,
+ LOCAL_STORAGE_PATH,
+ dir_path,
+ )
+ if await asyncio.to_thread(os.path.exists, resolved):
+ await asyncio.to_thread(shutil.rmtree, resolved)
diff --git a/src/langbot/pkg/storage/providers/s3storage.py b/src/langbot/pkg/storage/providers/s3storage.py
index 43cc2e966..308665971 100644
--- a/src/langbot/pkg/storage/providers/s3storage.py
+++ b/src/langbot/pkg/storage/providers/s3storage.py
@@ -1,9 +1,12 @@
from __future__ import annotations
+import asyncio
+
import boto3
from botocore.exceptions import ClientError
from ...core import app
+from ...utils import bounded_executor
from .. import provider
@@ -14,6 +17,7 @@ class S3StorageProvider(provider.StorageProvider):
super().__init__(ap)
self.s3_client = None
self.bucket_name = None
+ self._io_semaphore = asyncio.Semaphore(16)
async def initialize(self):
"""Initialize S3 client with configuration from config.yaml"""
@@ -26,6 +30,11 @@ class S3StorageProvider(provider.StorageProvider):
secret_access_key = s3_config.get('secret_access_key', '')
region_name = s3_config.get('region', 'us-east-1')
self.bucket_name = s3_config.get('bucket', 'langbot-storage')
+ try:
+ max_concurrency = int(s3_config.get('max_concurrency', 16))
+ except (TypeError, ValueError):
+ max_concurrency = 16
+ self._io_semaphore = asyncio.Semaphore(max(1, min(max_concurrency, 128)))
# Initialize S3 client
session = boto3.session.Session()
@@ -37,7 +46,25 @@ class S3StorageProvider(provider.StorageProvider):
aws_secret_access_key=secret_access_key,
)
- # Ensure bucket exists
+ await self._run_io(self._ensure_bucket)
+
+ async def shutdown(self) -> None:
+ """Close the botocore HTTP connection pool without blocking the loop."""
+
+ client = self.s3_client
+ self.s3_client = None
+ if client is not None:
+ await bounded_executor.run_blocking_cleanup(client.close)
+
+ async def _run_io(self, operation, /, *args, **kwargs):
+ """Run one blocking boto3 operation behind a bounded concurrency gate."""
+
+ async with self._io_semaphore:
+ return await asyncio.to_thread(operation, *args, **kwargs)
+
+ def _ensure_bucket(self) -> None:
+ """Probe/create the bucket without blocking the application event loop."""
+
try:
self.s3_client.head_bucket(Bucket=self.bucket_name)
except ClientError as e:
@@ -61,7 +88,8 @@ class S3StorageProvider(provider.StorageProvider):
):
"""Save bytes to S3"""
try:
- self.s3_client.put_object(
+ await self._run_io(
+ self.s3_client.put_object,
Bucket=self.bucket_name,
Key=key,
Body=value,
@@ -73,25 +101,48 @@ class S3StorageProvider(provider.StorageProvider):
async def load(
self,
key: str,
+ ) -> bytes:
+ return await self.load_bounded(key, max_bytes=provider.HARD_MAX_STORAGE_OBJECT_BYTES)
+
+ async def load_bounded(
+ self,
+ key: str,
+ *,
+ max_bytes: int,
) -> bytes:
"""Load bytes from S3"""
+ max_bytes = provider.normalize_read_limit(max_bytes)
try:
- response = self.s3_client.get_object(
- Bucket=self.bucket_name,
- Key=key,
- )
- return response['Body'].read()
+ return await self._run_io(self._load_sync, key, max_bytes)
except Exception as e:
self.ap.logger.error(f'Failed to load from S3: {e}')
raise
+ def _load_sync(self, key: str, max_bytes: int) -> bytes:
+ response = self.s3_client.get_object(
+ Bucket=self.bucket_name,
+ Key=key,
+ )
+ body = response['Body']
+ try:
+ declared_size = response.get('ContentLength')
+ if declared_size is not None and declared_size > max_bytes:
+ raise ValueError(f'Storage object exceeds the {max_bytes}-byte read limit')
+ value = body.read(max_bytes + 1)
+ if len(value) > max_bytes:
+ raise ValueError(f'Storage object exceeds the {max_bytes}-byte read limit')
+ return value
+ finally:
+ body.close()
+
async def exists(
self,
key: str,
) -> bool:
"""Check if object exists in S3"""
try:
- self.s3_client.head_object(
+ await self._run_io(
+ self.s3_client.head_object,
Bucket=self.bucket_name,
Key=key,
)
@@ -109,7 +160,8 @@ class S3StorageProvider(provider.StorageProvider):
):
"""Delete object from S3"""
try:
- self.s3_client.delete_object(
+ await self._run_io(
+ self.s3_client.delete_object,
Bucket=self.bucket_name,
Key=key,
)
@@ -123,7 +175,8 @@ class S3StorageProvider(provider.StorageProvider):
) -> int:
"""Get object size from S3 without downloading it"""
try:
- response = self.s3_client.head_object(
+ response = await self._run_io(
+ self.s3_client.head_object,
Bucket=self.bucket_name,
Key=key,
)
@@ -138,23 +191,23 @@ class S3StorageProvider(provider.StorageProvider):
):
"""Delete all objects with the given prefix (directory)"""
try:
- # Ensure dir_path ends with /
- if not dir_path.endswith('/'):
- dir_path = dir_path + '/'
-
- # List all objects with the prefix
- paginator = self.s3_client.get_paginator('list_objects_v2')
- pages = paginator.paginate(Bucket=self.bucket_name, Prefix=dir_path)
-
- # Delete all objects
- for page in pages:
- if 'Contents' in page:
- objects_to_delete = [{'Key': obj['Key']} for obj in page['Contents']]
- if objects_to_delete:
- self.s3_client.delete_objects(
- Bucket=self.bucket_name,
- Delete={'Objects': objects_to_delete},
- )
+ await self._run_io(self._delete_dir_recursive_sync, dir_path)
except Exception as e:
self.ap.logger.error(f'Failed to delete directory from S3: {e}')
raise
+
+ def _delete_dir_recursive_sync(self, dir_path: str) -> None:
+ if not dir_path.endswith('/'):
+ dir_path = dir_path + '/'
+
+ paginator = self.s3_client.get_paginator('list_objects_v2')
+ pages = paginator.paginate(Bucket=self.bucket_name, Prefix=dir_path)
+ for page in pages:
+ if 'Contents' not in page:
+ continue
+ objects_to_delete = [{'Key': obj['Key']} for obj in page['Contents']]
+ if objects_to_delete:
+ self.s3_client.delete_objects(
+ Bucket=self.bucket_name,
+ Delete={'Objects': objects_to_delete},
+ )
diff --git a/src/langbot/pkg/survey/manager.py b/src/langbot/pkg/survey/manager.py
index 34689625e..51bba58a7 100644
--- a/src/langbot/pkg/survey/manager.py
+++ b/src/langbot/pkg/survey/manager.py
@@ -2,15 +2,17 @@
from __future__ import annotations
-import asyncio
+import contextlib
import json
import typing
import httpx
import sqlalchemy
from ..core import app as core_app
+from ..core import entities as core_entities
from ..entity.persistence.metadata import Metadata
-from ..utils import constants
+from ..persistence.tenant_uow import CrossScopeTransactionError
+from ..utils import constants, httpclient
SURVEY_TRIGGERED_KEY = 'survey_triggered_events'
BOT_RESPONSE_COUNT_KEY = 'survey_bot_response_count'
@@ -36,15 +38,41 @@ class SurveyManager:
await self._load_triggered_events()
await self._load_bot_response_count()
+ @contextlib.asynccontextmanager
+ async def _instance_transaction(self):
+ """Bind instance-global survey metadata to an explicit Cloud transaction."""
+
+ persistence_mgr = self.ap.persistence_mgr
+ try:
+ active_session = getattr(persistence_mgr, 'current_session', lambda: None)()
+ except CrossScopeTransactionError:
+ # A newly-created child task inherited its parent's ContextVar;
+ # opening an explicit UoW below gives it an independent session.
+ active_session = None
+ if active_session is not None:
+ yield
+ return
+
+ cloud_runtime = getattr(getattr(persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ instance_uow = getattr(persistence_mgr, 'instance_discovery_uow', None)
+ if not callable(instance_uow):
+ raise RuntimeError('Cloud survey metadata requires an explicit instance UoW')
+ async with instance_uow(self.ap.workspace_service.instance_uuid):
+ yield
+ return
+ yield
+
async def _load_triggered_events(self):
"""Load previously triggered events from metadata table."""
try:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY)
- )
- row = result.first()
- if row:
- self._triggered_events = set(json.loads(row[0].value))
+ async with self._instance_transaction():
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(Metadata.value).where(Metadata.key == SURVEY_TRIGGERED_KEY)
+ )
+ value = result.scalar_one_or_none()
+ if value is not None:
+ self._triggered_events = set(json.loads(value))
except Exception:
self._triggered_events = set()
@@ -52,17 +80,18 @@ class SurveyManager:
"""Persist triggered events to metadata table."""
try:
value = json.dumps(list(self._triggered_events))
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY)
- )
- if result.first():
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY).values(value=value)
- )
- else:
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(Metadata).values(key=SURVEY_TRIGGERED_KEY, value=value)
+ async with self._instance_transaction():
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(Metadata.value).where(Metadata.key == SURVEY_TRIGGERED_KEY)
)
+ if result.scalar_one_or_none() is not None:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY).values(value=value)
+ )
+ else:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(Metadata).values(key=SURVEY_TRIGGERED_KEY, value=value)
+ )
except Exception as e:
self.ap.logger.debug(f'Failed to save survey triggered events: {e}')
@@ -75,12 +104,13 @@ class SurveyManager:
async def _load_bot_response_count(self):
"""Load the persisted successful bot response count from metadata table."""
try:
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY)
- )
- row = result.first()
- if row:
- self._bot_response_count = int(row[0].value)
+ async with self._instance_transaction():
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(Metadata.value).where(Metadata.key == BOT_RESPONSE_COUNT_KEY)
+ )
+ value = result.scalar_one_or_none()
+ if value is not None:
+ self._bot_response_count = int(value)
except Exception:
self._bot_response_count = 0
@@ -88,17 +118,18 @@ class SurveyManager:
"""Persist the successful bot response count to metadata table."""
try:
value = str(self._bot_response_count)
- result = await self.ap.persistence_mgr.execute_async(
- sqlalchemy.select(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY)
- )
- if result.first():
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.update(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY).values(value=value)
- )
- else:
- await self.ap.persistence_mgr.execute_async(
- sqlalchemy.insert(Metadata).values(key=BOT_RESPONSE_COUNT_KEY, value=value)
+ async with self._instance_transaction():
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(Metadata.value).where(Metadata.key == BOT_RESPONSE_COUNT_KEY)
)
+ if result.scalar_one_or_none() is not None:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(Metadata).where(Metadata.key == BOT_RESPONSE_COUNT_KEY).values(value=value)
+ )
+ else:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(Metadata).values(key=BOT_RESPONSE_COUNT_KEY, value=value)
+ )
except Exception as e:
self.ap.logger.debug(f'Failed to save survey bot response count: {e}')
@@ -131,7 +162,13 @@ class SurveyManager:
await self._save_triggered_events()
# Check for pending survey asynchronously
- asyncio.create_task(self._fetch_pending_survey(event))
+ self.ap.task_mgr.create_task(
+ self._fetch_pending_survey(event),
+ kind='survey-fetch',
+ name=f'survey-fetch-{event}',
+ scopes=[core_entities.LifecycleControlScope.APPLICATION],
+ instance_uuid=self.ap.workspace_service.instance_uuid,
+ )
async def _fetch_pending_survey(self, event: str):
"""Fetch pending survey from Space for this event."""
@@ -141,10 +178,13 @@ class SurveyManager:
'instance_id': constants.instance_id,
'event': event,
}
- async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
+ async with httpx.AsyncClient(
+ timeout=httpx.Timeout(10),
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ ) as client:
resp = await client.post(url, json=payload)
if resp.status_code == 200:
- data = resp.json()
+ data = await httpclient.parse_json_response(resp)
if data.get('code') == 0 and data.get('data', {}).get('survey'):
self._pending_survey = data['data']['survey']
self.ap.logger.info(f'Survey pending: {self._pending_survey.get("survey_id")}')
@@ -187,7 +227,10 @@ class SurveyManager:
'metadata': await self._build_base_metadata(),
'completed': completed,
}
- async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
+ async with httpx.AsyncClient(
+ timeout=httpx.Timeout(10),
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ ) as client:
resp = await client.post(url, json=payload)
if resp.status_code == 200:
self.clear_pending_survey()
@@ -214,11 +257,15 @@ class SurveyManager:
'attachments': attachments,
'metadata': metadata,
}
- async with httpx.AsyncClient(timeout=httpx.Timeout(30)) as client:
+ async with httpx.AsyncClient(
+ timeout=httpx.Timeout(30),
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ ) as client:
resp = await client.post(url, json=payload)
if resp.status_code == 200:
return True
- self.ap.logger.warning(f'Failed to submit feedback: {resp.status_code} {resp.text[:200]}')
+ body = await httpclient.response_text(resp, max_chars=200)
+ self.ap.logger.warning(f'Failed to submit feedback: {resp.status_code} {body}')
except Exception as e:
self.ap.logger.warning(f'Failed to submit feedback: {e}')
return False
@@ -233,7 +280,10 @@ class SurveyManager:
'survey_id': survey_id,
'instance_id': constants.instance_id,
}
- async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
+ async with httpx.AsyncClient(
+ timeout=httpx.Timeout(10),
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ ) as client:
resp = await client.post(url, json=payload)
if resp.status_code == 200:
self.clear_pending_survey()
diff --git a/src/langbot/pkg/telemetry/heartbeat.py b/src/langbot/pkg/telemetry/heartbeat.py
index 34b616733..3c3efb507 100644
--- a/src/langbot/pkg/telemetry/heartbeat.py
+++ b/src/langbot/pkg/telemetry/heartbeat.py
@@ -27,9 +27,25 @@ if typing.TYPE_CHECKING:
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600
-async def _count(ap: core_app.Application, table) -> int:
+async def _count(
+ ap: core_app.Application,
+ table,
+ *,
+ cloud_counter: typing.Callable[[], int] | None = None,
+) -> int:
"""Count rows in a persistence table; -1 when unavailable."""
try:
+ persistence_mgr = ap.persistence_mgr
+ cloud_runtime = getattr(getattr(persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
+ if cloud_runtime:
+ # The Cloud runtime role deliberately cannot bypass RLS. Counting
+ # every tenant by opening one UoW per Workspace turns a best-effort
+ # daily heartbeat into thousands of serial SQL statements. The
+ # already-loaded runtime registries are authoritative for this
+ # process and provide an O(1), connection-free operational count.
+ if cloud_counter is None:
+ return -1
+ return max(int(cloud_counter()), 0)
result = await ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count()).select_from(table))
return int(result.scalar() or 0)
except Exception:
@@ -81,11 +97,27 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
pass
# Resource counts
- features['pipeline_count'] = await _count(ap, persistence_pipeline.LegacyPipeline)
- features['mcp_server_count'] = await _count(ap, persistence_mcp.MCPServer)
- features['knowledge_base_count'] = await _count(ap, persistence_rag.KnowledgeBase)
+ features['pipeline_count'] = await _count(
+ ap,
+ persistence_pipeline.LegacyPipeline,
+ cloud_counter=lambda: len(ap.pipeline_mgr._pipelines_by_key),
+ )
+ features['mcp_server_count'] = await _count(
+ ap,
+ persistence_mcp.MCPServer,
+ cloud_counter=lambda: len(ap.tool_mgr.mcp_tool_loader._sessions),
+ )
+ features['knowledge_base_count'] = await _count(
+ ap,
+ persistence_rag.KnowledgeBase,
+ cloud_counter=lambda: len(ap.rag_mgr.knowledge_bases),
+ )
if 'bot_count' not in features:
- features['bot_count'] = await _count(ap, persistence_bot.Bot)
+ features['bot_count'] = await _count(
+ ap,
+ persistence_bot.Bot,
+ cloud_counter=lambda: len(ap.platform_mgr._bots_by_key),
+ )
# Plugin count (from plugin runtime)
try:
@@ -99,8 +131,8 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
# Skill count (from Box runtime via skill manager)
try:
skill_mgr = getattr(ap, 'skill_mgr', None)
- if skill_mgr is not None and getattr(skill_mgr, 'skills', None) is not None:
- features['skill_count'] = len(skill_mgr.skills)
+ if skill_mgr is not None:
+ features['skill_count'] = skill_mgr.total_cached_skill_count()
except Exception:
pass
diff --git a/src/langbot/pkg/telemetry/telemetry.py b/src/langbot/pkg/telemetry/telemetry.py
index 2948e268a..6c3b8ba29 100644
--- a/src/langbot/pkg/telemetry/telemetry.py
+++ b/src/langbot/pkg/telemetry/telemetry.py
@@ -1,8 +1,13 @@
from __future__ import annotations
import asyncio
+import contextlib
import httpx
from ..core import app as core_app
+from ..utils import httpclient
+
+
+_MAX_INFLIGHT_TELEMETRY_TASKS = 8
class TelemetryManager:
@@ -18,13 +23,45 @@ class TelemetryManager:
self.telemetry_config = {}
self.send_tasks: list[asyncio.Task] = []
+ self._client: httpx.AsyncClient | None = None
async def initialize(self):
self.telemetry_config = self.ap.instance_config.data.get('space', {})
async def start_send_task(self, payload: dict):
+ self.send_tasks = [task for task in self.send_tasks if not task.done()]
+ if len(self.send_tasks) >= _MAX_INFLIGHT_TELEMETRY_TASKS:
+ self.ap.logger.debug('Telemetry queue is full; dropping best-effort event')
+ return
task = asyncio.create_task(self.send(payload))
self.send_tasks.append(task)
+ task.add_done_callback(self._send_task_done)
+
+ def _send_task_done(self, task: asyncio.Task) -> None:
+ try:
+ self.send_tasks.remove(task)
+ except ValueError:
+ pass
+
+ async def shutdown(self) -> None:
+ tasks = list(self.send_tasks)
+ for task in tasks:
+ task.cancel()
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+ self.send_tasks.clear()
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+
+ @contextlib.asynccontextmanager
+ async def _client_context(self):
+ if self._client is None or self._client.is_closed:
+ self._client = httpx.AsyncClient(
+ timeout=httpx.Timeout(10),
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ )
+ yield self._client
async def send(self, payload: dict):
"""Send telemetry payload to configured telemetry server (non-blocking).
@@ -91,20 +128,21 @@ class TelemetryManager:
except Exception:
sanitized['duration_ms'] = 0
- async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
+ async with self._client_context() as client:
try:
# Use asyncio.wait_for to ensure we always bound the total time
resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1)
if resp.status_code >= 400:
+ body = await httpclient.response_text(resp, max_chars=200)
self.ap.logger.warning(
- f'Telemetry post to {url} returned status {resp.status_code} - {resp.text}'
+ f'Telemetry post to {url} returned status {resp.status_code} - {body}'
)
else:
# Detect application-level errors inside HTTP 200 responses
app_err = False
try:
- j = resp.json()
+ j = await httpclient.parse_json_response(resp)
if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400:
app_err = True
self.ap.logger.warning(
@@ -114,12 +152,14 @@ class TelemetryManager:
pass
if app_err:
+ body = await httpclient.response_text(resp, max_chars=200)
self.ap.logger.warning(
- f'Telemetry post to {url} returned app-level error - response: {resp.text[:200]}'
+ f'Telemetry post to {url} returned app-level error - response: {body}'
)
else:
+ body = await httpclient.response_text(resp, max_chars=200)
self.ap.logger.debug(
- f'Telemetry posted to {url}, status {resp.status_code} - response: {resp.text[:200]}'
+ f'Telemetry posted to {url}, status {resp.status_code} - response: {body}'
)
except asyncio.TimeoutError:
self.ap.logger.warning(f'Telemetry post to {url} timed out')
diff --git a/src/langbot/pkg/utils/bounded_executor.py b/src/langbot/pkg/utils/bounded_executor.py
new file mode 100644
index 000000000..006714181
--- /dev/null
+++ b/src/langbot/pkg/utils/bounded_executor.py
@@ -0,0 +1,312 @@
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import contextlib
+import contextvars
+import threading
+from collections.abc import Callable
+from typing import Any
+
+
+DEFAULT_MAX_WORKERS = 8
+DEFAULT_MAX_PENDING = 128
+DEFAULT_MAX_INFLIGHT_PER_SCOPE = 4
+HARD_MAX_WORKERS = 64
+HARD_MAX_PENDING = 4096
+BLOCKING_CLEANUP_SCOPE = 'system:cleanup'
+_CLEANUP_RETRY_INITIAL_SECONDS = 0.01
+_CLEANUP_RETRY_MAX_SECONDS = 0.25
+
+_blocking_work_scope: contextvars.ContextVar[str | None] = contextvars.ContextVar(
+ 'langbot_blocking_work_scope',
+ default=None,
+)
+
+
+class BlockingWorkCapacityError(RuntimeError):
+ """Raised before unbounded blocking work can enter the executor queue."""
+
+ def __init__(self, message: str, *, scope: str | None = None) -> None:
+ super().__init__(message)
+ self.scope = scope
+
+
+@contextlib.contextmanager
+def blocking_work_scope(scope: str | None):
+ """Attribute blocking submissions to one trusted tenant scope."""
+
+ normalized = str(scope).strip() if scope is not None else None
+ if not normalized:
+ yield
+ return
+ token = _blocking_work_scope.set(normalized)
+ try:
+ yield
+ finally:
+ _blocking_work_scope.reset(token)
+
+
+def current_blocking_work_scope() -> str | None:
+ """Return the active trusted blocking-work scope, if any."""
+
+ return _blocking_work_scope.get()
+
+
+async def run_blocking_atomic(
+ fn: Callable[..., Any],
+ /,
+ *args: Any,
+ **kwargs: Any,
+) -> Any:
+ """Let an admitted filesystem operation finish before propagating cancel."""
+
+ task = asyncio.create_task(asyncio.to_thread(fn, *args, **kwargs))
+ try:
+ return await asyncio.shield(task)
+ except asyncio.CancelledError:
+ await asyncio.gather(task, return_exceptions=True)
+ raise
+
+
+async def run_blocking_cleanup(
+ fn: Callable[..., Any],
+ /,
+ *args: Any,
+ **kwargs: Any,
+) -> Any:
+ """Wait for bounded executor capacity and complete cleanup atomically."""
+
+ retry_delay = _CLEANUP_RETRY_INITIAL_SECONDS
+ while True:
+ try:
+ with blocking_work_scope(BLOCKING_CLEANUP_SCOPE):
+ return await run_blocking_atomic(fn, *args, **kwargs)
+ except BlockingWorkCapacityError as exc:
+ if exc.scope != BLOCKING_CLEANUP_SCOPE:
+ raise
+ await asyncio.sleep(retry_delay)
+ retry_delay = min(
+ retry_delay * 2,
+ _CLEANUP_RETRY_MAX_SECONDS,
+ )
+
+
+async def run_in_blocking_work_scope(
+ coro,
+ scope: str | None,
+):
+ """Run a coroutine with blocking-work fairness attribution."""
+
+ with blocking_work_scope(scope):
+ return await coro
+
+
+def _bounded_integer(
+ value: Any,
+ *,
+ name: str,
+ minimum: int,
+ maximum: int,
+) -> int:
+ if isinstance(value, bool):
+ raise ValueError(f'{name} must be an integer')
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f'{name} must be an integer') from exc
+ if parsed < minimum or parsed > maximum:
+ raise ValueError(f'{name} must be between {minimum} and {maximum}')
+ return parsed
+
+
+def _validated_limits(
+ max_workers: Any,
+ max_pending: Any,
+ max_inflight_per_scope: Any | None,
+) -> tuple[int, int, int]:
+ workers = _bounded_integer(
+ max_workers,
+ name='blocking_executor.max_workers',
+ minimum=1,
+ maximum=HARD_MAX_WORKERS,
+ )
+ pending = _bounded_integer(
+ max_pending,
+ name='blocking_executor.max_pending',
+ minimum=0,
+ maximum=HARD_MAX_PENDING,
+ )
+ fair_share = max(1, workers // 2)
+ scope_limit = (
+ min(DEFAULT_MAX_INFLIGHT_PER_SCOPE, fair_share)
+ if max_inflight_per_scope is None
+ else _bounded_integer(
+ max_inflight_per_scope,
+ name='blocking_executor.max_inflight_per_scope',
+ minimum=1,
+ maximum=HARD_MAX_PENDING,
+ )
+ )
+ if scope_limit > fair_share:
+ raise ValueError(f'blocking_executor.max_inflight_per_scope must not exceed half of max_workers ({fair_share})')
+ return workers, pending, scope_limit
+
+
+class BoundedThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
+ """Thread pool with a hard cap on running plus queued submissions."""
+
+ def __init__(
+ self,
+ *,
+ max_workers: int = DEFAULT_MAX_WORKERS,
+ max_pending: int = DEFAULT_MAX_PENDING,
+ max_inflight_per_scope: int | None = None,
+ thread_name_prefix: str = 'langbot-blocking',
+ ) -> None:
+ max_workers, max_pending, max_inflight_per_scope = _validated_limits(
+ max_workers,
+ max_pending,
+ max_inflight_per_scope,
+ )
+ super().__init__(
+ max_workers=max_workers,
+ thread_name_prefix=thread_name_prefix,
+ )
+ self.max_workers = max_workers
+ self.max_pending = max_pending
+ self.max_inflight_per_scope = max_inflight_per_scope
+ self._capacity = threading.BoundedSemaphore(max_workers + max_pending)
+ self._stats_lock = threading.Lock()
+ self._inflight_by_scope: dict[str, int] = {}
+ self._inflight = 0
+ self._running = 0
+ self._submitted_total = 0
+ self._completed_total = 0
+ self._rejected_total = 0
+ self._global_rejected_total = 0
+ self._scope_rejected_total = 0
+
+ def submit(
+ self,
+ fn: Callable[..., Any],
+ /,
+ *args: Any,
+ **kwargs: Any,
+ ) -> concurrent.futures.Future:
+ scope = current_blocking_work_scope()
+ if not self._capacity.acquire(blocking=False):
+ with self._stats_lock:
+ self._rejected_total += 1
+ self._global_rejected_total += 1
+ raise BlockingWorkCapacityError(
+ 'Blocking executor capacity reached',
+ scope=scope,
+ )
+
+ with self._stats_lock:
+ if scope is not None and self._inflight_by_scope.get(scope, 0) >= self.max_inflight_per_scope:
+ self._rejected_total += 1
+ self._scope_rejected_total += 1
+ self._capacity.release()
+ raise BlockingWorkCapacityError(
+ 'Workspace blocking executor capacity reached',
+ scope=scope,
+ )
+ self._inflight += 1
+ self._submitted_total += 1
+ if scope is not None:
+ self._inflight_by_scope[scope] = self._inflight_by_scope.get(scope, 0) + 1
+
+ def run() -> Any:
+ with self._stats_lock:
+ self._running += 1
+ try:
+ return fn(*args, **kwargs)
+ finally:
+ with self._stats_lock:
+ self._running -= 1
+
+ try:
+ future = super().submit(run)
+ except BaseException:
+ with self._stats_lock:
+ self._inflight -= 1
+ self._release_scope_locked(scope)
+ self._capacity.release()
+ raise
+
+ def complete(_future: concurrent.futures.Future) -> None:
+ with self._stats_lock:
+ self._inflight -= 1
+ self._completed_total += 1
+ self._release_scope_locked(scope)
+ self._capacity.release()
+
+ future.add_done_callback(complete)
+ return future
+
+ def _release_scope_locked(self, scope: str | None) -> None:
+ if scope is None:
+ return
+ remaining = self._inflight_by_scope.get(scope, 0) - 1
+ if remaining > 0:
+ self._inflight_by_scope[scope] = remaining
+ else:
+ self._inflight_by_scope.pop(scope, None)
+
+ def snapshot(self) -> dict[str, int]:
+ with self._stats_lock:
+ inflight = self._inflight
+ running = self._running
+ return {
+ 'max_workers': self.max_workers,
+ 'max_pending': self.max_pending,
+ 'max_inflight_per_scope': self.max_inflight_per_scope,
+ 'inflight': inflight,
+ 'running': running,
+ 'pending': max(inflight - running, 0),
+ 'active_scopes': len(self._inflight_by_scope),
+ 'submitted_total': self._submitted_total,
+ 'completed_total': self._completed_total,
+ 'rejected_total': self._rejected_total,
+ 'global_rejected_total': self._global_rejected_total,
+ 'scope_rejected_total': self._scope_rejected_total,
+ }
+
+
+def configure_bounded_default_executor(
+ loop: asyncio.AbstractEventLoop,
+ *,
+ max_workers: int = DEFAULT_MAX_WORKERS,
+ max_pending: int = DEFAULT_MAX_PENDING,
+ max_inflight_per_scope: int | None = None,
+ thread_name_prefix: str = 'langbot-blocking',
+) -> BoundedThreadPoolExecutor:
+ """Install one bounded owner for every ``asyncio.to_thread`` call."""
+
+ max_workers, max_pending, max_inflight_per_scope = _validated_limits(
+ max_workers,
+ max_pending,
+ max_inflight_per_scope,
+ )
+ existing = getattr(loop, '_default_executor', None)
+ if isinstance(existing, BoundedThreadPoolExecutor):
+ if (
+ existing.max_workers != max_workers
+ or existing.max_pending != max_pending
+ or existing.max_inflight_per_scope != max_inflight_per_scope
+ ):
+ raise RuntimeError('The blocking executor is already configured with different limits')
+ return existing
+ if existing is not None:
+ raise RuntimeError('The event loop default executor was initialized before LangBot resource limits')
+
+ executor = BoundedThreadPoolExecutor(
+ max_workers=max_workers,
+ max_pending=max_pending,
+ max_inflight_per_scope=max_inflight_per_scope,
+ thread_name_prefix=thread_name_prefix,
+ )
+ loop.set_default_executor(executor)
+ return executor
diff --git a/src/langbot/pkg/utils/event_loop_monitor.py b/src/langbot/pkg/utils/event_loop_monitor.py
new file mode 100644
index 000000000..f38f10379
--- /dev/null
+++ b/src/langbot/pkg/utils/event_loop_monitor.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import math
+from collections import deque
+
+
+DEFAULT_SAMPLE_INTERVAL_SECONDS = 1.0
+DEFAULT_RECENT_SAMPLE_COUNT = 120
+
+
+class EventLoopLagMonitor:
+ """Measure event-loop scheduling delay with fixed, bounded state."""
+
+ def __init__(
+ self,
+ *,
+ sample_interval_seconds: float = DEFAULT_SAMPLE_INTERVAL_SECONDS,
+ recent_sample_count: int = DEFAULT_RECENT_SAMPLE_COUNT,
+ ) -> None:
+ interval = float(sample_interval_seconds)
+ if not math.isfinite(interval) or interval <= 0:
+ raise ValueError('sample_interval_seconds must be greater than zero')
+ sample_count = int(recent_sample_count)
+ if sample_count < 2 or sample_count > 3600:
+ raise ValueError('recent_sample_count must be between 2 and 3600')
+ self.sample_interval_seconds = interval
+ self.recent_sample_count = sample_count
+ self._recent_lag_ms: deque[float] = deque(maxlen=sample_count)
+ self._samples_total = 0
+ self._max_lag_ms = 0.0
+ self._last_lag_ms = 0.0
+ self._task: asyncio.Task[None] | None = None
+
+ @property
+ def running(self) -> bool:
+ return self._task is not None and not self._task.done()
+
+ def start(self) -> None:
+ """Start sampling on the current event loop; repeated calls are safe."""
+
+ if self.running:
+ return
+ self._task = asyncio.create_task(
+ self._run(),
+ name='event-loop-lag-monitor',
+ )
+
+ async def stop(self) -> None:
+ """Cancel and await the owned sampler task."""
+
+ task = self._task
+ self._task = None
+ if task is None:
+ return
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+
+ async def _run(self) -> None:
+ loop = asyncio.get_running_loop()
+ expected_at = loop.time() + self.sample_interval_seconds
+ while True:
+ await asyncio.sleep(max(expected_at - loop.time(), 0.0))
+ observed_at = loop.time()
+ self._record_lag_seconds(max(observed_at - expected_at, 0.0))
+ # One observation captures a long stall; do not replay every
+ # missed interval in a tight loop after the scheduler recovers.
+ expected_at = observed_at + self.sample_interval_seconds
+
+ def _record_lag_seconds(self, lag_seconds: float) -> None:
+ lag_ms = max(float(lag_seconds), 0.0) * 1000
+ self._last_lag_ms = lag_ms
+ self._max_lag_ms = max(self._max_lag_ms, lag_ms)
+ self._recent_lag_ms.append(lag_ms)
+ self._samples_total += 1
+
+ def snapshot(self) -> dict[str, int | float | bool]:
+ """Return aggregate metrics without exposing task or tenant state."""
+
+ recent = sorted(self._recent_lag_ms)
+ if recent:
+ p95_index = max(math.ceil(len(recent) * 0.95) - 1, 0)
+ recent_p95_ms = recent[p95_index]
+ recent_max_ms = recent[-1]
+ else:
+ recent_p95_ms = 0.0
+ recent_max_ms = 0.0
+ return {
+ 'running': self.running,
+ 'samples_total': self._samples_total,
+ 'last_lag_ms': self._last_lag_ms,
+ 'recent_p95_lag_ms': recent_p95_ms,
+ 'recent_max_lag_ms': recent_max_ms,
+ 'max_lag_ms': self._max_lag_ms,
+ }
diff --git a/src/langbot/pkg/utils/httpclient.py b/src/langbot/pkg/utils/httpclient.py
index e9c04b346..595dc1543 100644
--- a/src/langbot/pkg/utils/httpclient.py
+++ b/src/langbot/pkg/utils/httpclient.py
@@ -11,9 +11,76 @@ reuses the same underlying SSL context and connection pool.
from __future__ import annotations
+import asyncio
+import inspect
+import json
+import typing
+
import aiohttp
+import httpx
_sessions: dict[str, aiohttp.ClientSession] = {}
+DEFAULT_REMOTE_BODY_LIMIT = 10 * 1024 * 1024
+
+
+class RemoteResponseTooLargeError(ValueError):
+ """Raised before an untrusted remote response can exhaust process memory."""
+
+
+class _LimitedHTTPXAsyncByteStream(httpx.AsyncByteStream):
+ def __init__(self, inner: httpx.AsyncByteStream, max_bytes: int) -> None:
+ self._inner = inner
+ self._max_bytes = max_bytes
+ self._read_bytes = 0
+
+ async def __aiter__(self):
+ try:
+ async for chunk in self._inner:
+ self._read_bytes += len(chunk)
+ if self._read_bytes > self._max_bytes:
+ raise RemoteResponseTooLargeError(f'Remote response exceeds the {self._max_bytes}-byte limit')
+ yield chunk
+ except BaseException:
+ # HTTPX only closes a response after normal stream exhaustion. If
+ # this limiter raises (or its consumer is cancelled), explicitly
+ # release the underlying connection before propagating the original
+ # failure so persistent clients cannot accumulate stranded streams.
+ try:
+ await self._inner.aclose()
+ except BaseException:
+ pass
+ raise
+
+ async def aclose(self) -> None:
+ await self._inner.aclose()
+
+
+def httpx_response_limit_hooks(
+ max_bytes: int = DEFAULT_REMOTE_BODY_LIMIT,
+) -> dict[str, list]:
+ """Return hooks that cap HTTPX bodies before its automatic buffering."""
+
+ max_bytes = max(int(max_bytes), 1)
+
+ async def limit_response(response: httpx.Response) -> None:
+ content_length = response.headers.get('Content-Length')
+ if content_length is not None:
+ try:
+ declared_size = int(content_length)
+ except (TypeError, ValueError):
+ declared_size = None
+ if declared_size is not None and declared_size > max_bytes:
+ await response.aclose()
+ raise RemoteResponseTooLargeError(f'Remote response exceeds the {max_bytes}-byte limit')
+
+ if response.is_stream_consumed:
+ if len(response.content) > max_bytes:
+ await response.aclose()
+ raise RemoteResponseTooLargeError(f'Remote response exceeds the {max_bytes}-byte limit')
+ return
+ response.stream = _LimitedHTTPXAsyncByteStream(response.stream, max_bytes)
+
+ return {'response': [limit_response]}
def get_session(*, trust_env: bool = False) -> aiohttp.ClientSession:
@@ -29,7 +96,13 @@ def get_session(*, trust_env: bool = False) -> aiohttp.ClientSession:
session = _sessions.get(key)
if session is None or session.closed:
- session = aiohttp.ClientSession(trust_env=trust_env)
+ # Shared transport pools must never share upstream cookie state across
+ # Workspace-scoped requests. Callers that need a stateful cookie jar
+ # must own a dedicated session instead of using this global pool.
+ session = aiohttp.ClientSession(
+ trust_env=trust_env,
+ cookie_jar=aiohttp.DummyCookieJar(),
+ )
_sessions[key] = session
return session
@@ -41,3 +114,69 @@ async def close_all():
if not session.closed:
await session.close()
_sessions.clear()
+
+
+async def read_limited(
+ response: aiohttp.ClientResponse,
+ *,
+ max_bytes: int = DEFAULT_REMOTE_BODY_LIMIT,
+) -> bytes:
+ """Read an HTTP response incrementally with a strict byte limit."""
+
+ max_bytes = max(int(max_bytes), 1)
+ content_length = response.headers.get('Content-Length')
+ if content_length is not None:
+ try:
+ declared_size = int(content_length)
+ except (TypeError, ValueError):
+ declared_size = None
+ if declared_size is not None and declared_size > max_bytes:
+ raise RemoteResponseTooLargeError(f'Remote response exceeds the {max_bytes}-byte limit')
+
+ body = bytearray()
+ async for chunk in response.content.iter_chunked(64 * 1024):
+ body.extend(chunk)
+ if len(body) > max_bytes:
+ raise RemoteResponseTooLargeError(f'Remote response exceeds the {max_bytes}-byte limit')
+ return bytes(body)
+
+
+async def read_text_limited(
+ response: aiohttp.ClientResponse,
+ *,
+ max_bytes: int = DEFAULT_REMOTE_BODY_LIMIT,
+) -> str:
+ body = await read_limited(response, max_bytes=max_bytes)
+ return body.decode(response.charset or 'utf-8', errors='replace')
+
+
+async def read_json_limited(
+ response: aiohttp.ClientResponse,
+ *,
+ max_bytes: int = DEFAULT_REMOTE_BODY_LIMIT,
+) -> typing.Any:
+ body = await read_limited(response, max_bytes=max_bytes)
+ return await asyncio.to_thread(json.loads, body)
+
+
+async def parse_json_response(response: typing.Any) -> typing.Any:
+ """Parse an already bounded HTTP response without blocking the event loop."""
+
+ parsed = await asyncio.to_thread(response.json)
+ if inspect.isawaitable(parsed):
+ parsed = await parsed
+ return parsed
+
+
+async def response_text(
+ response: typing.Any,
+ *,
+ max_chars: int = 4096,
+) -> str:
+ """Decode an already bounded response body off-loop and cap diagnostics."""
+
+ text = await asyncio.to_thread(lambda: str(response.text))
+ max_chars = max(int(max_chars), 1)
+ if len(text) <= max_chars:
+ return text
+ return f'{text[:max_chars]}... [truncated]'
diff --git a/src/langbot/pkg/utils/image.py b/src/langbot/pkg/utils/image.py
index 0296ba05f..435671ae8 100644
--- a/src/langbot/pkg/utils/image.py
+++ b/src/langbot/pkg/utils/image.py
@@ -8,10 +8,46 @@ import aiohttp
from langbot.pkg.utils import httpclient
import PIL.Image
-import httpx
import asyncio
+_INSECURE_SSL_CONTEXT = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+_INSECURE_SSL_CONTEXT.check_hostname = False
+_INSECURE_SSL_CONTEXT.verify_mode = ssl.CERT_NONE
+DEFAULT_BASE64_MEDIA_LIMIT = 10 * 1024 * 1024
+
+
+def _detect_image_format(file_bytes: bytes) -> str:
+ with PIL.Image.open(io.BytesIO(file_bytes)) as image:
+ return str(image.format or 'jpeg').lower()
+
+
+def _decode_base64_limited(value: str, max_bytes: int) -> bytes:
+ max_bytes = max(int(max_bytes), 1)
+ max_encoded_chars = 4 * ((max_bytes + 2) // 3) + 4
+ if len(value) > max_encoded_chars:
+ raise ValueError(f'Base64 media exceeds the {max_bytes}-byte limit')
+ decoded = base64.b64decode(value)
+ if len(decoded) > max_bytes:
+ raise ValueError(f'Base64 media exceeds the {max_bytes}-byte limit')
+ return decoded
+
+
+async def decode_base64_limited(
+ value: str,
+ *,
+ max_bytes: int = DEFAULT_BASE64_MEDIA_LIMIT,
+) -> bytes:
+ """Decode bounded media outside the event loop."""
+
+ return await asyncio.to_thread(_decode_base64_limited, value, max_bytes)
+
+
+async def encode_base64(data: bytes) -> str:
+ """Encode a bounded byte payload outside the event loop."""
+
+ return (await asyncio.to_thread(base64.b64encode, data)).decode('utf-8')
+
async def get_gewechat_image_base64(
gewechat_url: str,
@@ -59,10 +95,10 @@ async def get_gewechat_image_base64(
timeout=timeout,
) as response:
if response.status != 200:
- # print(response)
- raise Exception(f'获取gewechat图片下载失败: {await response.text()}')
+ error = await httpclient.read_text_limited(response)
+ raise Exception(f'获取gewechat图片下载失败: {error}')
- resp_data = await response.json()
+ resp_data = await httpclient.read_json_limited(response)
if resp_data.get('ret') != 200:
raise Exception(f'获取gewechat图片下载链接失败: {resp_data}')
@@ -80,9 +116,10 @@ async def get_gewechat_image_base64(
try:
async with session.get(download_url) as img_response:
if img_response.status != 200:
- raise Exception(f'下载图片失败: {await img_response.text()}, URL: {download_url}')
+ error = await httpclient.read_text_limited(img_response)
+ raise Exception(f'下载图片失败: {error}, URL: {download_url}')
- image_data = await img_response.read()
+ image_data = await httpclient.read_limited(img_response)
content_type = img_response.headers.get('Content-Type', '')
if content_type:
@@ -90,7 +127,7 @@ async def get_gewechat_image_base64(
else:
image_format = file_url.split('.')[-1]
- base64_str = base64.b64encode(image_data).decode('utf-8')
+ base64_str = await encode_base64(image_data)
return base64_str, image_format
except asyncio.TimeoutError:
@@ -113,16 +150,13 @@ async def get_wecom_image_base64(pic_url: str) -> tuple[str, str]:
raise Exception(f'Failed to download image: {response.status}')
# 读取图片数据
- image_data = await response.read()
+ image_data = await httpclient.read_limited(response)
# 获取图片格式
content_type = response.headers.get('Content-Type', '')
image_format = content_type.split('/')[-1] # 例如 'image/jpeg' -> 'jpeg'
- # 转换为 base64
- import base64
-
- image_base64 = base64.b64encode(image_data).decode('utf-8')
+ image_base64 = await encode_base64(image_data)
return image_base64, image_format
@@ -132,11 +166,11 @@ async def get_qq_official_image_base64(pic_url: str, content_type: str) -> tuple
下载QQ官方图片,
并且转换为base64格式
"""
- async with httpx.AsyncClient() as client:
- response = await client.get(pic_url)
- response.raise_for_status() # 确保请求成功
- image_data = response.content
- base64_data = base64.b64encode(image_data).decode('utf-8')
+ session = httpclient.get_session()
+ async with session.get(pic_url) as response:
+ response.raise_for_status()
+ image_data = await httpclient.read_limited(response)
+ base64_data = await encode_base64(image_data)
return f'data:{content_type};base64,{base64_data}'
@@ -153,19 +187,20 @@ async def get_qq_image_bytes(image_url: str, query: dict = {}) -> tuple[bytes, s
"""[弃用]获取QQ图片的bytes"""
image_url, query_in_url = get_qq_image_downloadable_url(image_url)
query = {**query, **query_in_url}
- ssl_context = ssl.create_default_context()
- ssl_context.check_hostname = False
- ssl_context.verify_mode = ssl.CERT_NONE
session = httpclient.get_session()
- async with session.get(image_url, params=query, ssl=ssl_context, timeout=aiohttp.ClientTimeout(total=30.0)) as resp:
+ async with session.get(
+ image_url,
+ params=query,
+ ssl=_INSECURE_SSL_CONTEXT,
+ timeout=aiohttp.ClientTimeout(total=30.0),
+ ) as resp:
resp.raise_for_status()
- file_bytes = await resp.read()
+ file_bytes = await httpclient.read_limited(resp)
content_type = resp.headers.get('Content-Type')
if not content_type:
image_format = 'jpeg'
elif not content_type.startswith('image/'):
- pil_img = PIL.Image.open(io.BytesIO(file_bytes))
- image_format = pil_img.format.lower()
+ image_format = await asyncio.to_thread(_detect_image_format, file_bytes)
else:
image_format = content_type.split('/')[-1]
return file_bytes, image_format
@@ -187,7 +222,7 @@ async def qq_image_url_to_base64(image_url: str) -> typing.Tuple[str, str]:
file_bytes, image_format = await get_qq_image_bytes(image_url, query)
- base64_str = base64.b64encode(file_bytes).decode()
+ base64_str = await encode_base64(file_bytes)
return base64_str, image_format
@@ -209,8 +244,8 @@ async def get_slack_image_to_base64(pic_url: str, bot_token: str):
session = httpclient.get_session()
async with session.get(pic_url, headers=headers) as resp:
mime_type = resp.headers.get('Content-Type', 'application/octet-stream')
- file_bytes = await resp.read()
- base64_str = base64.b64encode(file_bytes).decode('utf-8')
+ file_bytes = await httpclient.read_limited(resp)
+ base64_str = await encode_base64(file_bytes)
return f'data:{mime_type};base64,{base64_str}'
except Exception as e:
raise (e)
diff --git a/src/langbot/pkg/utils/logcache.py b/src/langbot/pkg/utils/logcache.py
index 84c58f557..99c6c76a8 100644
--- a/src/langbot/pkg/utils/logcache.py
+++ b/src/langbot/pkg/utils/logcache.py
@@ -3,6 +3,7 @@ from __future__ import annotations
LOG_PAGE_SIZE = 20
MAX_CACHED_PAGES = 10
+MAX_LOG_LINE_CHARS = 20000
class LogPage:
@@ -40,6 +41,10 @@ class LogCache:
def add_log(self, log: str):
"""添加日志"""
+ log = str(log)
+ if len(log) > MAX_LOG_LINE_CHARS:
+ marker = '\n[log truncated]'
+ log = log[: MAX_LOG_LINE_CHARS - len(marker)] + marker
if self.log_pages[-1].add_log(log):
self.log_pages.append(LogPage(number=self.log_pages[-1].number + 1))
diff --git a/src/langbot/pkg/utils/managed_runtime.py b/src/langbot/pkg/utils/managed_runtime.py
index 374529874..4b83a6da0 100644
--- a/src/langbot/pkg/utils/managed_runtime.py
+++ b/src/langbot/pkg/utils/managed_runtime.py
@@ -31,7 +31,11 @@ class ManagedRuntimeConnector:
self._lifecycle_lock = asyncio.Lock()
self._closing = False
- async def _start_runtime_subprocess(self, *args: str) -> None:
+ async def _start_runtime_subprocess(
+ self,
+ *args: str,
+ env_overrides: dict[str, str] | None = None,
+ ) -> None:
"""Launch a local runtime as a subprocess of the current Python interpreter.
If a subprocess is already running (no *returncode* yet), this is a no-op.
@@ -41,6 +45,8 @@ class ManagedRuntimeConnector:
python_path = sys.executable
env = os.environ.copy()
+ if env_overrides:
+ env.update(env_overrides)
self.runtime_subprocess = await asyncio.create_subprocess_exec(
python_path,
*args,
diff --git a/src/langbot/pkg/utils/safe_regex.py b/src/langbot/pkg/utils/safe_regex.py
new file mode 100644
index 000000000..32ae27843
--- /dev/null
+++ b/src/langbot/pkg/utils/safe_regex.py
@@ -0,0 +1,177 @@
+from __future__ import annotations
+
+import asyncio
+import time
+from collections.abc import Sequence
+
+import regex
+
+
+MAX_PATTERN_COUNT = 64
+MAX_PATTERN_CHARS = 1024
+MAX_INPUT_CHARS = 1024 * 1024
+MAX_REPLACEMENT_CHARS = 64
+MAX_MASKED_OUTPUT_CHARS = 2 * 1024 * 1024
+DEFAULT_OPERATION_TIMEOUT_SECONDS = 0.05
+
+
+class SafeRegexError(ValueError):
+ """Base class for rejected, invalid, or timed-out tenant regex work."""
+
+
+class SafeRegexLimitError(SafeRegexError):
+ """Raised when a regex operation exceeds a deterministic resource limit."""
+
+
+class SafeRegexTimeoutError(SafeRegexError):
+ """Raised when the regex engine exhausts the operation CPU budget."""
+
+
+def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
+ normalized = tuple(patterns)
+ if len(normalized) > MAX_PATTERN_COUNT:
+ raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
+ for pattern in normalized:
+ if not isinstance(pattern, str):
+ raise SafeRegexError('Regex patterns must be strings')
+ if len(pattern) > MAX_PATTERN_CHARS:
+ raise SafeRegexLimitError(f'Regex patterns may contain at most {MAX_PATTERN_CHARS} characters')
+ return normalized
+
+
+def _validate_input(value: str) -> None:
+ if not isinstance(value, str):
+ raise SafeRegexError('Regex input must be a string')
+ if len(value) > MAX_INPUT_CHARS:
+ raise SafeRegexLimitError(f'Regex input may contain at most {MAX_INPUT_CHARS} characters')
+
+
+def _remaining_seconds(deadline: float) -> float:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise SafeRegexTimeoutError('Regex operation timed out')
+ return remaining
+
+
+def _compile(pattern: str):
+ try:
+ return regex.compile(pattern)
+ except regex.error as exc:
+ raise SafeRegexError(f'Invalid regex: {exc}') from exc
+
+
+def _matches_any_sync(
+ patterns: Sequence[str],
+ value: str,
+ *,
+ mode: str,
+ timeout_seconds: float,
+) -> bool:
+ normalized_patterns = _validate_patterns(patterns)
+ _validate_input(value)
+ if mode not in {'match', 'search'}:
+ raise ValueError(f'Unsupported safe regex mode: {mode}')
+
+ deadline = time.monotonic() + timeout_seconds
+ try:
+ for pattern in normalized_patterns:
+ compiled = _compile(pattern)
+ matcher = compiled.match if mode == 'match' else compiled.search
+ if matcher(
+ value,
+ timeout=_remaining_seconds(deadline),
+ concurrent=True,
+ ):
+ return True
+ except TimeoutError as exc:
+ raise SafeRegexTimeoutError('Regex operation timed out') from exc
+ return False
+
+
+async def matches_any(
+ patterns: Sequence[str],
+ value: str,
+ *,
+ mode: str = 'search',
+ timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
+) -> bool:
+ """Match untrusted patterns without blocking the shared event loop."""
+
+ if timeout_seconds <= 0:
+ raise ValueError('timeout_seconds must be positive')
+ return await asyncio.to_thread(
+ _matches_any_sync,
+ patterns,
+ value,
+ mode=mode,
+ timeout_seconds=timeout_seconds,
+ )
+
+
+def _mask_patterns_sync(
+ patterns: Sequence[str],
+ value: str,
+ *,
+ mask: str,
+ mask_word: str,
+ timeout_seconds: float,
+) -> tuple[bool, str]:
+ normalized_patterns = _validate_patterns(patterns)
+ _validate_input(value)
+ if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
+ raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
+
+ # Reject amplification before invoking a replacement callback. This is
+ # deliberately conservative: a hostile replacement must not allocate tens
+ # of megabytes before the post-operation output check can run.
+ replacement_width = len(mask_word) if mask_word else len(mask)
+ if replacement_width * max(1, len(value)) > MAX_MASKED_OUTPUT_CHARS:
+ raise SafeRegexLimitError('Regex replacement could exceed the masked output limit')
+
+ deadline = time.monotonic() + timeout_seconds
+ found = False
+ current = value
+
+ def replace(match) -> str:
+ nonlocal found
+ found = True
+ if mask_word:
+ return mask_word
+ return mask * len(match.group(0))
+
+ try:
+ for pattern in normalized_patterns:
+ compiled = _compile(pattern)
+ current = compiled.sub(
+ replace,
+ current,
+ timeout=_remaining_seconds(deadline),
+ concurrent=True,
+ )
+ if len(current) > MAX_MASKED_OUTPUT_CHARS:
+ raise SafeRegexLimitError('Regex replacement exceeded the masked output limit')
+ except TimeoutError as exc:
+ raise SafeRegexTimeoutError('Regex operation timed out') from exc
+ return found, current
+
+
+async def mask_patterns(
+ patterns: Sequence[str],
+ value: str,
+ *,
+ mask: str,
+ mask_word: str,
+ timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
+) -> tuple[bool, str]:
+ """Apply untrusted masking patterns with bounded CPU and output growth."""
+
+ if timeout_seconds <= 0:
+ raise ValueError('timeout_seconds must be positive')
+ return await asyncio.to_thread(
+ _mask_patterns_sync,
+ patterns,
+ value,
+ mask=mask,
+ mask_word=mask_word,
+ timeout_seconds=timeout_seconds,
+ )
diff --git a/src/langbot/pkg/utils/version.py b/src/langbot/pkg/utils/version.py
index 1e19420db..0ee49de14 100644
--- a/src/langbot/pkg/utils/version.py
+++ b/src/langbot/pkg/utils/version.py
@@ -1,12 +1,13 @@
from __future__ import annotations
+import asyncio
import typing
import logging
import requests
from ..core import app
-from . import constants
+from . import constants, httpclient
class VersionManager:
@@ -26,13 +27,14 @@ class VersionManager:
async def get_release_list(self) -> list:
"""Fetch release list from Space API (cached GitHub releases)."""
try:
- rls_list_resp = requests.get(
- url='https://space.langbot.app/api/v1/dist/info/releases',
+ rls_list_resp = await asyncio.to_thread(
+ requests.get,
+ 'https://space.langbot.app/api/v1/dist/info/releases',
proxies=self.ap.proxy_mgr.get_forward_proxies(),
timeout=10,
)
rls_list_resp.raise_for_status()
- resp_json = rls_list_resp.json()
+ resp_json = await httpclient.parse_json_response(rls_list_resp)
if resp_json.get('code') == 0 and isinstance(resp_json.get('data'), list):
return resp_json['data']
self.ap.logger.warning(f'Failed to fetch release list: unexpected response: {resp_json.get("msg", "")}')
diff --git a/src/langbot/pkg/vector/mgr.py b/src/langbot/pkg/vector/mgr.py
index 765c259f9..7a748d325 100644
--- a/src/langbot/pkg/vector/mgr.py
+++ b/src/langbot/pkg/vector/mgr.py
@@ -1,6 +1,15 @@
from __future__ import annotations
+import uuid
+
+import sqlalchemy
+
+from ..api.http.authz import WorkspaceRequiredError
+from ..api.http.context import ExecutionContext
from ..core import app
+from ..entity.persistence import rag as persistence_rag
+from ..entity.persistence import workspace as persistence_workspace
+from ..workspace.errors import WorkspaceNotFoundError
from .vdb import VectorDatabase, SearchType
@@ -55,9 +64,26 @@ class VectorDBManager:
# Get pgvector configuration
pgvector_config = kb_config.get('pgvector', {})
+ use_business_database = pgvector_config.get('use_business_database', False)
+ allowed_dimensions = pgvector_config.get(
+ 'allowed_dimensions',
+ [384, 512, 768, 1024, 1536],
+ )
+ common_options = {
+ 'use_business_database': use_business_database,
+ 'allowed_dimensions': allowed_dimensions,
+ }
+ if use_business_database:
+ self.vector_db = PgVectorDatabase(self.ap, **common_options)
+ self.ap.logger.info('Initialized pgvector on the shared business PostgreSQL database.')
+ return
connection_string = pgvector_config.get('connection_string')
if connection_string:
- self.vector_db = PgVectorDatabase(self.ap, connection_string=connection_string)
+ self.vector_db = PgVectorDatabase(
+ self.ap,
+ connection_string=connection_string,
+ **common_options,
+ )
else:
# Use individual parameters
host = pgvector_config.get('host', 'localhost')
@@ -66,7 +92,13 @@ class VectorDBManager:
user = pgvector_config.get('user', 'postgres')
password = pgvector_config.get('password', 'postgres')
self.vector_db = PgVectorDatabase(
- self.ap, host=host, port=port, database=database, user=user, password=password
+ self.ap,
+ host=host,
+ port=port,
+ database=database,
+ user=user,
+ password=password,
+ **common_options,
)
self.ap.logger.info('Initialized pgvector database backend.')
@@ -81,32 +113,227 @@ class VectorDBManager:
self.vector_db = ChromaVectorDatabase(self.ap)
self.ap.logger.warning('No vector database backend configured, defaulting to Chroma.')
+ async def shutdown(self) -> None:
+ """Release the active vector backend deterministically."""
+
+ vector_db = self.vector_db
+ self.vector_db = None
+ if vector_db is not None:
+ await vector_db.close()
+
def get_supported_search_types(self) -> list[str]:
"""Return the search types supported by the current VDB backend."""
if self.vector_db is None:
return [SearchType.VECTOR.value]
return [st.value for st in self.vector_db.supported_search_types()]
+ @staticmethod
+ def physical_collection_name(
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
+ ) -> str:
+ """Derive an opaque physical collection from trusted tenant identity.
+
+ Vector backends have different collection-name constraints, so the
+ instance, Workspace and knowledge-base identifiers are encoded through
+ UUIDv5 instead of being concatenated into a client-visible handle.
+ Placement generation is deliberately not part of the name: generation
+ fencing rejects stale work while preserving data across placements.
+ """
+
+ if not isinstance(execution_context, ExecutionContext):
+ raise WorkspaceRequiredError('ExecutionContext is required for vector access')
+ instance_uuid = execution_context.instance_uuid.strip()
+ workspace_uuid = execution_context.workspace_uuid.strip()
+ kb_uuid = knowledge_base_uuid.strip() if isinstance(knowledge_base_uuid, str) else ''
+ if not instance_uuid or not workspace_uuid or not kb_uuid:
+ raise WorkspaceRequiredError('Instance, Workspace and knowledge-base context are required')
+ if execution_context.placement_generation <= 0:
+ raise WorkspaceRequiredError('A positive placement generation is required')
+
+ collection_uuid = uuid.uuid5(
+ uuid.NAMESPACE_URL,
+ f'langbot:knowledge-vector:{instance_uuid}:{workspace_uuid}:{kb_uuid}',
+ )
+ return f'lb_{collection_uuid.hex}'
+
+ async def _validate_execution_context(self, execution_context: ExecutionContext) -> None:
+ """Validate the active placement before touching a vector backend."""
+
+ # Also performs structural validation before accessing app services.
+ self.physical_collection_name(execution_context, 'context-validation')
+ workspace_service = getattr(self.ap, 'workspace_service', None)
+ if workspace_service is None:
+ raise WorkspaceRequiredError('Workspace execution service is unavailable')
+ binding = await workspace_service.get_execution_binding(
+ execution_context.workspace_uuid,
+ expected_generation=execution_context.placement_generation,
+ )
+ if binding.instance_uuid != execution_context.instance_uuid:
+ raise WorkspaceRequiredError('ExecutionContext belongs to another LangBot instance')
+
+ async def _resolve_physical_collection_name(
+ self,
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
+ ) -> str:
+ """Resolve a scoped collection or an explicitly migrated OSS handle.
+
+ Legacy handles are server-owned migration state, not caller input.
+ They are honored only for the one local Workspace under the OSS
+ single-Workspace policy. A projected/cloud Workspace always gets the
+ opaque tenant-derived collection, even if its database row was
+ incorrectly marked as legacy.
+ """
+
+ await self._validate_execution_context(execution_context)
+ async with self.ap.persistence_mgr.tenant_uow(execution_context.workspace_uuid):
+ result = await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.select(
+ persistence_rag.KnowledgeBase.collection_id,
+ persistence_rag.KnowledgeBase.legacy_vector_collection,
+ persistence_workspace.Workspace.source,
+ )
+ .join(
+ persistence_workspace.Workspace,
+ persistence_workspace.Workspace.uuid == persistence_rag.KnowledgeBase.workspace_uuid,
+ )
+ .where(
+ persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid,
+ persistence_rag.KnowledgeBase.uuid == knowledge_base_uuid,
+ persistence_workspace.Workspace.instance_uuid == execution_context.instance_uuid,
+ )
+ .limit(1)
+ )
+ row = result.first()
+ if row is None:
+ raise WorkspaceNotFoundError('Knowledge base not found')
+
+ collection_id, legacy_vector_collection, workspace_source = row
+ if legacy_vector_collection:
+ policy = getattr(self.ap, 'workspace_policy', None)
+ is_single_workspace = policy is not None and not getattr(
+ policy,
+ 'multi_workspace_enabled',
+ True,
+ )
+ is_local_workspace = workspace_source == persistence_workspace.WorkspaceSource.LOCAL.value
+ if is_single_workspace and is_local_workspace and isinstance(collection_id, str) and collection_id.strip():
+ return collection_id
+ self.ap.logger.warning(
+ 'Ignored a legacy vector collection marker outside the local single-Workspace compatibility boundary.'
+ )
+
+ return self.physical_collection_name(execution_context, knowledge_base_uuid)
+
+ def _pgvector_database(self):
+ from .vdbs.pgvector_db import PgVectorDatabase
+
+ return self.vector_db if isinstance(self.vector_db, PgVectorDatabase) else None
+
+ async def _resolve_pgvector_scope(
+ self,
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
+ *,
+ expected_dimension: int | None,
+ initialize_dimension: bool,
+ ):
+ """Bind and verify the server-owned knowledge-base vector dimension."""
+
+ from .vdbs.pgvector_db import PgVectorScope
+
+ pgvector = self._pgvector_database()
+ if pgvector is None: # pragma: no cover - private call invariant
+ raise RuntimeError('pgvector scope requested for another vector backend')
+ if expected_dimension is not None and expected_dimension not in pgvector.allowed_dimensions:
+ raise ValueError(f'Embedding dimension {expected_dimension} is not enabled for this deployment')
+
+ async with self.ap.persistence_mgr.tenant_uow(execution_context.workspace_uuid):
+ query = sqlalchemy.select(persistence_rag.KnowledgeBase.embedding_dimension).where(
+ persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid,
+ persistence_rag.KnowledgeBase.uuid == knowledge_base_uuid,
+ )
+ current_dimension = (await self.ap.persistence_mgr.execute_async(query)).scalar_one_or_none()
+ if current_dimension is None and expected_dimension is not None and initialize_dimension:
+ await self.ap.persistence_mgr.execute_async(
+ sqlalchemy.update(persistence_rag.KnowledgeBase)
+ .where(
+ persistence_rag.KnowledgeBase.workspace_uuid == execution_context.workspace_uuid,
+ persistence_rag.KnowledgeBase.uuid == knowledge_base_uuid,
+ persistence_rag.KnowledgeBase.embedding_dimension.is_(None),
+ )
+ .values(embedding_dimension=expected_dimension)
+ )
+ current_dimension = (await self.ap.persistence_mgr.execute_async(query)).scalar_one_or_none()
+
+ if expected_dimension is not None and current_dimension != expected_dimension:
+ if current_dimension is None:
+ raise ValueError('Knowledge base has no selected pgvector embedding dimension')
+ raise ValueError(f'Knowledge base embedding dimension is {current_dimension}, not {expected_dimension}')
+
+ return PgVectorScope(
+ workspace_uuid=execution_context.workspace_uuid,
+ knowledge_base_uuid=knowledge_base_uuid,
+ embedding_dimension=current_dimension,
+ )
+
async def upsert(
self,
- collection_name: str,
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
vectors: list[list[float]],
ids: list[str],
metadata: list[dict] | None = None,
documents: list[str] | None = None,
):
- """Proxy: Upsert vectors"""
+ """Upsert vectors into a server-derived tenant collection."""
+
+ collection_name = await self._resolve_physical_collection_name(
+ execution_context,
+ knowledge_base_uuid,
+ )
+ source_metadata = metadata or [{} for _ in vectors]
+ scoped_metadata = [
+ {
+ **item,
+ '_langbot_instance_uuid': execution_context.instance_uuid,
+ '_langbot_workspace_uuid': execution_context.workspace_uuid,
+ '_langbot_knowledge_base_uuid': knowledge_base_uuid,
+ }
+ for item in source_metadata
+ ]
+ pgvector = self._pgvector_database()
+ if pgvector is not None:
+ if not vectors:
+ return
+ scope = await self._resolve_pgvector_scope(
+ execution_context,
+ knowledge_base_uuid,
+ expected_dimension=len(vectors[0]),
+ initialize_dimension=True,
+ )
+ await pgvector.add_embeddings(
+ collection=collection_name,
+ ids=ids,
+ embeddings_list=vectors,
+ metadatas=scoped_metadata,
+ documents=documents,
+ scope=scope,
+ )
+ return
await self.vector_db.add_embeddings(
collection=collection_name,
ids=ids,
embeddings_list=vectors,
- metadatas=metadata or [{} for _ in vectors],
+ metadatas=scoped_metadata,
documents=documents,
)
async def search(
self,
- collection_name: str,
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
query_vector: list[float],
limit: int,
filter: dict | None = None,
@@ -120,15 +347,38 @@ class VectorDBManager:
The underlying VectorDatabase.search returns Chroma-style format:
{ 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }
"""
- results = await self.vector_db.search(
- collection=collection_name,
- query_embedding=query_vector,
- k=limit,
- search_type=search_type,
- query_text=query_text,
- filter=filter,
- vector_weight=vector_weight,
+ collection_name = await self._resolve_physical_collection_name(
+ execution_context,
+ knowledge_base_uuid,
)
+ pgvector = self._pgvector_database()
+ if pgvector is not None:
+ scope = await self._resolve_pgvector_scope(
+ execution_context,
+ knowledge_base_uuid,
+ expected_dimension=len(query_vector),
+ initialize_dimension=False,
+ )
+ results = await pgvector.search(
+ collection=collection_name,
+ query_embedding=query_vector,
+ k=limit,
+ search_type=search_type,
+ query_text=query_text,
+ filter=filter,
+ vector_weight=vector_weight,
+ scope=scope,
+ )
+ else:
+ results = await self.vector_db.search(
+ collection=collection_name,
+ query_embedding=query_vector,
+ k=limit,
+ search_type=search_type,
+ query_text=query_text,
+ filter=filter,
+ vector_weight=vector_weight,
+ )
if not results or 'ids' not in results or not results['ids']:
return []
@@ -154,30 +404,89 @@ class VectorDBManager:
return parsed_results
- async def delete_by_file_id(self, collection_name: str, file_ids: list[str]):
+ async def delete_by_file_id(
+ self,
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
+ file_ids: list[str],
+ ):
"""Proxy: Delete vectors by file_id (metadata-level identifier).
This delegates to VectorDatabase.delete_by_file_id which removes
all vectors associated with the given file IDs.
"""
+ collection_name = await self._resolve_physical_collection_name(
+ execution_context,
+ knowledge_base_uuid,
+ )
+ pgvector = self._pgvector_database()
+ scope = None
+ if pgvector is not None:
+ scope = await self._resolve_pgvector_scope(
+ execution_context,
+ knowledge_base_uuid,
+ expected_dimension=None,
+ initialize_dimension=False,
+ )
for file_id in file_ids:
- await self.vector_db.delete_by_file_id(collection_name, file_id)
+ if pgvector is not None:
+ await pgvector.delete_by_file_id(collection_name, file_id, scope=scope)
+ else:
+ await self.vector_db.delete_by_file_id(collection_name, file_id)
- async def delete_collection(self, collection_name: str):
- """Proxy: Delete an entire collection."""
- await self.vector_db.delete_collection(collection_name)
+ async def delete_collection(
+ self,
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
+ ):
+ """Delete one server-derived tenant collection."""
- async def delete_by_filter(self, collection_name: str, filter: dict) -> int:
+ collection_name = await self._resolve_physical_collection_name(
+ execution_context,
+ knowledge_base_uuid,
+ )
+ pgvector = self._pgvector_database()
+ if pgvector is not None:
+ scope = await self._resolve_pgvector_scope(
+ execution_context,
+ knowledge_base_uuid,
+ expected_dimension=None,
+ initialize_dimension=False,
+ )
+ await pgvector.delete_collection(collection_name, scope=scope)
+ else:
+ await self.vector_db.delete_collection(collection_name)
+
+ async def delete_by_filter(
+ self,
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
+ filter: dict,
+ ) -> int:
"""Proxy: Delete vectors by metadata filter.
Returns:
Number of deleted vectors (best-effort; some backends return 0).
"""
+ collection_name = await self._resolve_physical_collection_name(
+ execution_context,
+ knowledge_base_uuid,
+ )
+ pgvector = self._pgvector_database()
+ if pgvector is not None:
+ scope = await self._resolve_pgvector_scope(
+ execution_context,
+ knowledge_base_uuid,
+ expected_dimension=None,
+ initialize_dimension=False,
+ )
+ return await pgvector.delete_by_filter(collection_name, filter, scope=scope)
return await self.vector_db.delete_by_filter(collection_name, filter)
async def list_by_filter(
self,
- collection_name: str,
+ execution_context: ExecutionContext,
+ knowledge_base_uuid: str,
filter: dict | None = None,
limit: int = 20,
offset: int = 0,
@@ -187,4 +496,17 @@ class VectorDBManager:
Returns:
Tuple of (items, total).
"""
+ collection_name = await self._resolve_physical_collection_name(
+ execution_context,
+ knowledge_base_uuid,
+ )
+ pgvector = self._pgvector_database()
+ if pgvector is not None:
+ scope = await self._resolve_pgvector_scope(
+ execution_context,
+ knowledge_base_uuid,
+ expected_dimension=None,
+ initialize_dimension=False,
+ )
+ return await pgvector.list_by_filter(collection_name, filter, limit, offset, scope=scope)
return await self.vector_db.list_by_filter(collection_name, filter, limit, offset)
diff --git a/src/langbot/pkg/vector/vdb.py b/src/langbot/pkg/vector/vdb.py
index df9bfacbf..67bc011f6 100644
--- a/src/langbot/pkg/vector/vdb.py
+++ b/src/langbot/pkg/vector/vdb.py
@@ -1,9 +1,37 @@
from __future__ import annotations
import abc
import enum
-from typing import Any, Dict
+from typing import Any, Dict, MutableMapping, MutableSet, TypeVar
import numpy as np
+_CacheValue = TypeVar('_CacheValue')
+
+
+def runtime_cache_limit(ap: Any, default: int = 1024) -> int:
+ try:
+ value = int(ap.instance_config.data.get('vdb', {}).get('runtime_cache_limit', default))
+ except (AttributeError, TypeError, ValueError):
+ value = default
+ return max(value, 1)
+
+
+def remember_bounded_mapping(
+ cache: MutableMapping[str, _CacheValue],
+ key: str,
+ value: _CacheValue,
+ limit: int,
+) -> None:
+ cache.pop(key, None)
+ cache[key] = value
+ while len(cache) > limit:
+ cache.pop(next(iter(cache)))
+
+
+def remember_bounded_set(cache: MutableSet[str], key: str, limit: int) -> None:
+ cache.add(key)
+ while len(cache) > limit:
+ cache.pop()
+
class SearchType(str, enum.Enum):
"""Supported search types for vector databases."""
@@ -14,6 +42,9 @@ class SearchType(str, enum.Enum):
class VectorDatabase(abc.ABC):
+ async def close(self) -> None:
+ """Release backend clients and in-process caches."""
+
@classmethod
def supported_search_types(cls) -> list[SearchType]:
"""Return the search types supported by this VDB backend.
diff --git a/src/langbot/pkg/vector/vdbs/chroma.py b/src/langbot/pkg/vector/vdbs/chroma.py
index b4d8af19c..6965443a5 100644
--- a/src/langbot/pkg/vector/vdbs/chroma.py
+++ b/src/langbot/pkg/vector/vdbs/chroma.py
@@ -2,7 +2,12 @@ from __future__ import annotations
import asyncio
from typing import Any
from chromadb import PersistentClient
-from langbot.pkg.vector.vdb import VectorDatabase, SearchType
+from langbot.pkg.vector.vdb import (
+ VectorDatabase,
+ SearchType,
+ remember_bounded_mapping,
+ runtime_cache_limit,
+)
from langbot.pkg.core import app
import chromadb
import chromadb.errors
@@ -16,6 +21,13 @@ class ChromaVectorDatabase(VectorDatabase):
self.ap = ap
self.client = PersistentClient(path=base_path)
self._collections = {}
+ self._runtime_cache_limit = runtime_cache_limit(ap)
+
+ async def close(self) -> None:
+ # Chroma's PersistentClient has no public close API. Collection
+ # wrappers are safe to discard and otherwise retain every collection
+ # touched during the lifetime of this application object.
+ self._collections.clear()
@classmethod
def supported_search_types(cls) -> list[SearchType]:
@@ -23,8 +35,12 @@ class ChromaVectorDatabase(VectorDatabase):
async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
if collection not in self._collections:
- self._collections[collection] = await asyncio.to_thread(
- self.client.get_or_create_collection, name=collection
+ runtime_collection = await asyncio.to_thread(self.client.get_or_create_collection, name=collection)
+ remember_bounded_mapping(
+ self._collections,
+ collection,
+ runtime_collection,
+ self._runtime_cache_limit,
)
self.ap.logger.info(f"Chroma collection '{collection}' accessed/created.")
return self._collections[collection]
diff --git a/src/langbot/pkg/vector/vdbs/milvus.py b/src/langbot/pkg/vector/vdbs/milvus.py
index 1b01b1101..ac6012451 100644
--- a/src/langbot/pkg/vector/vdbs/milvus.py
+++ b/src/langbot/pkg/vector/vdbs/milvus.py
@@ -3,9 +3,10 @@ import asyncio
from typing import Any, Dict
from pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema
from pymilvus.milvus_client.index import IndexParams
-from langbot.pkg.vector.vdb import VectorDatabase
+from langbot.pkg.vector.vdb import VectorDatabase, remember_bounded_set, runtime_cache_limit
from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields
from langbot.pkg.core import app
+from langbot.pkg.utils import bounded_executor
# Milvus schema only stores these metadata fields; filter on other fields is
# silently dropped with a warning.
@@ -71,8 +72,15 @@ class MilvusVectorDatabase(VectorDatabase):
self.db_name = db_name
self.client = None
self._collections: set[str] = set()
+ self._runtime_cache_limit = runtime_cache_limit(ap)
self._initialize_client()
+ async def close(self) -> None:
+ self._collections.clear()
+ if self.client is not None:
+ await bounded_executor.run_blocking_cleanup(self.client.close)
+ self.client = None
+
def _initialize_client(self):
"""Initialize Milvus client connection"""
try:
@@ -169,7 +177,7 @@ class MilvusVectorDatabase(VectorDatabase):
await self._ensure_index_if_missing(collection)
self.ap.logger.info(f"Milvus collection '{collection}' already exists")
- self._collections.add(collection)
+ remember_bounded_set(self._collections, collection, self._runtime_cache_limit)
return collection
async def _ensure_index_if_missing(self, collection: str) -> None:
diff --git a/src/langbot/pkg/vector/vdbs/pgvector_db.py b/src/langbot/pkg/vector/vdbs/pgvector_db.py
index 34879295f..341a99a96 100644
--- a/src/langbot/pkg/vector/vdbs/pgvector_db.py
+++ b/src/langbot/pkg/vector/vdbs/pgvector_db.py
@@ -1,22 +1,31 @@
from __future__ import annotations
-from typing import Any, Dict
-from sqlalchemy import create_engine, text, Column, String, Text
-from sqlalchemy.orm import declarative_base
-from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
+
+import contextlib
+import dataclasses
+from collections.abc import AsyncIterator
+from typing import Any
+
+import sqlalchemy
from pgvector.sqlalchemy import Vector
-from langbot.pkg.vector.vdb import VectorDatabase
-from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields
+from sqlalchemy.dialects.postgresql import insert as postgresql_insert
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.orm import declarative_base
+
from langbot.pkg.core import app
+from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields
+from langbot.pkg.vector.vdb import VectorDatabase
+
Base = declarative_base()
+DEFAULT_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
+
# pgvector schema only stores these metadata fields.
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
# Callers use canonical metadata key 'uuid' but pgvector stores it as 'chunk_uuid'.
_PG_FIELD_ALIASES = {'uuid': 'chunk_uuid'}
-# Map schema field names to SQLAlchemy columns (resolved lazily from PgVectorEntry).
_PG_COLUMN_MAP = {
'text': 'text',
'file_id': 'file_id',
@@ -24,21 +33,50 @@ _PG_COLUMN_MAP = {
}
+@dataclasses.dataclass(frozen=True, slots=True)
+class PgVectorScope:
+ """Trusted relational tenant key for one knowledge-base operation."""
+
+ workspace_uuid: str
+ knowledge_base_uuid: str
+ embedding_dimension: int | None = None
+
+ def __post_init__(self) -> None:
+ for field_name in ('workspace_uuid', 'knowledge_base_uuid'):
+ value = getattr(self, field_name)
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f'{field_name} must not be empty')
+ object.__setattr__(self, field_name, value.strip())
+ dimension = self.embedding_dimension
+ if dimension is not None and (isinstance(dimension, bool) or not isinstance(dimension, int) or dimension <= 0):
+ raise ValueError('embedding_dimension must be a positive integer')
+
+
class PgVectorEntry(Base):
- """SQLAlchemy model for pgvector entries"""
+ """Tenant-scoped pgvector row created only by release/OSS migrations."""
__tablename__ = 'langbot_vectors'
- id = Column(String, primary_key=True)
- collection = Column(String, index=True, nullable=False)
- embedding = Column(Vector(1536)) # Default dimension, will be created dynamically
- text = Column(Text)
- file_id = Column(String, index=True)
- chunk_uuid = Column(String)
+ workspace_uuid = sqlalchemy.Column(sqlalchemy.String(36), primary_key=True)
+ knowledge_base_uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ vector_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
+ embedding_dimension = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
+ embedding = sqlalchemy.Column(Vector(), nullable=False)
+ text = sqlalchemy.Column(sqlalchemy.Text)
+ file_id = sqlalchemy.Column(sqlalchemy.String(255), index=True)
+ chunk_uuid = sqlalchemy.Column(sqlalchemy.String(255))
+
+ __table_args__ = (
+ sqlalchemy.CheckConstraint(
+ 'vector_dims(embedding) = embedding_dimension',
+ name='ck_langbot_vectors_embedding_dimension',
+ ),
+ )
def _build_pg_conditions(filter_dict: dict[str, Any]) -> list:
- """Translate canonical filter dict into a list of SQLAlchemy conditions."""
+ """Translate canonical filter dict into SQLAlchemy conditions."""
+
triples = normalize_filter(filter_dict)
triples = strip_unsupported_fields(triples, _PG_SUPPORTED_FIELDS, _PG_FIELD_ALIASES)
@@ -65,83 +103,139 @@ def _build_pg_conditions(filter_dict: dict[str, Any]) -> list:
class PgVectorDatabase(VectorDatabase):
- """PostgreSQL with pgvector extension database implementation"""
+ """PostgreSQL vector adapter with explicit Workspace/RLS scope.
+
+ Cloud reuses the business database engine and never performs DDL. OSS can
+ still opt into a standalone pgvector database; that compatibility mode may
+ create a fresh schema, but it uses the same explicit tenant keys.
+ """
def __init__(
self,
ap: app.Application,
- connection_string: str = None,
+ connection_string: str | None = None,
host: str = 'localhost',
port: int = 5432,
database: str = 'langbot',
user: str = 'postgres',
password: str = 'postgres',
- ):
- """Initialize pgvector database
-
- Args:
- ap: Application instance
- connection_string: Full PostgreSQL connection string (overrides other params)
- host: PostgreSQL host
- port: PostgreSQL port
- database: Database name
- user: Database user
- password: Database password
- """
+ *,
+ use_business_database: bool = False,
+ allowed_dimensions: list[int] | tuple[int, ...] = DEFAULT_ALLOWED_DIMENSIONS,
+ ) -> None:
self.ap = ap
+ self.use_business_database = use_business_database
+ self.allowed_dimensions = self._normalize_allowed_dimensions(allowed_dimensions)
+ self.engine = None
+ self.async_engine = None
+ self.AsyncSessionLocal: async_sessionmaker[AsyncSession] | None = None
+
+ if use_business_database:
+ persistence_mgr = getattr(ap, 'persistence_mgr', None)
+ if persistence_mgr is None:
+ raise RuntimeError('Shared pgvector requires the initialized business persistence manager')
+ business_engine = persistence_mgr.get_db_engine()
+ if business_engine.dialect.name != 'postgresql':
+ raise RuntimeError('Shared pgvector requires the PostgreSQL business database')
+ self.async_engine = business_engine
+ self.ap.logger.info('Connected pgvector adapter to the shared PostgreSQL business database')
+ return
- # Build connection string if not provided
if connection_string:
self.connection_string = connection_string
else:
self.connection_string = f'postgresql+psycopg://{user}:{password}@{host}:{port}/{database}'
-
self.async_connection_string = self.connection_string.replace('postgresql://', 'postgresql+asyncpg://').replace(
'postgresql+psycopg://', 'postgresql+asyncpg://'
)
+ self._initialize_standalone_db()
- self.engine = None
- self.async_engine = None
- self.SessionLocal = None
- self.AsyncSessionLocal = None
- self._collections = set()
- self._initialize_db()
+ @staticmethod
+ def _normalize_allowed_dimensions(dimensions: list[int] | tuple[int, ...]) -> frozenset[int]:
+ if not isinstance(dimensions, (list, tuple)) or not dimensions:
+ raise ValueError('pgvector allowed_dimensions must be a non-empty list')
+ if any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in dimensions):
+ raise ValueError('pgvector allowed_dimensions must contain positive integers')
+ unsupported = set(dimensions) - set(DEFAULT_ALLOWED_DIMENSIONS)
+ if unsupported:
+ raise ValueError(f'pgvector dimensions do not have release-created ANN indexes: {sorted(unsupported)}')
+ return frozenset(dimensions)
- def _initialize_db(self):
- """Initialize database connection and create tables"""
- try:
- # Create async engine for async operations
- self.async_engine = create_async_engine(self.async_connection_string, echo=False, pool_pre_ping=True)
- self.AsyncSessionLocal = async_sessionmaker(self.async_engine, class_=AsyncSession, expire_on_commit=False)
+ def _initialize_standalone_db(self) -> None:
+ """Initialize the explicit OSS external database compatibility path."""
- # Create sync engine for table creation
- sync_connection_string = self.connection_string.replace('postgresql+asyncpg://', 'postgresql+psycopg://')
- self.engine = create_engine(sync_connection_string, echo=False)
+ from sqlalchemy import create_engine
- # Create pgvector extension and tables
- with self.engine.connect() as conn:
- # Enable pgvector extension
- conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
- conn.commit()
+ self.async_engine = create_async_engine(self.async_connection_string, echo=False, pool_pre_ping=True)
+ self.AsyncSessionLocal = async_sessionmaker(self.async_engine, class_=AsyncSession, expire_on_commit=False)
+ sync_connection_string = self.connection_string.replace('postgresql+asyncpg://', 'postgresql+psycopg://')
+ self.engine = create_engine(sync_connection_string, echo=False)
- # Create tables
- Base.metadata.create_all(self.engine)
+ with self.engine.begin() as conn:
+ conn.execute(sqlalchemy.text('CREATE EXTENSION IF NOT EXISTS vector'))
+ existing_tables = set(sqlalchemy.inspect(conn).get_table_names())
+ if PgVectorEntry.__tablename__ in existing_tables:
+ columns = {
+ column['name'] for column in sqlalchemy.inspect(conn).get_columns(PgVectorEntry.__tablename__)
+ }
+ required = {
+ 'workspace_uuid',
+ 'knowledge_base_uuid',
+ 'vector_id',
+ 'embedding_dimension',
+ 'embedding',
+ }
+ if not required.issubset(columns):
+ raise RuntimeError(
+ 'The external pgvector database uses the legacy unscoped schema; '
+ 'migrate it before enabling multi-tenant vector access'
+ )
+ Base.metadata.create_all(conn)
- self.ap.logger.info('Connected to PostgreSQL with pgvector')
- except Exception as e:
- self.ap.logger.error(f'Failed to connect to PostgreSQL: {e}')
- raise
+ self.ap.logger.info('Connected to standalone PostgreSQL pgvector database')
+
+ def _require_scope(self, scope: PgVectorScope | None, *, require_dimension: bool) -> PgVectorScope:
+ if not isinstance(scope, PgVectorScope):
+ raise ValueError('pgvector operations require a trusted PgVectorScope')
+ dimension = scope.embedding_dimension
+ if require_dimension and dimension is None:
+ raise ValueError('pgvector operation requires an embedding dimension')
+ if dimension is not None and dimension not in self.allowed_dimensions:
+ raise ValueError(f'Embedding dimension {dimension} is not enabled for this pgvector deployment')
+ return scope
+
+ @staticmethod
+ def _scope_conditions(scope: PgVectorScope) -> tuple[Any, Any]:
+ return (
+ PgVectorEntry.workspace_uuid == scope.workspace_uuid,
+ PgVectorEntry.knowledge_base_uuid == scope.knowledge_base_uuid,
+ )
+
+ @contextlib.asynccontextmanager
+ async def _session(self, scope: PgVectorScope) -> AsyncIterator[AsyncSession]:
+ admission = getattr(self.ap, 'deployment_admission', None)
+ if admission is not None:
+ admission.require_active()
+
+ if self.use_business_database:
+ async with self.ap.persistence_mgr.tenant_uow(scope.workspace_uuid) as uow:
+ yield uow.session
+ if admission is not None:
+ admission.require_active()
+ return
+
+ if self.AsyncSessionLocal is None: # pragma: no cover - constructor invariant
+ raise RuntimeError('Standalone pgvector session factory is unavailable')
+ async with self.AsyncSessionLocal() as session, session.begin():
+ yield session
+ if admission is not None:
+ admission.require_active()
async def get_or_create_collection(self, collection: str):
- """Get or create a collection (logical grouping in pgvector)
+ """Retain the common adapter API; relational rows need no collection DDL."""
- Args:
- collection: Collection name (knowledge base UUID)
- """
- # In pgvector, collections are logical - we just track them
- if collection not in self._collections:
- self._collections.add(collection)
- self.ap.logger.info(f"Registered pgvector collection '{collection}'")
+ if not isinstance(collection, str) or not collection.strip():
+ raise ValueError('collection must not be empty')
return collection
async def add_embeddings(
@@ -151,38 +245,59 @@ class PgVectorDatabase(VectorDatabase):
embeddings_list: list[list[float]],
metadatas: list[dict[str, Any]],
documents: list[str] | None = None,
+ *,
+ scope: PgVectorScope | None = None,
) -> None:
- """Add vector embeddings to pgvector
-
- Args:
- collection: Collection name
- ids: List of unique IDs for each vector
- embeddings_list: List of embedding vectors
- metadatas: List of metadata dictionaries
- """
+ scope = self._require_scope(scope, require_dimension=True)
await self.get_or_create_collection(collection)
+ if not ids:
+ return
+ if len(ids) != len(embeddings_list) or len(metadatas) != len(ids):
+ raise ValueError('pgvector ids, embeddings and metadata lengths must match')
+ if documents is not None and len(documents) != len(ids):
+ raise ValueError('pgvector documents length must match ids')
+ if len(set(ids)) != len(ids) or any(not isinstance(item, str) or not item.strip() for item in ids):
+ raise ValueError('pgvector vector IDs must be unique non-empty strings per upsert')
+ expected_dimension = scope.embedding_dimension
+ if any(len(embedding) != expected_dimension for embedding in embeddings_list):
+ raise ValueError(f'All embeddings must have the selected dimension {expected_dimension}')
- async with self.AsyncSessionLocal() as session:
- try:
- for i, vector_id in enumerate(ids):
- metadata = metadatas[i] if i < len(metadatas) else {}
+ values = []
+ for index, vector_id in enumerate(ids):
+ metadata = metadatas[index]
+ document = documents[index] if documents is not None else None
+ values.append(
+ {
+ 'workspace_uuid': scope.workspace_uuid,
+ 'knowledge_base_uuid': scope.knowledge_base_uuid,
+ 'vector_id': vector_id.strip(),
+ 'embedding_dimension': expected_dimension,
+ 'embedding': embeddings_list[index],
+ 'text': metadata.get('text', document or ''),
+ 'file_id': metadata.get('file_id', ''),
+ 'chunk_uuid': metadata.get('uuid', metadata.get('chunk_uuid', '')),
+ }
+ )
- entry = PgVectorEntry(
- id=vector_id,
- collection=collection,
- embedding=embeddings_list[i],
- text=metadata.get('text', ''),
- file_id=metadata.get('file_id', ''),
- chunk_uuid=metadata.get('uuid', ''),
- )
- session.add(entry)
-
- await session.commit()
- self.ap.logger.info(f"Added {len(ids)} embeddings to pgvector collection '{collection}'")
- except Exception as e:
- await session.rollback()
- self.ap.logger.error(f'Error adding embeddings to pgvector: {e}')
- raise
+ statement = postgresql_insert(PgVectorEntry).values(values)
+ excluded = statement.excluded
+ statement = statement.on_conflict_do_update(
+ index_elements=[
+ PgVectorEntry.workspace_uuid,
+ PgVectorEntry.knowledge_base_uuid,
+ PgVectorEntry.vector_id,
+ ],
+ set_={
+ 'embedding_dimension': excluded.embedding_dimension,
+ 'embedding': excluded.embedding,
+ 'text': excluded.text,
+ 'file_id': excluded.file_id,
+ 'chunk_uuid': excluded.chunk_uuid,
+ },
+ )
+ async with self._session(scope) as session:
+ await session.execute(statement)
+ self.ap.logger.info(f'Upserted {len(ids)} pgvector embeddings for knowledge base {scope.knowledge_base_uuid}')
async def search(
self,
@@ -193,125 +308,79 @@ class PgVectorDatabase(VectorDatabase):
query_text: str = '',
filter: dict[str, Any] | None = None,
vector_weight: float | None = None,
- ) -> Dict[str, Any]:
- """Search for similar vectors using cosine distance
-
- Args:
- collection: Collection name
- query_embedding: Query vector
- k: Number of top results to return
-
- Returns:
- Dictionary with search results in Chroma-compatible format
- """
+ *,
+ scope: PgVectorScope | None = None,
+ ) -> dict[str, Any]:
+ del query_text, vector_weight
+ scope = self._require_scope(scope, require_dimension=True)
await self.get_or_create_collection(collection)
+ if search_type != 'vector':
+ raise ValueError('pgvector currently supports vector search only')
+ if k <= 0:
+ raise ValueError('pgvector search limit must be positive')
+ if len(query_embedding) != scope.embedding_dimension:
+ raise ValueError(f'Query embedding must have the selected dimension {scope.embedding_dimension}')
- async with self.AsyncSessionLocal() as session:
- try:
- # Use cosine distance for similarity search
- from sqlalchemy import select
+ typed_embedding = sqlalchemy.cast(PgVectorEntry.embedding, Vector(scope.embedding_dimension))
+ distance = typed_embedding.cosine_distance(query_embedding)
+ statement = (
+ sqlalchemy.select(
+ PgVectorEntry.vector_id,
+ PgVectorEntry.text,
+ PgVectorEntry.file_id,
+ PgVectorEntry.chunk_uuid,
+ distance.label('distance'),
+ )
+ .where(*self._scope_conditions(scope), PgVectorEntry.embedding_dimension == scope.embedding_dimension)
+ .order_by(distance)
+ .limit(k)
+ )
+ for condition in _build_pg_conditions(filter or {}):
+ statement = statement.where(condition)
- # Query for similar vectors
- stmt = (
- select(
- PgVectorEntry.id,
- PgVectorEntry.text,
- PgVectorEntry.file_id,
- PgVectorEntry.chunk_uuid,
- PgVectorEntry.embedding.cosine_distance(query_embedding).label('distance'),
- )
- .filter(PgVectorEntry.collection == collection)
- .order_by(PgVectorEntry.embedding.cosine_distance(query_embedding))
- .limit(k)
- )
+ async with self._session(scope) as session:
+ rows = (await session.execute(statement)).all()
- if filter:
- for cond in _build_pg_conditions(filter):
- stmt = stmt.filter(cond)
+ ids = [row.vector_id for row in rows]
+ distances = [float(row.distance) for row in rows]
+ metadatas = [
+ {'text': row.text or '', 'file_id': row.file_id or '', 'uuid': row.chunk_uuid or ''} for row in rows
+ ]
+ return {'ids': [ids], 'distances': [distances], 'metadatas': [metadatas]}
- result = await session.execute(stmt)
- rows = result.fetchall()
-
- # Convert to Chroma-compatible format
- ids = []
- distances = []
- metadatas = []
-
- for row in rows:
- ids.append(row.id)
- distances.append(float(row.distance))
- metadatas.append(
- {'text': row.text or '', 'file_id': row.file_id or '', 'uuid': row.chunk_uuid or ''}
- )
-
- result_dict = {'ids': [ids], 'distances': [distances], 'metadatas': [metadatas]}
-
- self.ap.logger.info(f"pgvector search in '{collection}' returned {len(ids)} results")
- return result_dict
-
- except Exception as e:
- self.ap.logger.error(f'Error searching pgvector: {e}')
- raise
-
- async def delete_by_file_id(self, collection: str, file_id: str) -> None:
- """Delete vectors by file_id
-
- Args:
- collection: Collection name
- file_id: File ID to filter deletion
- """
+ async def delete_by_file_id(
+ self,
+ collection: str,
+ file_id: str,
+ *,
+ scope: PgVectorScope | None = None,
+ ) -> None:
+ scope = self._require_scope(scope, require_dimension=False)
await self.get_or_create_collection(collection)
+ statement = sqlalchemy.delete(PgVectorEntry).where(
+ *self._scope_conditions(scope),
+ PgVectorEntry.file_id == file_id,
+ )
+ async with self._session(scope) as session:
+ await session.execute(statement)
- async with self.AsyncSessionLocal() as session:
- try:
- from sqlalchemy import delete
-
- stmt = delete(PgVectorEntry).where(
- PgVectorEntry.collection == collection, PgVectorEntry.file_id == file_id
- )
- await session.execute(stmt)
- await session.commit()
-
- self.ap.logger.info(
- f"Deleted embeddings from pgvector collection '{collection}' with file_id: {file_id}"
- )
- except Exception as e:
- await session.rollback()
- self.ap.logger.error(f'Error deleting from pgvector: {e}')
- raise
-
- async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:
- """Delete vectors matching a metadata filter.
-
- Args:
- collection: Collection name
- filter: Canonical metadata filter dict
- """
+ async def delete_by_filter(
+ self,
+ collection: str,
+ filter: dict[str, Any],
+ *,
+ scope: PgVectorScope | None = None,
+ ) -> int:
+ scope = self._require_scope(scope, require_dimension=False)
+ await self.get_or_create_collection(collection)
conditions = _build_pg_conditions(filter)
if not conditions:
- self.ap.logger.warning(
- f"pgvector delete_by_filter on '{collection}': filter produced no conditions, skipping"
- )
+ self.ap.logger.warning('pgvector delete_by_filter produced no supported conditions; skipping')
return 0
-
- await self.get_or_create_collection(collection)
-
- async with self.AsyncSessionLocal() as session:
- try:
- from sqlalchemy import delete
-
- stmt = delete(PgVectorEntry).where(PgVectorEntry.collection == collection)
- for cond in conditions:
- stmt = stmt.where(cond)
- result = await session.execute(stmt)
- await session.commit()
- deleted = result.rowcount
- self.ap.logger.info(f"Deleted {deleted} embeddings from pgvector collection '{collection}' by filter")
- return deleted
- except Exception as e:
- await session.rollback()
- self.ap.logger.error(f'Error deleting from pgvector by filter: {e}')
- raise
+ statement = sqlalchemy.delete(PgVectorEntry).where(*self._scope_conditions(scope), *conditions)
+ async with self._session(scope) as session:
+ result = await session.execute(statement)
+ return int(result.rowcount or 0)
async def list_by_filter(
self,
@@ -319,85 +388,62 @@ class PgVectorDatabase(VectorDatabase):
filter: dict[str, Any] | None = None,
limit: int = 20,
offset: int = 0,
+ *,
+ scope: PgVectorScope | None = None,
) -> tuple[list[dict[str, Any]], int]:
+ scope = self._require_scope(scope, require_dimension=False)
await self.get_or_create_collection(collection)
+ if limit <= 0 or offset < 0:
+ raise ValueError('pgvector pagination requires limit > 0 and offset >= 0')
- async with self.AsyncSessionLocal() as session:
- try:
- from sqlalchemy import select, func
+ conditions = [*self._scope_conditions(scope), *_build_pg_conditions(filter or {})]
+ statement = (
+ sqlalchemy.select(
+ PgVectorEntry.vector_id,
+ PgVectorEntry.text,
+ PgVectorEntry.file_id,
+ PgVectorEntry.chunk_uuid,
+ )
+ .where(*conditions)
+ .order_by(PgVectorEntry.vector_id)
+ .offset(offset)
+ .limit(limit)
+ )
+ count_statement = sqlalchemy.select(sqlalchemy.func.count()).select_from(PgVectorEntry).where(*conditions)
+ async with self._session(scope) as session:
+ rows = (await session.execute(statement)).all()
+ total = int((await session.execute(count_statement)).scalar_one())
- stmt = (
- select(
- PgVectorEntry.id,
- PgVectorEntry.text,
- PgVectorEntry.file_id,
- PgVectorEntry.chunk_uuid,
- )
- .filter(PgVectorEntry.collection == collection)
- .offset(offset)
- .limit(limit)
- )
+ return (
+ [
+ {
+ 'id': row.vector_id,
+ 'document': row.text or '',
+ 'metadata': {
+ 'text': row.text or '',
+ 'file_id': row.file_id or '',
+ 'uuid': row.chunk_uuid or '',
+ },
+ }
+ for row in rows
+ ],
+ total,
+ )
- count_stmt = (
- select(func.count()).select_from(PgVectorEntry).filter(PgVectorEntry.collection == collection)
- )
+ async def delete_collection(
+ self,
+ collection: str,
+ *,
+ scope: PgVectorScope | None = None,
+ ) -> None:
+ scope = self._require_scope(scope, require_dimension=False)
+ await self.get_or_create_collection(collection)
+ statement = sqlalchemy.delete(PgVectorEntry).where(*self._scope_conditions(scope))
+ async with self._session(scope) as session:
+ await session.execute(statement)
- if filter:
- for cond in _build_pg_conditions(filter):
- stmt = stmt.filter(cond)
- count_stmt = count_stmt.filter(cond)
-
- result = await session.execute(stmt)
- rows = result.fetchall()
-
- count_result = await session.execute(count_stmt)
- total = count_result.scalar() or 0
-
- items = []
- for row in rows:
- items.append(
- {
- 'id': row.id,
- 'document': row.text or '',
- 'metadata': {
- 'text': row.text or '',
- 'file_id': row.file_id or '',
- 'uuid': row.chunk_uuid or '',
- },
- }
- )
-
- return items, total
- except Exception as e:
- self.ap.logger.error(f'Error listing from pgvector: {e}')
- raise
-
- async def delete_collection(self, collection: str):
- """Delete all vectors in a collection
-
- Args:
- collection: Collection name to delete
- """
- if collection in self._collections:
- self._collections.remove(collection)
-
- async with self.AsyncSessionLocal() as session:
- try:
- from sqlalchemy import delete
-
- stmt = delete(PgVectorEntry).where(PgVectorEntry.collection == collection)
- await session.execute(stmt)
- await session.commit()
-
- self.ap.logger.info(f"Deleted pgvector collection '{collection}'")
- except Exception as e:
- await session.rollback()
- self.ap.logger.error(f'Error deleting pgvector collection: {e}')
- raise
-
- async def close(self):
- """Close database connections"""
- if self.async_engine:
+ async def close(self) -> None:
+ if not self.use_business_database and self.async_engine is not None:
await self.async_engine.dispose()
- if self.engine:
+ if self.engine is not None:
self.engine.dispose()
diff --git a/src/langbot/pkg/vector/vdbs/qdrant.py b/src/langbot/pkg/vector/vdbs/qdrant.py
index 6da4c79ef..dea6a8676 100644
--- a/src/langbot/pkg/vector/vdbs/qdrant.py
+++ b/src/langbot/pkg/vector/vdbs/qdrant.py
@@ -4,7 +4,7 @@ from typing import Any, Dict, List
from qdrant_client import AsyncQdrantClient, models
from langbot.pkg.core import app
-from langbot.pkg.vector.vdb import VectorDatabase
+from langbot.pkg.vector.vdb import VectorDatabase, remember_bounded_set, runtime_cache_limit
from langbot.pkg.vector.filter_utils import normalize_filter
@@ -52,6 +52,11 @@ class QdrantVectorDatabase(VectorDatabase):
self.client = AsyncQdrantClient(host=host, port=int(port), api_key=api_key)
self._collections: set[str] = set()
+ self._runtime_cache_limit = runtime_cache_limit(ap)
+
+ async def close(self) -> None:
+ self._collections.clear()
+ await self.client.close()
async def _ensure_collection(self, collection: str, vector_size: int) -> None:
if collection in self._collections:
@@ -59,14 +64,14 @@ class QdrantVectorDatabase(VectorDatabase):
exists = await self.client.collection_exists(collection)
if exists:
- self._collections.add(collection)
+ remember_bounded_set(self._collections, collection, self._runtime_cache_limit)
return
await self.client.create_collection(
collection_name=collection,
vectors_config=models.VectorParams(size=vector_size, distance=models.Distance.COSINE),
)
- self._collections.add(collection)
+ remember_bounded_set(self._collections, collection, self._runtime_cache_limit)
self.ap.logger.info(f"Qdrant collection '{collection}' created with dim={vector_size}.")
async def get_or_create_collection(self, collection: str):
diff --git a/src/langbot/pkg/vector/vdbs/seekdb.py b/src/langbot/pkg/vector/vdbs/seekdb.py
index c96a9ca77..fc82298e0 100644
--- a/src/langbot/pkg/vector/vdbs/seekdb.py
+++ b/src/langbot/pkg/vector/vdbs/seekdb.py
@@ -7,7 +7,13 @@ from typing import Any, Dict, List
from langbot.pkg.core import app
-from langbot.pkg.vector.vdb import VectorDatabase, SearchType
+from langbot.pkg.utils import bounded_executor
+from langbot.pkg.vector.vdb import (
+ VectorDatabase,
+ SearchType,
+ remember_bounded_mapping,
+ runtime_cache_limit,
+)
try:
import pyseekdb
@@ -90,6 +96,7 @@ class SeekDBVectorDatabase(VectorDatabase):
self._collections: Dict[str, Any] = {}
self._collection_configs: Dict[str, HNSWConfiguration] = {}
+ self._runtime_cache_limit = runtime_cache_limit(ap)
self._escape_table = str.maketrans(
{
@@ -103,6 +110,13 @@ class SeekDBVectorDatabase(VectorDatabase):
}
)
+ async def close(self) -> None:
+ self._collections.clear()
+ self._collection_configs.clear()
+ close = getattr(self.client, 'close', None)
+ if callable(close):
+ await bounded_executor.run_blocking_cleanup(close)
+
def _normalize_collection_name(self, collection: str) -> str:
"""SeekDB only accepts [a-zA-Z0-9_], while LangBot uses UUID-like KB IDs."""
normalized = re.sub(r'[^A-Za-z0-9_]', '_', collection)
@@ -132,7 +146,12 @@ class SeekDBVectorDatabase(VectorDatabase):
if await asyncio.to_thread(self.client.has_collection, collection):
# Collection exists, get it
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
- self._collections[collection] = coll
+ remember_bounded_mapping(
+ self._collections,
+ collection,
+ coll,
+ self._runtime_cache_limit,
+ )
self.ap.logger.info(f"SeekDB collection '{collection}' retrieved.")
return coll
@@ -145,7 +164,12 @@ class SeekDBVectorDatabase(VectorDatabase):
# Create HNSW configuration
config = HNSWConfiguration(dimension=vector_size, distance='cosine')
- self._collection_configs[collection] = config
+ remember_bounded_mapping(
+ self._collection_configs,
+ collection,
+ config,
+ self._runtime_cache_limit,
+ )
# Create collection without embedding function (we manage embeddings externally)
coll = await asyncio.to_thread(
@@ -155,7 +179,12 @@ class SeekDBVectorDatabase(VectorDatabase):
embedding_function=None, # Disable automatic embedding
)
- self._collections[collection] = coll
+ remember_bounded_mapping(
+ self._collections,
+ collection,
+ coll,
+ self._runtime_cache_limit,
+ )
self.ap.logger.info(f"SeekDB collection '{collection}' created with dimension={vector_size}, distance='cosine'")
return coll
@@ -243,7 +272,12 @@ class SeekDBVectorDatabase(VectorDatabase):
# Get collection
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
- self._collections[collection] = coll
+ remember_bounded_mapping(
+ self._collections,
+ collection,
+ coll,
+ self._runtime_cache_limit,
+ )
else:
coll = self._collections[collection]
@@ -349,7 +383,12 @@ class SeekDBVectorDatabase(VectorDatabase):
# Get collection
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
- self._collections[collection] = coll
+ remember_bounded_mapping(
+ self._collections,
+ collection,
+ coll,
+ self._runtime_cache_limit,
+ )
else:
coll = self._collections[collection]
@@ -374,7 +413,12 @@ class SeekDBVectorDatabase(VectorDatabase):
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
- self._collections[collection] = coll
+ remember_bounded_mapping(
+ self._collections,
+ collection,
+ coll,
+ self._runtime_cache_limit,
+ )
else:
coll = self._collections[collection]
@@ -396,7 +440,12 @@ class SeekDBVectorDatabase(VectorDatabase):
if collection not in self._collections:
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
- self._collections[collection] = coll
+ remember_bounded_mapping(
+ self._collections,
+ collection,
+ coll,
+ self._runtime_cache_limit,
+ )
else:
coll = self._collections[collection]
diff --git a/src/langbot/pkg/vector/vdbs/valkey_search.py b/src/langbot/pkg/vector/vdbs/valkey_search.py
index 66be43664..48cf27a4c 100644
--- a/src/langbot/pkg/vector/vdbs/valkey_search.py
+++ b/src/langbot/pkg/vector/vdbs/valkey_search.py
@@ -6,7 +6,12 @@ import struct
from typing import Any
from langbot.pkg.core import app
-from langbot.pkg.vector.vdb import VectorDatabase, SearchType
+from langbot.pkg.vector.vdb import (
+ VectorDatabase,
+ SearchType,
+ remember_bounded_set,
+ runtime_cache_limit,
+)
from langbot.pkg.vector.filter_utils import normalize_filter, strip_unsupported_fields
try:
@@ -77,6 +82,7 @@ _MATCH_ALL = '-@file_id:{__langbot_match_all_sentinel__}'
# files/filters matching more than one page of chunks are fully removed
# (no silent truncation / orphaned vectors).
_DELETE_SCAN_BATCH = 10000
+_MAX_DELETE_SCAN_ROUNDS = 1000
# Characters Valkey Search's TAG query parser cannot handle even when
# backslash-escaped (the brace delimiters and the wildcard). file_id TAG
@@ -153,6 +159,7 @@ class ValkeySearchVectorDatabase(VectorDatabase):
self._client_lock = asyncio.Lock()
# Index names we have already ensured this process lifetime.
self._ensured_indexes: set[str] = set()
+ self._runtime_cache_limit = runtime_cache_limit(ap)
# Whether we have already warned about the non-honored vector_weight.
self._vector_weight_warned = False
@@ -364,7 +371,7 @@ class ValkeySearchVectorDatabase(VectorDatabase):
# check-then-create TOCTOU window.
try:
await ft.info(client, index)
- self._ensured_indexes.add(index)
+ remember_bounded_set(self._ensured_indexes, index, self._runtime_cache_limit)
return
except RequestError:
pass
@@ -389,7 +396,7 @@ class ValkeySearchVectorDatabase(VectorDatabase):
]
options = FtCreateOptions(data_type=DataType.HASH, prefixes=[self._key_prefix(collection)])
await ft.create(client, index, schema, options)
- self._ensured_indexes.add(index)
+ remember_bounded_set(self._ensured_indexes, index, self._runtime_cache_limit)
self.ap.logger.info(
f"Valkey Search index '{index}' created (dim={dim}, algo={self._algorithm.value}, "
f'metric={self._distance_metric.value})'
@@ -646,11 +653,9 @@ class ValkeySearchVectorDatabase(VectorDatabase):
return
query = f'@{_FIELD_FILE_ID}:{{{self._encode_and_escape_tag(file_id)}}}'
- keys = await self._search_keys(client, index, query)
- if keys:
- await client.delete(keys)
+ deleted = await self._delete_search_results(client, index, query)
self.ap.logger.info(
- f"Deleted {len(keys)} embeddings from Valkey Search collection '{collection}' with file_id: {file_id}"
+ f"Deleted {deleted} embeddings from Valkey Search collection '{collection}' with file_id: {file_id}"
)
async def delete_by_filter(self, collection: str, filter: dict[str, Any]) -> int:
@@ -670,11 +675,9 @@ class ValkeySearchVectorDatabase(VectorDatabase):
collection,
)
return 0
- keys = await self._search_keys(client, index, query)
- if keys:
- await client.delete(keys)
- self.ap.logger.info(f"Deleted {len(keys)} embeddings from Valkey Search collection '{collection}' by filter")
- return len(keys)
+ deleted = await self._delete_search_results(client, index, query)
+ self.ap.logger.info(f"Deleted {deleted} embeddings from Valkey Search collection '{collection}' by filter")
+ return deleted
async def list_by_filter(
self,
@@ -772,43 +775,35 @@ class ValkeySearchVectorDatabase(VectorDatabase):
# was being paid on the first query to each collection.
try:
await ft.info(client, index)
- self._ensured_indexes.add(index)
+ remember_bounded_set(self._ensured_indexes, index, self._runtime_cache_limit)
return True
except RequestError:
return False
- async def _search_keys(self, client: GlideClient, index: str, query: str) -> list[str]:
- """Return all matching document keys for a query (NOCONTENT).
+ async def _delete_search_results(self, client: GlideClient, index: str, query: str) -> int:
+ """Delete matching hashes in fixed batches without retaining every key.
- Paginates through the full result set in pages of ``_DELETE_SCAN_BATCH``
- so that queries matching more than one page of chunks are fully
- enumerated (avoids silently truncating deletes and leaving orphaned
- vectors).
+ Each deletion shrinks the result set, so every search starts at offset
+ zero. Advancing an offset after deleting the preceding page would skip
+ records as the remaining results shift left.
"""
- keys: list[str] = []
- offset = 0
- while True:
+
+ deleted = 0
+ for _round in range(_MAX_DELETE_SCAN_ROUNDS):
options = FtSearchOptions(
nocontent=True,
- limit=FtSearchLimit(offset, _DELETE_SCAN_BATCH),
+ limit=FtSearchLimit(0, _DELETE_SCAN_BATCH),
dialect=2,
)
try:
reply = await ft.search(client, index, query, options)
except Exception as exc:
if self._is_missing_index_error(exc):
- return keys
+ return deleted
raise
if not reply or len(reply) < 2:
- break
-
- # reply[0] is the total match count; reply[1] holds this page.
- total = 0
- try:
- total = int(reply[0])
- except (TypeError, ValueError):
- total = 0
+ return deleted
docs = reply[1]
if isinstance(docs, dict):
@@ -819,11 +814,17 @@ class ValkeySearchVectorDatabase(VectorDatabase):
page = []
if not page:
- break
- keys.extend(page)
+ return deleted
+ await client.delete(page)
+ deleted += len(page)
- offset += len(page)
- if offset >= total or len(page) < _DELETE_SCAN_BATCH:
- break
+ try:
+ total = int(reply[0])
+ except (TypeError, ValueError):
+ total = len(page)
+ if total <= len(page) or len(page) < _DELETE_SCAN_BATCH:
+ return deleted
- return keys
+ raise RuntimeError(
+ f'Valkey deletion exceeded {_MAX_DELETE_SCAN_ROUNDS} batches ({_DELETE_SCAN_BATCH} keys per batch)'
+ )
diff --git a/src/langbot/pkg/workspace/__init__.py b/src/langbot/pkg/workspace/__init__.py
new file mode 100644
index 000000000..c8db78b9a
--- /dev/null
+++ b/src/langbot/pkg/workspace/__init__.py
@@ -0,0 +1,25 @@
+from .errors import (
+ WorkspaceExecutionUnavailableError,
+ WorkspaceGenerationMismatchError,
+ WorkspaceInvariantError,
+ WorkspaceLimitExceededError,
+ WorkspaceNotFoundError,
+ WorkspaceOwnerAlreadyExistsError,
+)
+from .entities import WorkspaceExecutionBinding
+from .policy import SingleWorkspacePolicy
+from .repository import WorkspaceRepository
+from .service import WorkspaceService
+
+__all__ = [
+ 'SingleWorkspacePolicy',
+ 'WorkspaceExecutionBinding',
+ 'WorkspaceExecutionUnavailableError',
+ 'WorkspaceGenerationMismatchError',
+ 'WorkspaceInvariantError',
+ 'WorkspaceLimitExceededError',
+ 'WorkspaceNotFoundError',
+ 'WorkspaceOwnerAlreadyExistsError',
+ 'WorkspaceRepository',
+ 'WorkspaceService',
+]
diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py
new file mode 100644
index 000000000..bcebe47c4
--- /dev/null
+++ b/src/langbot/pkg/workspace/collaboration.py
@@ -0,0 +1,820 @@
+from __future__ import annotations
+
+import dataclasses
+import datetime
+import hashlib
+import secrets
+import typing
+import uuid
+import asyncio
+from contextlib import asynccontextmanager
+from collections.abc import Awaitable, Callable
+
+import sqlalchemy
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from ..entity.persistence.user import AccountStatus, User
+from ..entity.persistence.workspace import (
+ InvitationStatus,
+ MembershipRole,
+ MembershipStatus,
+ Workspace,
+ WorkspaceInvitation,
+ WorkspaceMembership,
+ WorkspaceStatus,
+)
+from .entities import WorkspaceExecutionBinding
+from .errors import WorkspaceExecutionUnavailableError, WorkspaceInvariantError, WorkspaceNotFoundError
+from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
+from .service import WorkspaceService
+
+if typing.TYPE_CHECKING:
+ from ..core.app import Application
+
+
+class WorkspaceCollaborationError(Exception):
+ """Stable collaboration error surfaced by the Workspace API."""
+
+ code = 'workspace_collaboration_error'
+
+
+class MembershipNotFoundError(WorkspaceCollaborationError):
+ code = 'membership_not_found'
+
+
+class MembershipPermissionError(WorkspaceCollaborationError):
+ code = 'permission_denied'
+
+
+class LastOwnerError(WorkspaceCollaborationError):
+ code = 'last_owner_required'
+
+
+class InvitationError(WorkspaceCollaborationError):
+ code = 'invitation_invalid'
+
+
+class InvitationExpiredError(InvitationError):
+ code = 'invitation_expired'
+
+
+class InvitationRevokedError(InvitationError):
+ code = 'invitation_revoked'
+
+
+class InvitationUsedError(InvitationError):
+ code = 'invitation_used'
+
+
+class InvitationEmailMismatchError(InvitationError):
+ code = 'invitation_email_mismatch'
+
+
+class InvitationRoleError(InvitationError):
+ code = 'invitation_role_invalid'
+
+
+class AlreadyMemberError(InvitationError):
+ code = 'already_a_member'
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class ResolvedWorkspaceAccess:
+ workspace: Workspace
+ membership: WorkspaceMembership
+ execution: WorkspaceExecutionBinding
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class WorkspaceMemberView:
+ membership: WorkspaceMembership
+ email: str
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class CreatedInvitation:
+ invitation: WorkspaceInvitation
+ token: str
+
+
+@dataclasses.dataclass(slots=True)
+class _InvitationLockEntry:
+ lock: asyncio.Lock
+ users: int = 0
+
+
+T = typing.TypeVar('T')
+
+
+def normalize_email(email: str) -> str:
+ """Return the canonical email identity used by invitations."""
+
+ normalized = email.strip().casefold()
+ if not normalized or '@' not in normalized:
+ raise ValueError('A valid email address is required')
+ if len(normalized) > 320:
+ raise ValueError('Email address exceeds the normalized identity limit')
+ return normalized
+
+
+def hash_invitation_token(token: str) -> str:
+ """Hash an invitation bearer secret for lookup and at-rest storage."""
+
+ return hashlib.sha256(token.encode('utf-8')).hexdigest()
+
+
+class WorkspaceCollaborationService:
+ """Membership and invitation operations for the local Workspace directory."""
+
+ def __init__(
+ self,
+ ap: Application,
+ workspace_service: WorkspaceService,
+ *,
+ policy: SingleWorkspacePolicy | CloudWorkspacePolicy | None = None,
+ ) -> None:
+ self.ap = ap
+ self.workspace_service = workspace_service
+ self.policy = policy or workspace_service.policy
+ self._invitation_locks: dict[str, _InvitationLockEntry] = {}
+ self._invitation_locks_guard = asyncio.Lock()
+
+ def _session_factory(self) -> async_sessionmaker[AsyncSession]:
+ return async_sessionmaker(
+ self.ap.persistence_mgr.get_db_engine(),
+ expire_on_commit=False,
+ )
+
+ async def resolve_account_workspace(
+ self,
+ account_uuid: str,
+ requested_workspace_uuid: str | None,
+ *,
+ session: AsyncSession | None = None,
+ ) -> ResolvedWorkspaceAccess:
+ """Resolve a selector against an active Account membership."""
+
+ normalized_workspace_uuid = requested_workspace_uuid.strip() if requested_workspace_uuid else None
+ if normalized_workspace_uuid is None and self.policy.multi_workspace_enabled:
+ raise WorkspaceNotFoundError('A Workspace selector is required')
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if session is None and normalized_workspace_uuid is not None and callable(tenant_uow):
+ async with tenant_uow(normalized_workspace_uuid) as uow:
+ return await self.resolve_account_workspace(
+ account_uuid,
+ normalized_workspace_uuid,
+ session=uow.session,
+ )
+
+ async def operation(active_session: AsyncSession) -> ResolvedWorkspaceAccess:
+ workspace_uuid = normalized_workspace_uuid
+ if workspace_uuid is None:
+ if self.policy.multi_workspace_enabled:
+ raise WorkspaceNotFoundError('A Workspace selector is required')
+ workspace = await self.workspace_service.get_singleton_workspace(session=active_session)
+ else:
+ workspace = await active_session.get(Workspace, workspace_uuid)
+ if (
+ workspace is None
+ or workspace.instance_uuid != self.workspace_service.instance_uuid
+ or workspace.status != WorkspaceStatus.ACTIVE.value
+ ):
+ raise WorkspaceNotFoundError('Workspace not found')
+
+ membership = await active_session.scalar(
+ sqlalchemy.select(WorkspaceMembership).where(
+ WorkspaceMembership.workspace_uuid == workspace.uuid,
+ WorkspaceMembership.account_uuid == account_uuid,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ )
+ )
+ if membership is None:
+ # Deliberately hide Workspace existence across Accounts.
+ raise WorkspaceNotFoundError('Workspace not found')
+
+ execution = await self.workspace_service.get_execution_binding(
+ workspace.uuid,
+ session=active_session,
+ )
+ return ResolvedWorkspaceAccess(workspace, membership, execution)
+
+ return await self._run(operation, session=session, read_only=True)
+
+ async def list_account_workspaces(
+ self,
+ account_uuid: str,
+ *,
+ session: AsyncSession | None = None,
+ ) -> list[ResolvedWorkspaceAccess]:
+ current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
+ account_uow = getattr(self.ap.persistence_mgr, 'account_discovery_uow', None)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if session is None and current_session() is None and callable(account_uow) and callable(tenant_uow):
+ # Discovery exposes only this Account's active Membership rows.
+ # Each resulting Workspace is then re-read under its own tenant
+ # transaction; directory discovery never grants business access.
+ async with account_uow(account_uuid) as discovery:
+ workspace_uuids = list(
+ (
+ await discovery.session.scalars(
+ sqlalchemy.select(WorkspaceMembership.workspace_uuid)
+ .where(
+ WorkspaceMembership.account_uuid == account_uuid,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ )
+ .order_by(WorkspaceMembership.workspace_uuid)
+ )
+ ).all()
+ )
+
+ accesses: list[ResolvedWorkspaceAccess] = []
+ for workspace_uuid in workspace_uuids:
+ async with tenant_uow(workspace_uuid) as workspace_uow:
+ try:
+ accesses.append(
+ await self.resolve_account_workspace(
+ account_uuid,
+ workspace_uuid,
+ session=workspace_uow.session,
+ )
+ )
+ except (
+ WorkspaceExecutionUnavailableError,
+ WorkspaceInvariantError,
+ WorkspaceNotFoundError,
+ ) as exc:
+ self.ap.logger.warning(
+ f'Skipping inactive Workspace discovery projection {workspace_uuid!r}: {exc}'
+ )
+ accesses.sort(key=lambda access: (access.workspace.created_at, access.workspace.uuid))
+ return accesses
+
+ async def operation(active_session: AsyncSession) -> list[ResolvedWorkspaceAccess]:
+ statement = (
+ sqlalchemy.select(WorkspaceMembership, Workspace)
+ .join(Workspace, Workspace.uuid == WorkspaceMembership.workspace_uuid)
+ .where(
+ WorkspaceMembership.account_uuid == account_uuid,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ Workspace.instance_uuid == self.workspace_service.instance_uuid,
+ Workspace.status == WorkspaceStatus.ACTIVE.value,
+ )
+ .order_by(Workspace.created_at, Workspace.uuid)
+ )
+ rows = (await active_session.execute(statement)).all()
+ accesses: list[ResolvedWorkspaceAccess] = []
+ for membership, workspace in rows:
+ execution = await self.workspace_service.get_execution_binding(
+ workspace.uuid,
+ session=active_session,
+ )
+ accesses.append(ResolvedWorkspaceAccess(workspace, membership, execution))
+ return accesses
+
+ return await self._run(operation, session=session, read_only=True)
+
+ async def list_members(
+ self,
+ workspace_uuid: str,
+ actor: WorkspaceMembership,
+ *,
+ session: AsyncSession | None = None,
+ ) -> list[WorkspaceMemberView]:
+ if session is None:
+ current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)()
+ tenant_uow: typing.Any = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if current_session is None and callable(tenant_uow):
+ async with tenant_uow(workspace_uuid) as workspace_uow:
+ return await self.list_members(
+ workspace_uuid,
+ actor,
+ session=workspace_uow.session,
+ )
+
+ async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
+ await self._load_actor(active_session, workspace_uuid, actor)
+ statement = (
+ sqlalchemy.select(WorkspaceMembership, User.user)
+ .join(User, User.uuid == WorkspaceMembership.account_uuid)
+ .where(
+ WorkspaceMembership.workspace_uuid == workspace_uuid,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ User.status == AccountStatus.ACTIVE.value,
+ )
+ .order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
+ )
+ return [
+ WorkspaceMemberView(membership=membership, email=email)
+ for membership, email in (await active_session.execute(statement)).all()
+ ]
+
+ return await self._run(operation, session=session, read_only=True)
+
+ async def list_invitations(
+ self,
+ workspace_uuid: str,
+ actor: WorkspaceMembership,
+ *,
+ session: AsyncSession | None = None,
+ ) -> list[WorkspaceInvitation]:
+ async def operation(active_session: AsyncSession) -> list[WorkspaceInvitation]:
+ await self._require_active_workspace(active_session, workspace_uuid)
+ persisted_actor = await self._load_actor(active_session, workspace_uuid, actor)
+ self._require_member_manager(persisted_actor, workspace_uuid)
+ await self._expire_pending_invitations(active_session, workspace_uuid=workspace_uuid)
+ statement = (
+ sqlalchemy.select(WorkspaceInvitation)
+ .where(
+ WorkspaceInvitation.workspace_uuid == workspace_uuid,
+ WorkspaceInvitation.status == InvitationStatus.PENDING.value,
+ )
+ .order_by(WorkspaceInvitation.created_at, WorkspaceInvitation.uuid)
+ )
+ return list((await active_session.scalars(statement)).all())
+
+ return await self._run(operation, session=session)
+
+ async def create_invitation(
+ self,
+ workspace_uuid: str,
+ actor: WorkspaceMembership,
+ email: str,
+ role: str,
+ *,
+ expires_in: datetime.timedelta = datetime.timedelta(days=7),
+ session: AsyncSession | None = None,
+ ) -> CreatedInvitation:
+ if role not in {
+ MembershipRole.ADMIN.value,
+ MembershipRole.DEVELOPER.value,
+ MembershipRole.OPERATOR.value,
+ MembershipRole.VIEWER.value,
+ }:
+ raise InvitationRoleError('Invitations cannot grant this role')
+ normalized_email = normalize_email(email)
+ if expires_in <= datetime.timedelta(0):
+ raise InvitationError('Invitation expiry must be in the future')
+
+ async def operation(active_session: AsyncSession) -> CreatedInvitation:
+ await self._require_active_workspace(active_session, workspace_uuid)
+ persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
+ self._require_member_manager(persisted_actor, workspace_uuid)
+ existing_account = await active_session.scalar(
+ sqlalchemy.select(User).where(User.normalized_email == normalized_email)
+ )
+ if existing_account is not None:
+ existing_membership = await active_session.scalar(
+ sqlalchemy.select(WorkspaceMembership).where(
+ WorkspaceMembership.workspace_uuid == workspace_uuid,
+ WorkspaceMembership.account_uuid == existing_account.uuid,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ )
+ )
+ if existing_membership is not None:
+ raise AlreadyMemberError('This Account is already a Workspace member')
+
+ existing_pending = await active_session.scalar(
+ sqlalchemy.select(WorkspaceInvitation)
+ .where(
+ WorkspaceInvitation.workspace_uuid == workspace_uuid,
+ WorkspaceInvitation.normalized_email == normalized_email,
+ WorkspaceInvitation.status == InvitationStatus.PENDING.value,
+ )
+ .with_for_update()
+ )
+ now = self._utcnow()
+ if existing_pending is not None:
+ existing_pending.status = InvitationStatus.REVOKED.value
+ existing_pending.revoked_at = now
+ await active_session.flush()
+
+ token = f'lbi_{secrets.token_urlsafe(32)}'
+ invitation = WorkspaceInvitation(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_uuid,
+ normalized_email=normalized_email,
+ role=role,
+ token_hash=hash_invitation_token(token),
+ status=InvitationStatus.PENDING.value,
+ expires_at=now + expires_in,
+ created_by_account_uuid=persisted_actor.account_uuid,
+ )
+ active_session.add(invitation)
+ await active_session.flush()
+ return CreatedInvitation(invitation, token)
+
+ return await self._run(operation, session=session)
+
+ async def inspect_invitation(
+ self,
+ token: str,
+ *,
+ session: AsyncSession | None = None,
+ ) -> tuple[WorkspaceInvitation, Workspace]:
+ if session is None:
+ scoped_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)()
+ invitation_uow = getattr(self.ap.persistence_mgr, 'invitation_discovery_uow', None)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if scoped_session is None and callable(invitation_uow) and callable(tenant_uow):
+ token_hash = hash_invitation_token(token)
+ async with invitation_uow(token_hash) as discovery:
+ invitation = await self._get_invitation_by_token(discovery.session, token, for_update=False)
+ workspace_uuid = invitation.workspace_uuid
+ async with tenant_uow(workspace_uuid) as workspace_uow:
+ return await self.inspect_invitation(token, session=workspace_uow.session)
+
+ async def operation(active_session: AsyncSession) -> tuple[WorkspaceInvitation, Workspace]:
+ invitation = await self._get_invitation_by_token(active_session, token, for_update=True)
+ self._validate_invitation_state(invitation)
+ workspace = await active_session.get(Workspace, invitation.workspace_uuid)
+ if workspace is None or workspace.status != WorkspaceStatus.ACTIVE.value:
+ raise InvitationError('The invitation Workspace is unavailable')
+ return invitation, workspace
+
+ return await self._run(operation, session=session)
+
+ async def accept_invitation(
+ self,
+ token: str,
+ account_uuid: str,
+ *,
+ session: AsyncSession | None = None,
+ ) -> WorkspaceMembership:
+ if session is None:
+ scoped_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)()
+ invitation_uow = getattr(self.ap.persistence_mgr, 'invitation_discovery_uow', None)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if scoped_session is None and callable(invitation_uow) and callable(tenant_uow):
+ token_digest = hash_invitation_token(token)
+ async with invitation_uow(token_digest) as discovery:
+ invitation = await self._get_invitation_by_token(discovery.session, token, for_update=False)
+ workspace_uuid = invitation.workspace_uuid
+ async with tenant_uow(workspace_uuid) as workspace_uow:
+ return await self.accept_invitation(token, account_uuid, session=workspace_uow.session)
+
+ async def operation(active_session: AsyncSession) -> WorkspaceMembership:
+ invitation = await self._get_invitation_by_token(active_session, token, for_update=True)
+ self._validate_invitation_state(invitation)
+ await self._require_active_workspace(active_session, invitation.workspace_uuid)
+ account = await active_session.scalar(sqlalchemy.select(User).where(User.uuid == account_uuid))
+ if account is None or account.status != AccountStatus.ACTIVE.value:
+ raise MembershipNotFoundError('Account not found')
+ if account.normalized_email != invitation.normalized_email:
+ raise InvitationEmailMismatchError('Invitation email does not match the Account')
+
+ membership = await active_session.scalar(
+ sqlalchemy.select(WorkspaceMembership)
+ .where(
+ WorkspaceMembership.workspace_uuid == invitation.workspace_uuid,
+ WorkspaceMembership.account_uuid == account_uuid,
+ )
+ .with_for_update()
+ )
+ now = self._utcnow()
+ if membership is None:
+ membership = WorkspaceMembership(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=invitation.workspace_uuid,
+ account_uuid=account_uuid,
+ role=invitation.role,
+ status=MembershipStatus.ACTIVE.value,
+ invited_by_account_uuid=invitation.created_by_account_uuid,
+ joined_at=now,
+ projection_revision=0,
+ )
+ active_session.add(membership)
+ elif membership.status != MembershipStatus.ACTIVE.value:
+ membership.role = invitation.role
+ membership.status = MembershipStatus.ACTIVE.value
+ membership.invited_by_account_uuid = invitation.created_by_account_uuid
+ membership.joined_at = now
+
+ invitation.status = InvitationStatus.ACCEPTED.value
+ invitation.accepted_at = now
+ await active_session.flush()
+ return membership
+
+ token_digest = hash_invitation_token(token)
+ async with self._invitation_lock(token_digest):
+ return await self._run(operation, session=session)
+
+ @asynccontextmanager
+ async def _invitation_lock(self, lock_key: str):
+ """Serialize one token within workspace scope while retaining only active lock entries."""
+
+ async with self._invitation_locks_guard:
+ entry = self._invitation_locks.get(lock_key)
+ if entry is None:
+ entry = _InvitationLockEntry(lock=asyncio.Lock())
+ self._invitation_locks[lock_key] = entry
+ entry.users += 1
+
+ await entry.lock.acquire()
+ try:
+ yield
+ finally:
+ entry.lock.release()
+ async with self._invitation_locks_guard:
+ entry.users -= 1
+ if entry.users == 0:
+ self._invitation_locks.pop(lock_key, None)
+
+ async def revoke_invitation(
+ self,
+ workspace_uuid: str,
+ invitation_uuid: str,
+ actor: WorkspaceMembership,
+ *,
+ session: AsyncSession | None = None,
+ ) -> WorkspaceInvitation:
+ async def operation(active_session: AsyncSession) -> WorkspaceInvitation:
+ await self._require_active_workspace(active_session, workspace_uuid)
+ persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
+ self._require_member_manager(persisted_actor, workspace_uuid)
+ invitation = await active_session.scalar(
+ sqlalchemy.select(WorkspaceInvitation)
+ .where(
+ WorkspaceInvitation.uuid == invitation_uuid,
+ WorkspaceInvitation.workspace_uuid == workspace_uuid,
+ )
+ .with_for_update()
+ )
+ if invitation is None:
+ raise InvitationError('Invitation not found')
+ if invitation.status == InvitationStatus.REVOKED.value:
+ return invitation
+ if invitation.status != InvitationStatus.PENDING.value:
+ self._validate_invitation_state(invitation)
+ invitation.status = InvitationStatus.REVOKED.value
+ invitation.revoked_at = self._utcnow()
+ await active_session.flush()
+ return invitation
+
+ return await self._run(operation, session=session)
+
+ async def cleanup_expired_invitations(
+ self,
+ *,
+ retention: datetime.timedelta = datetime.timedelta(0),
+ active_bindings: typing.Iterable[WorkspaceExecutionBinding] | None = None,
+ ) -> int:
+ """Delete expired invitation records without crossing Cloud tenant scopes."""
+ cutoff = self._utcnow() - retention
+
+ async def cleanup_session(active_session: AsyncSession, workspace_uuid: str | None = None) -> int:
+ statement = sqlalchemy.delete(WorkspaceInvitation).where(
+ WorkspaceInvitation.status.in_((InvitationStatus.PENDING.value, InvitationStatus.EXPIRED.value)),
+ WorkspaceInvitation.expires_at <= cutoff,
+ )
+ if workspace_uuid is not None:
+ statement = statement.where(WorkspaceInvitation.workspace_uuid == workspace_uuid)
+ result = await active_session.execute(statement)
+ return int(result.rowcount or 0)
+
+ if getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime':
+ list_bindings = getattr(self.workspace_service, 'list_active_execution_bindings', None)
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if not callable(list_bindings) or not callable(tenant_uow):
+ raise RuntimeError('Cloud invitation cleanup requires tenant units of work')
+ deleted = 0
+ bindings = active_bindings if active_bindings is not None else await list_bindings()
+ for binding in bindings:
+ async with tenant_uow(binding.workspace_uuid) as uow:
+ deleted += await cleanup_session(uow.session, binding.workspace_uuid)
+ return deleted
+ return await self._run(cleanup_session, session=None)
+
+ async def run_expired_invitation_cleanup(self, *, interval_seconds: float = 3600) -> None:
+ """Periodically remove expired records, waiting first so expiry inspection wins."""
+ while True:
+ await asyncio.sleep(interval_seconds)
+ try:
+ await self.cleanup_expired_invitations()
+ except asyncio.CancelledError:
+ raise
+ except Exception:
+ self.ap.logger.exception('Expired Workspace invitation cleanup failed')
+
+ async def update_member_role(
+ self,
+ workspace_uuid: str,
+ target_account_uuid: str,
+ role: str,
+ actor: WorkspaceMembership,
+ *,
+ session: AsyncSession | None = None,
+ ) -> WorkspaceMembership:
+ if role not in {item.value for item in MembershipRole}:
+ raise MembershipPermissionError('Unknown Workspace role')
+
+ async def operation(active_session: AsyncSession) -> WorkspaceMembership:
+ await self._require_active_workspace(active_session, workspace_uuid)
+ persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
+ self._require_member_manager(persisted_actor, workspace_uuid)
+ target = await self._get_active_member_for_update(
+ active_session,
+ workspace_uuid,
+ target_account_uuid,
+ )
+ self._require_can_manage_target(persisted_actor, target, new_role=role)
+ if target.role == MembershipRole.OWNER.value and role != MembershipRole.OWNER.value:
+ await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
+ target.role = role
+ await active_session.flush()
+ return target
+
+ return await self._run(operation, session=session)
+
+ async def remove_member(
+ self,
+ workspace_uuid: str,
+ target_account_uuid: str,
+ actor: WorkspaceMembership,
+ *,
+ session: AsyncSession | None = None,
+ ) -> WorkspaceMembership:
+ async def operation(active_session: AsyncSession) -> WorkspaceMembership:
+ await self._require_active_workspace(active_session, workspace_uuid)
+ persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True)
+ self._require_member_manager(persisted_actor, workspace_uuid)
+ target = await self._get_active_member_for_update(
+ active_session,
+ workspace_uuid,
+ target_account_uuid,
+ )
+ self._require_can_manage_target(persisted_actor, target)
+ if target.role == MembershipRole.OWNER.value:
+ await self._require_another_owner(active_session, workspace_uuid, target.account_uuid)
+ target.status = MembershipStatus.REMOVED.value
+ await active_session.flush()
+ return target
+
+ return await self._run(operation, session=session)
+
+ async def _get_invitation_by_token(
+ self,
+ session: AsyncSession,
+ token: str,
+ *,
+ for_update: bool,
+ ) -> WorkspaceInvitation:
+ if not isinstance(token, str) or not token.startswith('lbi_'):
+ raise InvitationError('Invitation not found')
+ statement = sqlalchemy.select(WorkspaceInvitation).where(
+ WorkspaceInvitation.token_hash == hash_invitation_token(token)
+ )
+ if for_update:
+ statement = statement.with_for_update()
+ invitation = await session.scalar(statement)
+ if invitation is None:
+ raise InvitationError('Invitation not found')
+ return invitation
+
+ async def _require_active_workspace(
+ self,
+ session: AsyncSession,
+ workspace_uuid: str,
+ ) -> Workspace:
+ workspace = await session.get(Workspace, workspace_uuid)
+ if (
+ workspace is None
+ or workspace.instance_uuid != self.workspace_service.instance_uuid
+ or workspace.status != WorkspaceStatus.ACTIVE.value
+ ):
+ raise WorkspaceNotFoundError('Workspace not found')
+ return workspace
+
+ def _validate_invitation_state(self, invitation: WorkspaceInvitation) -> None:
+ if invitation.status == InvitationStatus.REVOKED.value:
+ raise InvitationRevokedError('Invitation was revoked')
+ if invitation.status == InvitationStatus.ACCEPTED.value:
+ raise InvitationUsedError('Invitation was already accepted')
+ if invitation.status == InvitationStatus.EXPIRED.value or invitation.expires_at <= self._utcnow():
+ invitation.status = InvitationStatus.EXPIRED.value
+ raise InvitationExpiredError('Invitation has expired')
+ if invitation.status != InvitationStatus.PENDING.value:
+ raise InvitationError('Invitation is not pending')
+
+ async def _expire_pending_invitations(
+ self,
+ session: AsyncSession,
+ *,
+ workspace_uuid: str,
+ ) -> None:
+ await session.execute(
+ sqlalchemy.update(WorkspaceInvitation)
+ .where(
+ WorkspaceInvitation.workspace_uuid == workspace_uuid,
+ WorkspaceInvitation.status == InvitationStatus.PENDING.value,
+ WorkspaceInvitation.expires_at <= self._utcnow(),
+ )
+ .values(status=InvitationStatus.EXPIRED.value)
+ )
+
+ async def _get_active_member_for_update(
+ self,
+ session: AsyncSession,
+ workspace_uuid: str,
+ account_uuid: str,
+ ) -> WorkspaceMembership:
+ membership = await session.scalar(
+ sqlalchemy.select(WorkspaceMembership)
+ .where(
+ WorkspaceMembership.workspace_uuid == workspace_uuid,
+ WorkspaceMembership.account_uuid == account_uuid,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ )
+ .with_for_update()
+ )
+ if membership is None:
+ raise MembershipNotFoundError('Workspace member not found')
+ return membership
+
+ async def _load_actor(
+ self,
+ session: AsyncSession,
+ workspace_uuid: str,
+ actor: WorkspaceMembership,
+ *,
+ for_update: bool = False,
+ ) -> WorkspaceMembership:
+ self._require_actor_workspace(actor, workspace_uuid)
+ statement = sqlalchemy.select(WorkspaceMembership).where(
+ WorkspaceMembership.workspace_uuid == workspace_uuid,
+ WorkspaceMembership.account_uuid == actor.account_uuid,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ )
+ if for_update:
+ statement = statement.with_for_update()
+ persisted_actor = await session.scalar(statement)
+ if persisted_actor is None:
+ raise WorkspaceNotFoundError('Workspace not found')
+ return persisted_actor
+
+ async def _require_another_owner(
+ self,
+ session: AsyncSession,
+ workspace_uuid: str,
+ excluded_account_uuid: str,
+ ) -> None:
+ owners = (
+ await session.scalars(
+ sqlalchemy.select(WorkspaceMembership)
+ .where(
+ WorkspaceMembership.workspace_uuid == workspace_uuid,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ WorkspaceMembership.role == MembershipRole.OWNER.value,
+ )
+ .with_for_update()
+ )
+ ).all()
+ if not any(owner.account_uuid != excluded_account_uuid for owner in owners):
+ raise LastOwnerError('The last Workspace owner cannot be removed or demoted')
+
+ def _require_actor_workspace(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
+ if actor.workspace_uuid != workspace_uuid or actor.status != MembershipStatus.ACTIVE.value:
+ raise WorkspaceNotFoundError('Workspace not found')
+
+ def _require_member_manager(self, actor: WorkspaceMembership, workspace_uuid: str) -> None:
+ self._require_actor_workspace(actor, workspace_uuid)
+ if actor.role not in {MembershipRole.OWNER.value, MembershipRole.ADMIN.value}:
+ raise MembershipPermissionError('Member management permission is required')
+
+ def _require_can_manage_target(
+ self,
+ actor: WorkspaceMembership,
+ target: WorkspaceMembership,
+ *,
+ new_role: str | None = None,
+ ) -> None:
+ if actor.role == MembershipRole.ADMIN.value and (
+ target.role == MembershipRole.OWNER.value or new_role == MembershipRole.OWNER.value
+ ):
+ raise MembershipPermissionError('Admins cannot manage Workspace owners')
+
+ @staticmethod
+ def _utcnow() -> datetime.datetime:
+ return datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+
+ async def _run(
+ self,
+ operation: Callable[[AsyncSession], Awaitable[T]],
+ *,
+ session: AsyncSession | None,
+ read_only: bool = False,
+ ) -> T:
+ if session is not None:
+ return await operation(session)
+ current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)()
+ if current_session is not None:
+ return await operation(current_session)
+ if getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime':
+ require_current_session = getattr(self.ap.persistence_mgr, 'require_current_session', None)
+ if callable(require_current_session):
+ require_current_session()
+ raise RuntimeError('Cloud collaboration services require an explicit persistence unit of work')
+ async with self._session_factory()() as owned_session:
+ if read_only:
+ return await operation(owned_session)
+ async with owned_session.begin():
+ return await operation(owned_session)
diff --git a/src/langbot/pkg/workspace/entities.py b/src/langbot/pkg/workspace/entities.py
new file mode 100644
index 000000000..77ef4914f
--- /dev/null
+++ b/src/langbot/pkg/workspace/entities.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class WorkspaceExecutionBinding:
+ """Core-neutral binding for one validated Workspace execution generation."""
+
+ instance_uuid: str
+ workspace_uuid: str
+ placement_generation: int
+ write_fenced: bool
+ state: str
diff --git a/src/langbot/pkg/workspace/errors.py b/src/langbot/pkg/workspace/errors.py
new file mode 100644
index 000000000..5e93171ac
--- /dev/null
+++ b/src/langbot/pkg/workspace/errors.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+
+class WorkspaceError(Exception):
+ """Base error for workspace directory operations."""
+
+
+class WorkspaceNotFoundError(WorkspaceError):
+ """Raised when the instance does not have its required local workspace."""
+
+
+class WorkspaceInvariantError(WorkspaceError):
+ """Raised when persisted workspace state violates a tenancy invariant."""
+
+
+class WorkspaceLimitExceededError(WorkspaceError):
+ """Raised when OSS code attempts to create a second local workspace."""
+
+ code = 'edition_limit'
+
+
+class WorkspaceOwnerAlreadyExistsError(WorkspaceError):
+ """Raised when another account already owns the singleton workspace."""
+
+
+class WorkspaceExecutionUnavailableError(WorkspaceError):
+ """Raised when a Workspace cannot accept work in its current execution state."""
+
+
+class WorkspaceGenerationMismatchError(WorkspaceExecutionUnavailableError):
+ """Raised when a caller holds a stale Workspace placement generation."""
diff --git a/src/langbot/pkg/workspace/invitation_delivery.py b/src/langbot/pkg/workspace/invitation_delivery.py
new file mode 100644
index 000000000..a3e2e8c86
--- /dev/null
+++ b/src/langbot/pkg/workspace/invitation_delivery.py
@@ -0,0 +1,314 @@
+from __future__ import annotations
+
+import asyncio
+import dataclasses
+import os
+import smtplib
+import ssl
+import typing
+from email.message import EmailMessage
+from urllib.parse import quote
+
+import httpx
+
+from ..utils import httpclient
+
+if typing.TYPE_CHECKING:
+ from ..core.app import Application
+
+
+DeliveryStatus = typing.Literal['sent', 'link_only', 'failed']
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class InvitationDeliveryResult:
+ status: DeliveryStatus
+ provider: str | None
+
+ def to_public_dict(self) -> dict[str, str | None]:
+ return {'status': self.status, 'provider': self.provider}
+
+
+@dataclasses.dataclass(frozen=True, slots=True)
+class _EmailConfig:
+ provider: typing.Literal['resend', 'smtp'] | None
+ sender: str
+ resend_api_key: str
+ resend_api_url: str
+ smtp_host: str
+ smtp_port: int
+ smtp_username: str
+ smtp_password: str
+ smtp_starttls: bool
+ smtp_ssl: bool
+ timeout: float
+
+
+class InvitationDeliveryService:
+ """Optional Workspace invitation email delivery.
+
+ The invitation link is always returned to the caller. Email failures are
+ reported as non-secret status and never invalidate the persisted invite.
+ """
+
+ def __init__(self, ap: Application) -> None:
+ self.ap = ap
+
+ def capability(self) -> dict[str, str | bool | None]:
+ config = self._email_config()
+ return {'enabled': config.provider is not None, 'provider': config.provider}
+
+ def build_invitation_link(self, token: str) -> str:
+ base_url = self._public_web_url().rstrip('/')
+ return f'{base_url}/invitations/accept#token={quote(token, safe="")}'
+
+ async def deliver_invitation(
+ self,
+ *,
+ recipient_email: str,
+ workspace_name: str,
+ invitation_link: str,
+ ) -> InvitationDeliveryResult:
+ config = self._email_config()
+ if config.provider is None:
+ return InvitationDeliveryResult(status='link_only', provider=None)
+
+ try:
+ if config.provider == 'resend':
+ sent = await self._send_resend(config, recipient_email, workspace_name, invitation_link)
+ else:
+ sent = await self._send_smtp(config, recipient_email, workspace_name, invitation_link)
+ except Exception as exc:
+ self._log_delivery_failure(config.provider, exc)
+ sent = False
+
+ return InvitationDeliveryResult(
+ status='sent' if sent else 'failed',
+ provider=config.provider,
+ )
+
+ async def _send_resend(
+ self,
+ config: _EmailConfig,
+ recipient_email: str,
+ workspace_name: str,
+ invitation_link: str,
+ ) -> bool:
+ payload = {
+ 'from': config.sender,
+ 'to': [recipient_email],
+ 'subject': f'You were invited to {workspace_name}',
+ 'text': self._plain_text(workspace_name, invitation_link),
+ 'html': self._html(workspace_name, invitation_link),
+ }
+ async with httpx.AsyncClient(
+ timeout=httpx.Timeout(config.timeout),
+ trust_env=True,
+ event_hooks=httpclient.httpx_response_limit_hooks(),
+ ) as client:
+ response = await client.post(
+ config.resend_api_url,
+ headers={'Authorization': f'Bearer {config.resend_api_key}'},
+ json=payload,
+ )
+ if response.status_code >= 400:
+ self._log_delivery_failure(
+ config.provider or 'resend', RuntimeError(f'Resend returned {response.status_code}')
+ )
+ return False
+ return True
+
+ async def _send_smtp(
+ self,
+ config: _EmailConfig,
+ recipient_email: str,
+ workspace_name: str,
+ invitation_link: str,
+ ) -> bool:
+ message = EmailMessage()
+ message['From'] = config.sender
+ message['To'] = recipient_email
+ message['Subject'] = f'You were invited to {workspace_name}'
+ message.set_content(self._plain_text(workspace_name, invitation_link))
+ message.add_alternative(self._html(workspace_name, invitation_link), subtype='html')
+
+ return await asyncio.to_thread(self._send_smtp_sync, config, message)
+
+ @staticmethod
+ def _send_smtp_sync(config: _EmailConfig, message: EmailMessage) -> bool:
+ smtp_cls = smtplib.SMTP_SSL if config.smtp_ssl else smtplib.SMTP
+ context = ssl.create_default_context()
+ with smtp_cls(config.smtp_host, config.smtp_port, timeout=config.timeout) as smtp:
+ if config.smtp_starttls and not config.smtp_ssl:
+ smtp.starttls(context=context)
+ if config.smtp_username:
+ smtp.login(config.smtp_username, config.smtp_password)
+ smtp.send_message(message)
+ return True
+
+ def _email_config(self) -> _EmailConfig:
+ data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
+ email = data.get('workspace', {}).get('invitations', {}).get('email', {})
+ if not isinstance(email, dict):
+ email = {}
+ raw_provider = self._env('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', email.get('provider', ''))
+ raw_provider = str(raw_provider or '').strip().casefold()
+ provider: typing.Literal['resend', 'smtp'] | None
+ provider = raw_provider if raw_provider in {'resend', 'smtp'} else None
+ sender = str(self._env('WORKSPACE__INVITATIONS__EMAIL__FROM', email.get('from', '')) or '').strip()
+ timeout = self._number(
+ self._env('WORKSPACE__INVITATIONS__EMAIL__TIMEOUT_SECONDS', email.get('timeout_seconds', 10)),
+ 10.0,
+ )
+
+ resend = email.get('resend', {})
+ if not isinstance(resend, dict):
+ resend = {}
+ smtp_config = email.get('smtp', {})
+ if not isinstance(smtp_config, dict):
+ smtp_config = {}
+
+ resend_api_key = str(
+ self._env('WORKSPACE__INVITATIONS__EMAIL__RESEND__API_KEY', resend.get('api_key', '')) or ''
+ ).strip()
+ resend_api_url = str(
+ self._env(
+ 'WORKSPACE__INVITATIONS__EMAIL__RESEND__API_URL',
+ resend.get('api_url', 'https://api.resend.com/emails'),
+ )
+ or ''
+ ).strip()
+ smtp_host = str(
+ self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', smtp_config.get('host', '')) or ''
+ ).strip()
+ smtp_port = int(
+ self._number(self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__PORT', smtp_config.get('port', 587)), 587)
+ )
+ smtp_username = str(
+ self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__USERNAME', smtp_config.get('username', '')) or ''
+ ).strip()
+ smtp_password = str(
+ self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', smtp_config.get('password', '')) or ''
+ )
+ smtp_starttls = self._bool(
+ self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__STARTTLS', smtp_config.get('starttls', True))
+ )
+ smtp_ssl = self._bool(self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__SSL', smtp_config.get('ssl', False)))
+
+ if provider == 'resend' and not (sender and resend_api_key and resend_api_url):
+ provider = None
+ elif provider == 'smtp' and not (sender and smtp_host):
+ provider = None
+
+ return _EmailConfig(
+ provider=provider,
+ sender=sender,
+ resend_api_key=resend_api_key,
+ resend_api_url=resend_api_url,
+ smtp_host=smtp_host,
+ smtp_port=smtp_port,
+ smtp_username=smtp_username,
+ smtp_password=smtp_password,
+ smtp_starttls=smtp_starttls,
+ smtp_ssl=smtp_ssl,
+ timeout=timeout,
+ )
+
+ def _public_web_url(self) -> str:
+ data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {}
+ invitations = data.get('workspace', {}).get('invitations', {})
+ configured = ''
+ if isinstance(invitations, dict):
+ configured = str(
+ self._env('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', invitations.get('public_web_url', '')) or ''
+ ).strip()
+ if configured:
+ return configured
+ api = data.get('api', {})
+ if isinstance(api, dict):
+ webui_url = str(api.get('webui_url', '') or '').strip()
+ if webui_url:
+ return webui_url
+ webhook_prefix = str(api.get('webhook_prefix', '') or '').strip()
+ if webhook_prefix:
+ return webhook_prefix
+ port = api.get('port', 5300)
+ else:
+ port = 5300
+ return f'http://127.0.0.1:{port}'
+
+ @staticmethod
+ def _plain_text(workspace_name: str, invitation_link: str) -> str:
+ return (
+ 'You have been invited to LangBot Cloud\n\n'
+ f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
+ f'Accept invitation: {invitation_link}\n\n'
+ 'This secure invitation expires in 7 days and can only be accepted by the email address '
+ 'it was sent to. If you were not expecting it, you can safely ignore this email.\n'
+ )
+
+ @staticmethod
+ def _html(workspace_name: str, invitation_link: str) -> str:
+ import html
+
+ escaped_workspace = html.escape(workspace_name, quote=True)
+ escaped_link = html.escape(invitation_link, quote=True)
+ return f'''
+
+
+
+
+ Join {escaped_workspace} on LangBot Cloud
+
+
+ You have been invited to join {escaped_workspace} on LangBot Cloud.
+
+
+
+ |
+ LangBot Cloud
+ You’re invited
+ |
+ |
+ You have been invited to collaborate in this Workspace:
+ {escaped_workspace}
+
+ This invitation expires in 7 days and is bound to the email address that received it.
+ If the button does not work, copy and paste this URL into your browser:
+ {escaped_link}
+ |
+ | If you were not expecting this invitation, you can safely ignore this email. |
+
+ |
+
+
+'''
+
+ @staticmethod
+ def _number(value: typing.Any, default: float) -> float:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return default
+
+ @staticmethod
+ def _bool(value: typing.Any) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in {'true', '1', 'yes', 'on'}
+ return bool(value)
+
+ @staticmethod
+ def _env(name: str, fallback: typing.Any) -> typing.Any:
+ value = os.environ.get(name)
+ if value is None:
+ return fallback
+ return value
+
+ def _log_delivery_failure(self, provider: str, exc: Exception) -> None:
+ logger = getattr(self.ap, 'logger', None)
+ if logger is not None:
+ logger.warning(f'Workspace invitation email delivery via {provider} failed: {exc.__class__.__name__}')
diff --git a/src/langbot/pkg/workspace/policy.py b/src/langbot/pkg/workspace/policy.py
new file mode 100644
index 000000000..bfdca864d
--- /dev/null
+++ b/src/langbot/pkg/workspace/policy.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .errors import WorkspaceLimitExceededError
+
+
+@dataclass(frozen=True, slots=True)
+class SingleWorkspacePolicy:
+ """OSS edition policy: one local workspace with unrestricted membership count."""
+
+ workspace_limit: int = 1
+ members_enabled: bool = True
+ invitations_enabled: bool = True
+ fixed_rbac_enabled: bool = True
+ multi_workspace_enabled: bool = False
+
+ def require_workspace_creation_allowed(self, current_workspace_count: int) -> None:
+ if current_workspace_count >= self.workspace_limit:
+ raise WorkspaceLimitExceededError(f'This LangBot edition allows at most {self.workspace_limit} workspace')
+
+
+@dataclass(frozen=True, slots=True)
+class CloudWorkspacePolicy:
+ """SaaS data-plane policy backed by closed control-plane projections.
+
+ Core never creates a cloud Workspace or mutates its directory. The policy
+ only enables explicit selection among already projected Workspaces.
+ """
+
+ workspace_limit: int = 0
+ members_enabled: bool = True
+ invitations_enabled: bool = False
+ fixed_rbac_enabled: bool = True
+ multi_workspace_enabled: bool = True
+
+ def require_workspace_creation_allowed(self, current_workspace_count: int) -> None:
+ del current_workspace_count
+ raise WorkspaceLimitExceededError('Cloud Workspaces are created by the SaaS control plane')
+
+
+def open_core_workspace_policy() -> SingleWorkspacePolicy:
+ """Return the only policy the open-source bootstrap may activate.
+
+ ``system.edition`` and other local configuration are deliberately absent
+ from this boundary. A future closed Cloud bootstrap must first verify its
+ signed InstanceManifest and then explicitly construct the cloud policy.
+ """
+
+ return SingleWorkspacePolicy()
diff --git a/src/langbot/pkg/workspace/repository.py b/src/langbot/pkg/workspace/repository.py
new file mode 100644
index 000000000..374fa9b0b
--- /dev/null
+++ b/src/langbot/pkg/workspace/repository.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import sqlalchemy
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from ..entity.persistence.workspace import (
+ MembershipRole,
+ MembershipStatus,
+ Workspace,
+ WorkspaceExecutionState,
+ WorkspaceMembership,
+ WorkspaceSource,
+)
+
+
+class WorkspaceRepository:
+ """Transaction-bound persistence operations for the workspace directory."""
+
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+
+ async def count_local_workspaces(self, instance_uuid: str) -> int:
+ statement = (
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(Workspace)
+ .where(
+ Workspace.instance_uuid == instance_uuid,
+ Workspace.source == WorkspaceSource.LOCAL.value,
+ )
+ )
+ return int((await self.session.scalar(statement)) or 0)
+
+ async def list_local_workspaces(self, instance_uuid: str, *, for_update: bool = False) -> list[Workspace]:
+ statement = (
+ sqlalchemy.select(Workspace)
+ .where(
+ Workspace.instance_uuid == instance_uuid,
+ Workspace.source == WorkspaceSource.LOCAL.value,
+ )
+ .order_by(Workspace.created_at, Workspace.uuid)
+ )
+ if for_update:
+ statement = statement.with_for_update()
+ return list((await self.session.scalars(statement)).all())
+
+ async def get_workspace(self, workspace_uuid: str) -> Workspace | None:
+ return await self.session.get(Workspace, workspace_uuid)
+
+ def add_workspace(self, workspace: Workspace) -> None:
+ self.session.add(workspace)
+
+ async def get_execution_state(self, workspace_uuid: str) -> WorkspaceExecutionState | None:
+ return await self.session.get(WorkspaceExecutionState, workspace_uuid)
+
+ def add_execution_state(self, execution_state: WorkspaceExecutionState) -> None:
+ self.session.add(execution_state)
+
+ async def get_membership(self, workspace_uuid: str, account_uuid: str) -> WorkspaceMembership | None:
+ statement = sqlalchemy.select(WorkspaceMembership).where(
+ WorkspaceMembership.workspace_uuid == workspace_uuid,
+ WorkspaceMembership.account_uuid == account_uuid,
+ )
+ return await self.session.scalar(statement)
+
+ async def get_active_owner(self, workspace_uuid: str, *, for_update: bool = False) -> WorkspaceMembership | None:
+ statement = (
+ sqlalchemy.select(WorkspaceMembership)
+ .where(
+ WorkspaceMembership.workspace_uuid == workspace_uuid,
+ WorkspaceMembership.role == MembershipRole.OWNER.value,
+ WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
+ )
+ .order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
+ .limit(1)
+ )
+ if for_update:
+ statement = statement.with_for_update()
+ return await self.session.scalar(statement)
+
+ def add_membership(self, membership: WorkspaceMembership) -> None:
+ self.session.add(membership)
+
+ async def flush(self) -> None:
+ await self.session.flush()
diff --git a/src/langbot/pkg/workspace/service.py b/src/langbot/pkg/workspace/service.py
new file mode 100644
index 000000000..bab30d4f2
--- /dev/null
+++ b/src/langbot/pkg/workspace/service.py
@@ -0,0 +1,555 @@
+from __future__ import annotations
+
+import datetime
+import uuid
+import typing
+from collections.abc import Awaitable, Callable
+from typing import TypeVar
+
+import sqlalchemy
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from ..entity.persistence.workspace import (
+ MembershipRole,
+ MembershipStatus,
+ Workspace,
+ WorkspaceExecutionSource,
+ WorkspaceExecutionState,
+ WorkspaceExecutionStatus,
+ WorkspaceMembership,
+ WorkspaceSource,
+ WorkspaceStatus,
+ WorkspaceType,
+)
+from ..utils import constants
+from .errors import (
+ WorkspaceExecutionUnavailableError,
+ WorkspaceGenerationMismatchError,
+ WorkspaceInvariantError,
+ WorkspaceNotFoundError,
+ WorkspaceOwnerAlreadyExistsError,
+)
+from .entities import WorkspaceExecutionBinding
+from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
+from .repository import WorkspaceRepository
+
+
+T = TypeVar('T')
+
+if typing.TYPE_CHECKING:
+ from ..core.app import Application
+
+
+class WorkspaceService:
+ """Local workspace lifecycle service used by OSS bootstrap and account flows."""
+
+ def __init__(
+ self,
+ ap: Application,
+ *,
+ policy: SingleWorkspacePolicy | CloudWorkspacePolicy | None = None,
+ instance_uuid: str | None = None,
+ ) -> None:
+ self.ap = ap
+ self.policy = policy or SingleWorkspacePolicy()
+ self._instance_uuid = instance_uuid
+ self._startup_execution_bindings: (
+ tuple[
+ WorkspaceExecutionBinding,
+ ...,
+ ]
+ | None
+ ) = None
+
+ @property
+ def instance_uuid(self) -> str:
+ instance_uuid = (self._instance_uuid or constants.instance_id).strip()
+ if not instance_uuid:
+ raise WorkspaceInvariantError('LangBot instance UUID is empty')
+ return instance_uuid
+
+ async def get_workspace(
+ self,
+ workspace_uuid: str,
+ *,
+ session: AsyncSession | None = None,
+ ) -> Workspace:
+ """Load one Workspace projected onto this LangBot instance."""
+
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if session is None and callable(tenant_uow):
+ async with tenant_uow(workspace_uuid) as uow:
+ return await self.get_workspace(workspace_uuid, session=uow.session)
+
+ async def operation(repository: WorkspaceRepository) -> Workspace:
+ workspace = await repository.get_workspace(workspace_uuid)
+ if workspace is None or workspace.instance_uuid != self.instance_uuid:
+ raise WorkspaceNotFoundError('Workspace not found')
+ return workspace
+
+ return await self._run(operation, session=session)
+
+ async def get_singleton_workspace(self, *, session: AsyncSession | None = None) -> Workspace:
+ async def operation(repository: WorkspaceRepository) -> Workspace:
+ workspaces = await repository.list_local_workspaces(self.instance_uuid)
+ if not workspaces:
+ raise WorkspaceNotFoundError('The local workspace has not been initialized')
+ if len(workspaces) != 1:
+ raise WorkspaceInvariantError(
+ f'Expected one local workspace for {self.instance_uuid!r}, found {len(workspaces)}'
+ )
+ return workspaces[0]
+
+ return await self._run(operation, session=session)
+
+ async def get_execution_state(
+ self,
+ workspace_uuid: str,
+ *,
+ session: AsyncSession | None = None,
+ ) -> WorkspaceExecutionState:
+ """Load a Workspace execution state and validate its instance binding."""
+
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if session is None and callable(tenant_uow):
+ async with tenant_uow(workspace_uuid) as uow:
+ return await self.get_execution_state(workspace_uuid, session=uow.session)
+
+ async def operation(repository: WorkspaceRepository) -> WorkspaceExecutionState:
+ execution_state = await repository.get_execution_state(workspace_uuid)
+ if execution_state is None:
+ raise WorkspaceExecutionUnavailableError(f'Workspace {workspace_uuid!r} has no execution state')
+ if execution_state.instance_uuid != self.instance_uuid:
+ raise WorkspaceInvariantError(
+ f'Workspace {workspace_uuid!r} execution state belongs to another instance'
+ )
+ return execution_state
+
+ return await self._run(operation, session=session)
+
+ async def list_active_execution_bindings(self) -> list[WorkspaceExecutionBinding]:
+ """Discover this instance's active Workspaces, then validate each tenant projection.
+
+ The instance discovery transaction may only reveal active, unfenced
+ execution-state identifiers. It is closed before any Workspace data is
+ read; every returned binding is revalidated inside its own tenant unit
+ of work.
+ """
+
+ self._require_deployment_admission()
+ self._require_directory_projection()
+ if self._startup_execution_bindings is not None:
+ return list(self._startup_execution_bindings)
+ return await self._discover_active_execution_bindings()
+
+ async def prime_startup_execution_bindings(
+ self,
+ ) -> list[WorkspaceExecutionBinding]:
+ """Freeze one validated binding snapshot during the serial boot graph.
+
+ Directory synchronization starts only after ``BuildAppStage`` has
+ finished, so all runtime managers would otherwise rediscover and
+ revalidate the same active Workspace set independently. A boot-scoped
+ immutable snapshot removes those repeated tenant transactions without
+ weakening request-time generation checks.
+ """
+
+ self._require_deployment_admission()
+ self._require_directory_projection()
+ if self._startup_execution_bindings is None:
+ self._startup_execution_bindings = tuple(await self._discover_active_execution_bindings())
+ return list(self._startup_execution_bindings)
+
+ def release_startup_execution_bindings(self) -> None:
+ """Release the boot-only projection snapshot before background sync."""
+
+ self._startup_execution_bindings = None
+
+ async def _discover_active_execution_bindings(
+ self,
+ ) -> list[WorkspaceExecutionBinding]:
+ instance_discovery_uow = getattr(self.ap.persistence_mgr, 'instance_discovery_uow', None)
+ statement = (
+ sqlalchemy.select(WorkspaceExecutionState.workspace_uuid)
+ .where(
+ WorkspaceExecutionState.instance_uuid == self.instance_uuid,
+ WorkspaceExecutionState.state == WorkspaceExecutionStatus.ACTIVE.value,
+ WorkspaceExecutionState.write_fenced == sqlalchemy.false(),
+ )
+ .order_by(WorkspaceExecutionState.workspace_uuid)
+ )
+ if callable(instance_discovery_uow):
+ async with instance_discovery_uow(self.instance_uuid) as uow:
+ result = await uow.session.execute(statement)
+ workspace_uuids = list(result.scalars().all())
+ else:
+ # Compatibility for lightweight test doubles. Production managers
+ # always expose the explicit discovery scope.
+ result = await self.ap.persistence_mgr.execute_async(statement)
+ workspace_uuids = list(result.scalars().all())
+
+ bindings: list[WorkspaceExecutionBinding] = []
+ for workspace_uuid in workspace_uuids:
+ try:
+ bindings.append(await self.get_execution_binding(workspace_uuid))
+ except (
+ WorkspaceExecutionUnavailableError,
+ WorkspaceInvariantError,
+ WorkspaceNotFoundError,
+ ) as exc:
+ self.ap.logger.warning(f'Skipping invalid Workspace execution projection {workspace_uuid!r}: {exc}')
+ return bindings
+
+ async def get_execution_binding(
+ self,
+ workspace_uuid: str | None = None,
+ *,
+ expected_generation: int | None = None,
+ session: AsyncSession | None = None,
+ _require_local: bool = False,
+ ) -> WorkspaceExecutionBinding:
+ """Resolve an active, unfenced binding projected onto this instance.
+
+ SaaS Workspaces are projected by a closed control plane, but Core still
+ validates the local projection and execution fence. Callers never infer
+ a Workspace from source or recency.
+ """
+
+ self._require_deployment_admission()
+ self._require_directory_projection()
+
+ tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
+ if session is None and workspace_uuid is not None and callable(tenant_uow):
+ async with tenant_uow(workspace_uuid) as uow:
+ return await self.get_execution_binding(
+ workspace_uuid,
+ expected_generation=expected_generation,
+ session=uow.session,
+ _require_local=_require_local,
+ )
+
+ async def operation(repository: WorkspaceRepository) -> WorkspaceExecutionBinding:
+ if workspace_uuid is None:
+ workspaces = await repository.list_local_workspaces(self.instance_uuid)
+ if not workspaces:
+ raise WorkspaceNotFoundError('The local workspace has not been initialized')
+ if len(workspaces) != 1:
+ raise WorkspaceInvariantError(
+ f'Expected one local workspace for {self.instance_uuid!r}, found {len(workspaces)}'
+ )
+ workspace = workspaces[0]
+ else:
+ workspace = await repository.get_workspace(workspace_uuid)
+ if workspace is None:
+ raise WorkspaceNotFoundError(f'Workspace {workspace_uuid!r} does not exist')
+
+ if workspace.instance_uuid != self.instance_uuid:
+ raise WorkspaceInvariantError(f'Workspace {workspace.uuid!r} belongs to another instance')
+ if _require_local and workspace.source != WorkspaceSource.LOCAL.value:
+ raise WorkspaceInvariantError(f'Workspace {workspace.uuid!r} is not an OSS local workspace')
+ if workspace.status != WorkspaceStatus.ACTIVE.value:
+ raise WorkspaceExecutionUnavailableError(f'Workspace {workspace.uuid!r} is not active')
+
+ execution_state = await repository.get_execution_state(workspace.uuid)
+ if execution_state is None:
+ raise WorkspaceExecutionUnavailableError(f'Workspace {workspace.uuid!r} has no execution state')
+ if execution_state.instance_uuid != self.instance_uuid:
+ raise WorkspaceInvariantError(
+ f'Workspace {workspace.uuid!r} execution state belongs to another instance'
+ )
+ expected_source = (
+ WorkspaceExecutionSource.LOCAL.value
+ if workspace.source == WorkspaceSource.LOCAL.value
+ else WorkspaceExecutionSource.CLOUD.value
+ )
+ if execution_state.source != expected_source:
+ raise WorkspaceInvariantError(
+ f'Workspace {workspace.uuid!r} execution source does not match its directory source'
+ )
+ if execution_state.state != WorkspaceExecutionStatus.ACTIVE.value or execution_state.write_fenced:
+ raise WorkspaceExecutionUnavailableError(f'Workspace {workspace.uuid!r} execution is unavailable')
+ if execution_state.active_generation <= 0:
+ raise WorkspaceInvariantError(f'Workspace {workspace.uuid!r} has an invalid execution generation')
+ if expected_generation is not None and execution_state.active_generation != expected_generation:
+ raise WorkspaceGenerationMismatchError(
+ f'Workspace {workspace.uuid!r} generation {execution_state.active_generation} '
+ f'does not match expected generation {expected_generation}'
+ )
+
+ return WorkspaceExecutionBinding(
+ instance_uuid=self.instance_uuid,
+ workspace_uuid=workspace.uuid,
+ placement_generation=execution_state.active_generation,
+ write_fenced=execution_state.write_fenced,
+ state=execution_state.state,
+ )
+
+ binding = await self._run(operation, session=session)
+ # The database lookup can cross the Manifest expiry boundary. Never
+ # return a binding that is already invalid at a side-effect boundary.
+ self._require_directory_projection()
+ self._require_deployment_admission()
+ return binding
+
+ async def get_local_execution_binding(
+ self,
+ workspace_uuid: str | None = None,
+ *,
+ expected_generation: int | None = None,
+ session: AsyncSession | None = None,
+ ) -> WorkspaceExecutionBinding:
+ """Resolve an active binding and require an OSS-local Workspace."""
+
+ return await self.get_execution_binding(
+ workspace_uuid,
+ expected_generation=expected_generation,
+ session=session,
+ _require_local=True,
+ )
+
+ async def get_local_execution_context(
+ self,
+ workspace_uuid: str | None = None,
+ *,
+ expected_generation: int | None = None,
+ session: AsyncSession | None = None,
+ ) -> WorkspaceExecutionBinding:
+ """Compatibility alias for callers introduced during the tenancy rollout."""
+ return await self.get_local_execution_binding(
+ workspace_uuid,
+ expected_generation=expected_generation,
+ session=session,
+ )
+
+ async def ensure_singleton_workspace(
+ self,
+ *,
+ session: AsyncSession | None = None,
+ name: str = 'Default Workspace',
+ slug: str = 'default',
+ ) -> Workspace:
+ """Create or repair the instance's single local workspace and execution state."""
+
+ async def operation(repository: WorkspaceRepository) -> Workspace:
+ workspaces = await repository.list_local_workspaces(self.instance_uuid, for_update=True)
+ if len(workspaces) > 1:
+ raise WorkspaceInvariantError(f'Multiple local workspaces exist for instance {self.instance_uuid!r}')
+ if workspaces:
+ workspace = workspaces[0]
+ else:
+ self.policy.require_workspace_creation_allowed(0)
+ workspace = self._new_local_workspace(name=name, slug=slug)
+ repository.add_workspace(workspace)
+ await repository.flush()
+
+ await self._ensure_execution_state(repository, workspace)
+ return workspace
+
+ return await self._run(operation, session=session)
+
+ async def create_local_workspace(
+ self,
+ *,
+ name: str,
+ slug: str,
+ created_by_account_uuid: str | None = None,
+ session: AsyncSession | None = None,
+ ) -> Workspace:
+ """Create the OSS workspace, enforcing the one-workspace edition limit."""
+
+ async def operation(repository: WorkspaceRepository) -> Workspace:
+ current_count = await repository.count_local_workspaces(self.instance_uuid)
+ self.policy.require_workspace_creation_allowed(current_count)
+
+ workspace = self._new_local_workspace(
+ name=name,
+ slug=slug,
+ created_by_account_uuid=created_by_account_uuid,
+ )
+ repository.add_workspace(workspace)
+ await repository.flush()
+ await self._ensure_execution_state(repository, workspace)
+ if created_by_account_uuid is not None:
+ await self._claim_initial_owner(repository, workspace, created_by_account_uuid)
+ return workspace
+
+ return await self._run(operation, session=session)
+
+ async def claim_initial_owner(
+ self,
+ account_uuid: str,
+ *,
+ session: AsyncSession | None = None,
+ ) -> WorkspaceMembership:
+ """Atomically claim an ownerless singleton workspace for the first account."""
+
+ async def operation(repository: WorkspaceRepository) -> WorkspaceMembership:
+ workspaces = await repository.list_local_workspaces(self.instance_uuid, for_update=True)
+ if not workspaces:
+ self.policy.require_workspace_creation_allowed(0)
+ workspace = self._new_local_workspace(name='Default Workspace', slug='default')
+ repository.add_workspace(workspace)
+ await repository.flush()
+ elif len(workspaces) == 1:
+ workspace = workspaces[0]
+ else:
+ raise WorkspaceInvariantError(f'Multiple local workspaces exist for instance {self.instance_uuid!r}')
+ await self._ensure_execution_state(repository, workspace)
+ return await self._claim_initial_owner(repository, workspace, account_uuid)
+
+ return await self._run(operation, session=session)
+
+ async def bootstrap_local_account(
+ self,
+ account_uuid: str,
+ *,
+ session: AsyncSession | None = None,
+ ) -> tuple[Workspace, WorkspaceMembership]:
+ """Bind the first local account to the singleton workspace as owner."""
+
+ async def operation(repository: WorkspaceRepository) -> tuple[Workspace, WorkspaceMembership]:
+ workspaces = await repository.list_local_workspaces(self.instance_uuid, for_update=True)
+ if not workspaces:
+ self.policy.require_workspace_creation_allowed(0)
+ workspace = self._new_local_workspace(name='Default Workspace', slug='default')
+ repository.add_workspace(workspace)
+ await repository.flush()
+ elif len(workspaces) == 1:
+ workspace = workspaces[0]
+ else:
+ raise WorkspaceInvariantError(f'Multiple local workspaces exist for instance {self.instance_uuid!r}')
+
+ await self._ensure_execution_state(repository, workspace)
+ membership = await self._claim_initial_owner(repository, workspace, account_uuid)
+ return workspace, membership
+
+ return await self._run(operation, session=session)
+
+ async def _claim_initial_owner(
+ self,
+ repository: WorkspaceRepository,
+ workspace: Workspace,
+ account_uuid: str,
+ ) -> WorkspaceMembership:
+ active_owner = await repository.get_active_owner(workspace.uuid, for_update=True)
+ if active_owner is not None and active_owner.account_uuid != account_uuid:
+ raise WorkspaceOwnerAlreadyExistsError(f'Workspace {workspace.uuid!r} already has an owner')
+ if active_owner is not None:
+ if workspace.created_by_account_uuid is None:
+ workspace.created_by_account_uuid = account_uuid
+ await repository.flush()
+ return active_owner
+
+ membership = await repository.get_membership(workspace.uuid, account_uuid)
+ joined_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+ if membership is None:
+ membership = WorkspaceMembership(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace.uuid,
+ account_uuid=account_uuid,
+ role=MembershipRole.OWNER.value,
+ status=MembershipStatus.ACTIVE.value,
+ joined_at=joined_at,
+ projection_revision=0,
+ )
+ repository.add_membership(membership)
+ else:
+ membership.role = MembershipRole.OWNER.value
+ membership.status = MembershipStatus.ACTIVE.value
+ membership.joined_at = membership.joined_at or joined_at
+
+ if workspace.created_by_account_uuid is None:
+ workspace.created_by_account_uuid = account_uuid
+ await repository.flush()
+ return membership
+
+ async def _ensure_execution_state(
+ self,
+ repository: WorkspaceRepository,
+ workspace: Workspace,
+ ) -> WorkspaceExecutionState:
+ execution_state = await repository.get_execution_state(workspace.uuid)
+ if execution_state is not None:
+ if execution_state.instance_uuid != self.instance_uuid:
+ raise WorkspaceInvariantError(
+ f'Workspace {workspace.uuid!r} execution state belongs to another instance'
+ )
+ return execution_state
+
+ execution_state = WorkspaceExecutionState(
+ workspace_uuid=workspace.uuid,
+ instance_uuid=self.instance_uuid,
+ active_generation=1,
+ state=WorkspaceExecutionStatus.ACTIVE.value,
+ write_fenced=False,
+ source=WorkspaceExecutionSource.LOCAL.value,
+ desired_state_revision=0,
+ )
+ repository.add_execution_state(execution_state)
+ await repository.flush()
+ return execution_state
+
+ def _new_local_workspace(
+ self,
+ *,
+ name: str,
+ slug: str,
+ created_by_account_uuid: str | None = None,
+ ) -> Workspace:
+ return Workspace(
+ uuid=str(uuid.uuid4()),
+ instance_uuid=self.instance_uuid,
+ name=name,
+ slug=slug,
+ type=WorkspaceType.TEAM.value,
+ status=WorkspaceStatus.ACTIVE.value,
+ created_by_account_uuid=created_by_account_uuid,
+ source=WorkspaceSource.LOCAL.value,
+ projection_revision=0,
+ )
+
+ async def _run(
+ self,
+ operation: Callable[[WorkspaceRepository], Awaitable[T]],
+ *,
+ session: AsyncSession | None,
+ ) -> T:
+ if session is not None:
+ return await operation(WorkspaceRepository(session))
+
+ current_session = getattr(self.ap.persistence_mgr, 'current_session', lambda: None)
+ active_session = current_session()
+ if active_session is not None:
+ return await operation(WorkspaceRepository(active_session))
+ if getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime':
+ # Do not let service-local session factories silently bypass the
+ # request/task scope enforced by PersistenceManager.
+ require_current_session = getattr(self.ap.persistence_mgr, 'require_current_session', None)
+ if callable(require_current_session):
+ require_current_session()
+ raise RuntimeError('Cloud Workspace services require an explicit persistence unit of work')
+
+ session_factory = async_sessionmaker(
+ self.ap.persistence_mgr.get_db_engine(),
+ expire_on_commit=False,
+ )
+ async with session_factory() as owned_session:
+ async with owned_session.begin():
+ return await operation(WorkspaceRepository(owned_session))
+
+ def _require_deployment_admission(self) -> None:
+ guard = getattr(self.ap, 'deployment_admission', None)
+ if guard is not None:
+ guard.require_active()
+
+ def _require_directory_projection(self) -> None:
+ deployment = getattr(self.ap, 'deployment', None)
+ if deployment is None or not getattr(deployment, 'multi_workspace_enabled', False):
+ return
+ projection = getattr(self.ap, 'directory_projection_service', None)
+ if projection is None:
+ raise WorkspaceExecutionUnavailableError('Cloud directory projection is unavailable')
+ try:
+ projection.require_ready()
+ except RuntimeError as exc:
+ raise WorkspaceExecutionUnavailableError(str(exc)) from exc
diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml
index b5870b8eb..b0bb10c93 100644
--- a/src/langbot/templates/config.yaml
+++ b/src/langbot/templates/config.yaml
@@ -2,12 +2,42 @@ api:
port: 5300
webhook_prefix: 'http://127.0.0.1:5300'
extra_webhook_prefix: ''
+ # Canonical browser origin when WebUI and API use different origins in
+ # development (for example http://localhost:3000). Production bundled UI
+ # may leave this empty when webhook_prefix already has the browser origin.
+ # OAuth redirects trust only these server-side values, never request Host
+ # or Origin headers.
+ webui_url: ''
# Global API key for the HTTP service API and the MCP server. When set to a
# non-empty string, this key is accepted anywhere a web-UI-created API key is
# accepted (X-API-Key header or "Authorization: Bearer "), WITHOUT any
# login session and without a database record. Leave empty to disable.
# Keep this value secret; only enable it on trusted/internal deployments.
global_api_key: ''
+workspace:
+ invitations:
+ # Public WebUI origin used to build invitation links. Leave empty to
+ # use api.webui_url, then api.webhook_prefix. Set via
+ # WORKSPACE__INVITATIONS__PUBLIC_WEB_URL in container deployments.
+ public_web_url: ''
+ email:
+ # Optional invitation email delivery. Empty provider keeps
+ # invitations link-only. Supported: resend, smtp.
+ provider: ''
+ from: ''
+ timeout_seconds: 10
+ resend:
+ api_url: 'https://api.resend.com/emails'
+ # Secret. Set via WORKSPACE__INVITATIONS__EMAIL__RESEND__API_KEY.
+ api_key: ''
+ smtp:
+ host: ''
+ port: 587
+ username: ''
+ # Secret. Set via WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD.
+ password: ''
+ starttls: true
+ ssl: false
command:
enable: true
prefix:
@@ -17,6 +47,35 @@ command:
concurrency:
pipeline: 20
session: 1
+ # Hard admission limits for queued + running pipeline queries.
+ pending_queries: 1000
+ pending_queries_per_workspace: 100
+webhooks:
+ # Bound database materialization and per-message outbound fan-out.
+ # Existing rows above this limit remain deletable through the management
+ # API, but only this many enabled destinations are dispatched.
+ # Supports WEBHOOKS__MAX_PER_WORKSPACE (hard cap: 64).
+ max_per_workspace: 16
+ # Instance-wide request admission. Delivery fails open when every slot is
+ # occupied instead of retaining an unbounded queue of webhook tasks.
+ # Supports WEBHOOKS__MAX_INFLIGHT_REQUESTS (hard cap: 128).
+ max_inflight_requests: 16
+cloud:
+ # Operational safety ceilings for the one logical Cloud instance. These
+ # are not subscription entitlements. An authoritative directory update
+ # that would exceed them is rejected atomically rather than truncated.
+ directory:
+ # Tune downward from the measured production capacity curve. Core has
+ # an absolute safety ceiling of 5,000 active Workspaces.
+ max_active_workspaces: 1000
+ # Full snapshots contain current Workspaces only. Archived tombstones
+ # are delivered through bounded per-Workspace deltas.
+ max_snapshot_workspaces: 1000
+ # Aggregate memberships accepted in one signed snapshot or delta.
+ max_snapshot_memberships: 20000
+ # Signed control-plane envelope buffered by the closed adapter before
+ # JSON/JWS verification (32 MiB; absolute maximum 64 MiB).
+ max_response_bytes: 33554432
proxy:
http: ''
https: ''
@@ -26,6 +85,15 @@ system:
recovery_key: ''
allow_modify_login_info: true
disabled_adapters: []
+ blocking_executor:
+ # All asyncio.to_thread work shares this process-wide bounded pool.
+ # Both running threads and queued calls are capped to prevent tenant
+ # bursts from creating an unbounded queue of retained request objects.
+ max_workers: 8
+ max_pending: 128
+ # One trusted Workspace can occupy at most this many running + queued
+ # slots. This must not exceed half of max_workers.
+ max_inflight_per_scope: 4
# Public outbound IP addresses of this LangBot deployment. Some platforms
# (e.g. WeCom, WeChat Official Account, QQ Official API) require the
# caller's IPs to be added to their trusted-IP / IP-whitelist settings.
@@ -37,6 +105,7 @@ system:
max_bots: -1
max_pipelines: -1
max_extensions: -1
+ max_knowledge_bases: -1
# When set to a non-empty string, every pipeline is forced to use this
# Box sandbox-scope template regardless of its own configuration, and
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
@@ -46,6 +115,32 @@ system:
task_retention:
# Keep at most this many completed async task records in memory
completed_limit: 200
+ # Bound progress output retained by one task, including running tasks.
+ max_log_chars: 200000
+ # Protect the shared process from user-triggered operation storms.
+ max_active_user_tasks: 256
+ max_active_user_tasks_per_workspace: 8
+ session_retention:
+ # Process-local conversation sessions are a cache, not durable history.
+ max_entries: 2000
+ max_entries_per_workspace: 200
+ idle_ttl_seconds: 86400
+ max_conversations_per_session: 20
+ max_messages_per_conversation: 100
+ websocket_retention:
+ # Bound live browser sockets and per-Workspace fan-out in the shared process.
+ max_connections: 1024
+ max_connections_per_workspace: 32
+ # Idle proxy runtimes are evicted when this process-local cache fills.
+ max_workspace_proxies: 1024
+ max_conversations_per_workspace: 200
+ max_messages_per_conversation: 100
+ conversation_idle_ttl_seconds: 86400
+ send_queue_size: 100
+ response_limits:
+ # Defense in depth for tenant-configured upstream providers.
+ max_generated_chars: 1048576
+ max_stream_chunks: 100000
jwt:
expire: 604800
secret: ''
@@ -54,13 +149,33 @@ database:
sqlite:
path: 'data/langbot.db'
postgresql:
+ # Optional SQLAlchemy URL (postgresql[+asyncpg]://...). When set, it
+ # overrides the structured fields and preserves TLS/query options.
+ url: ''
host: '127.0.0.1'
port: 5432
user: 'postgres'
password: 'postgres'
database: 'postgres'
+ # One bounded pool is shared by business data and Cloud pgvector.
+ pool_size: 10
+ max_overflow: 10
+ pool_timeout_seconds: 30
+ pool_recycle_seconds: 1800
+ # Applied only to Cloud runtime connections. The one-shot release
+ # migration uses its operator connection without these short limits.
+ statement_timeout_ms: 60000
+ lock_timeout_ms: 5000
+ idle_in_transaction_session_timeout_ms: 60000
+ cloud_migration:
+ # `langbot migrate --cloud` reads an operator-only PostgreSQL DSN from
+ # this environment variable. The operator role must differ from the
+ # runtime role above; never put its password in this file or CLI args.
+ operator_dsn_env: 'LANGBOT_CLOUD_MIGRATION_DSN'
vdb:
use: chroma
+ # Bound process-local collection/index handles across all Workspaces.
+ runtime_cache_limit: 1024
qdrant:
url: ''
host: localhost
@@ -82,6 +197,11 @@ vdb:
token: ''
db_name: ''
pgvector:
+ # SaaS/shared-schema deployments reuse database.postgresql. OSS can
+ # keep this false when deliberately using an external pgvector DB.
+ use_business_database: false
+ # Release migrations create one partial ANN index per enabled value.
+ allowed_dimensions: [384, 512, 768, 1024, 1536]
host: '127.0.0.1'
port: 5433
database: 'langbot'
@@ -99,6 +219,9 @@ vdb:
request_timeout: 5000 # per-request timeout in ms (glide default 250ms is too low for KNN)
storage:
use: local
+ # Bound every object materialized into Core memory. Built-in Local/S3
+ # providers enforce this while reading (hard cap: 64 MiB).
+ max_object_read_bytes: 10485760
cleanup:
# Enable periodic cleanup of local/S3 uploaded files and old log files
enabled: true
@@ -108,21 +231,79 @@ storage:
uploaded_file_retention_days: 7
# LangBot log files older than this many days will be deleted
log_retention_days: 3
+ # Bound per-Workspace file cleanup and diagnostic candidate lists.
+ # Supports STORAGE__CLEANUP__MAX_FILES_PER_RUN (hard cap: 10000).
+ max_files_per_run: 1000
s3:
endpoint_url: ''
access_key_id: ''
secret_access_key: ''
region: 'us-east-1'
bucket: 'langbot-storage'
+ # boto3 is synchronous; bound the number of operations delegated to
+ # worker threads so an S3 slowdown cannot saturate the process.
+ max_concurrency: 16
plugin:
enable: true
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
+ worker:
+ # Instance-wide maximum for every plugin installation. Plugin
+ # manifests cannot raise or override these limits.
+ max_cpus: 1.0
+ max_memory_mb: 512
+ max_pids: 128
+ max_open_files: 256
+ max_file_size_mb: 512
+ # Instance-wide admission budgets. The effective worker count is the
+ # lowest of max_workers, max_total_cpus/max_cpus and
+ # max_total_memory_mb/max_memory_mb.
+ max_workers: 16
+ max_total_cpus: 8.0
+ max_total_memory_mb: 8192
+ # Includes disabled and historical installation fences retained to
+ # reject stale desired-state replay.
+ max_installations: 10000
+ # Restart storms are globally serialized by default. Repeated
+ # unexpected exits within the configured window open a Runtime-wide
+ # circuit; one half-open probe must remain stable before other
+ # installations may restart.
+ max_concurrent_restarts: 1
+ restart_failure_threshold: 8
+ restart_failure_window_seconds: 30.0
+ restart_circuit_open_seconds: 60.0
+ # Cloud shared Runtime sets this to true and fails closed unless
+ # delegated cgroup v2 controllers are available.
+ require_hard_limits: false
binary_storage:
# Max bytes for a single plugin binary storage value
max_value_bytes: 10485760
+mcp:
+ # Bound instance-wide MCP startup and shutdown bursts. Supports
+ # MCP__LIFECYCLE_CONCURRENCY and is clamped to a maximum of 128.
+ lifecycle_concurrency: 16
+ stdio:
+ # Independent gate for local stdio MCP transports. Cloud v2 sets
+ # MCP__STDIO__ENABLED=false even when Box Runtime is available.
+ enabled: true
monitoring:
+ query_limits:
+ # Maximum records materialized by one paginated monitoring request.
+ # Supports MONITORING__QUERY_LIMITS__PAGE_ROWS (hard cap: 5000).
+ page_rows: 1000
+ # CSV exports are currently assembled in memory. Keep this lower than
+ # the historical 100000-row default (hard cap: 50000).
+ export_rows: 10000
+ # Maximum related records returned by one session/message detail view
+ # (hard cap: 10000). Aggregate statistics remain database-computed.
+ detail_rows: 2000
+ # Token charts are grouped in SQL and return only the newest buckets
+ # (hard cap: 10000). Supports an environment variable override.
+ timeseries_buckets: 1000
+ # Bound high-offset scans that can otherwise monopolize PostgreSQL CPU
+ # (hard cap: 10000000).
+ max_offset: 1000000
auto_cleanup:
# Enable automatic cleanup of expired monitoring records
enabled: true
@@ -132,6 +313,9 @@ monitoring:
check_interval_hours: 1
# Number of expired rows to delete per table batch
delete_batch_size: 1000
+ # Prevent one large Workspace backlog from monopolizing PostgreSQL.
+ # Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN.
+ max_batches_per_table_per_run: 4
box:
# Master switch for the Box sandbox runtime. When false, LangBot does NOT
# attempt to connect to a remote Box runtime nor start a local stdio Box
@@ -142,12 +326,42 @@ box:
enabled: true
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
+ # External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in
+ # both LangBot and Box. Keep the shared secret out of this config file.
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
limits:
- max_sessions: 64 # Includes persistent sessions. New sessions fail explicitly when this cap is reached.
- max_managed_processes: 64 # Maximum concurrently running stdio MCP / managed processes.
- max_completed_processes: 256 # Global cap for retained exited-process diagnostics.
- completed_process_retention_sec: 300 # Keep exited-process diagnostics before releasing memory.
+ max_sessions: 64
+ max_managed_processes: 64
+ max_completed_processes: 256
+ # Core scans a Workspace before and after quota-enforced executions.
+ # Fail closed instead of repeatedly walking an inode bomb.
+ # Supports BOX__LIMITS__MAX_WORKSPACE_ENTRIES (hard cap: 1000000).
+ max_workspace_entries: 100000
+ # Retained admission fences prevent replay after entitlement expiry or
+ # revocation. Fail closed before that monotonic state can grow without
+ # bound; Cloud may override this with BOX__LIMITS__MAX_ADMISSION_RECORDS.
+ max_admission_records: 100000
+ max_rpc_file_bytes: 20971520
+ # Cloud v2 overrides these values through the instance config/environment.
+ # OSS keeps admission disabled and preserves the existing multi-session
+ # local behavior. These limits are Runtime-owned and cannot be relaxed by
+ # a pipeline, Workspace entitlement, or tool call.
+ admission:
+ required: false
+ logical_session_id: 'global'
+ required_backend: 'nsjail'
+ max_sessions: 1
+ max_managed_processes: 0
+ max_grant_ttl_sec: 300
+ max_timeout_sec: 120
+ cpus: 1.0
+ memory_mb: 512
+ pids_limit: 128
+ read_only_rootfs: true
+ # OSS admission-disabled mode uses 0 for unlimited compatibility.
+ # Cloud bootstrap requires a positive hard quota.
+ workspace_quota_mb: 0
+ readiness_cache_sec: 15
local:
profile: 'default'
image: '' # Custom local sandbox image. Leave empty to use the profile default.
diff --git a/src/langbot/templates/embed/widget.js b/src/langbot/templates/embed/widget.js
index e08ffe180..2a710711b 100644
--- a/src/langbot/templates/embed/widget.js
+++ b/src/langbot/templates/embed/widget.js
@@ -356,6 +356,7 @@
isConnected: false,
ws: null,
connectionId: null,
+ sessionToken: null,
sessionId: getOrCreateSessionId(),
reconnectAttempts: 0,
heartbeatTimer: null,
@@ -538,7 +539,12 @@
state.ws.onopen = function () {
state.reconnectAttempts = 0;
- startHeartbeat();
+ state.ws.send(
+ JSON.stringify({
+ type: "authenticate",
+ token: state.sessionToken || "",
+ }),
+ );
};
state.ws.onmessage = function (event) {
@@ -576,6 +582,7 @@
state.connectionId = data.connection_id;
if (state.hasConnected) loadHistory(true);
state.hasConnected = true;
+ startHeartbeat();
updateStatusDot();
updateSendBtn();
break;
diff --git a/tests/e2e/test_startup.py b/tests/e2e/test_startup.py
index e63150b4c..955e33af6 100644
--- a/tests/e2e/test_startup.py
+++ b/tests/e2e/test_startup.py
@@ -27,6 +27,21 @@ class TestStartupFlow:
"""Verify LangBot API is responding."""
assert langbot_process.health_check()
+ def test_health_check_exposes_bounded_blocking_executor(self, e2e_client):
+ """The production startup path installs blocking-work admission."""
+ response = e2e_client.get('/healthz')
+
+ assert response.status_code == 200
+ executor = response.json()['resources']['blocking_executor']
+ assert executor['max_workers'] == 8
+ assert executor['max_pending'] == 128
+ assert executor['max_inflight_per_scope'] == 4
+ assert executor['inflight'] >= 0
+ assert executor['rejected_total'] >= 0
+ event_loop = response.json()['resources']['event_loop']
+ assert event_loop['running'] is True
+ assert event_loop['recent_max_lag_ms'] >= 0
+
def test_system_info_endpoint(self, e2e_client):
"""Test /api/v1/system/info endpoint."""
response = e2e_client.get('/api/v1/system/info')
diff --git a/tests/factories/app.py b/tests/factories/app.py
index d1edf56a2..432e8723f 100644
--- a/tests/factories/app.py
+++ b/tests/factories/app.py
@@ -82,6 +82,9 @@ class FakeApp:
def _create_mock_persistence_manager(self):
persistence_mgr = AsyncMock()
persistence_mgr.execute_async = AsyncMock()
+ # AsyncMock invents arbitrary callable attributes on access. Keep the
+ # optional production UoW hook explicitly absent unless a test opts in.
+ persistence_mgr.tenant_uow = None
return persistence_mgr
def _create_mock_query_pool(self):
@@ -125,6 +128,8 @@ class FakeApp:
"""Mock SkillManager that returns no skill index addition by default."""
skill_mgr = Mock()
skill_mgr.skills = {}
+ skill_mgr.ensure_loaded = AsyncMock()
+ skill_mgr.get_skills = Mock(return_value=[])
skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
skill_mgr.get_skill_index = Mock(return_value=[])
return skill_mgr
diff --git a/tests/factories/message.py b/tests/factories/message.py
index 9b3cc3602..619354e2c 100644
--- a/tests/factories/message.py
+++ b/tests/factories/message.py
@@ -14,6 +14,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.provider.session as provider_session
+from langbot.pkg.api.http.context import ExecutionContext
# Counter for generating unique IDs
@@ -194,7 +195,20 @@ def _base_query(
for key, value in overrides.items():
base_data[key] = value
- return pipeline_query.Query.model_construct(**base_data)
+ query = pipeline_query.Query.model_construct(**base_data)
+ object.__setattr__(
+ query,
+ '_execution_context',
+ ExecutionContext(
+ instance_uuid='test-instance',
+ workspace_uuid='test-workspace',
+ placement_generation=1,
+ bot_uuid=query.bot_uuid,
+ pipeline_uuid=query.pipeline_uuid,
+ query_uuid=query.query_uuid,
+ ),
+ )
+ return query
def text_query(
diff --git a/tests/integration/api/test_bots.py b/tests/integration/api/test_bots.py
index 0e6854bf9..bb8f423e9 100644
--- a/tests/integration/api/test_bots.py
+++ b/tests/integration/api/test_bots.py
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
+from types import SimpleNamespace
from tests.factories import FakeApp
@@ -65,12 +66,43 @@ def fake_bot_app():
)
# Auth services
+ account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
- app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
+ app.user_service.get_user_by_email = AsyncMock(return_value=account)
+ app.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ app.workspace_collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ workspace=SimpleNamespace(uuid='workspace-test'),
+ membership=SimpleNamespace(
+ uuid='membership-test',
+ role='owner',
+ projection_revision=0,
+ ),
+ execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
+ )
+ )
+ )
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
+ app.apikey_service.authenticate_api_key = AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-test',
+ placement_generation=1,
+ api_key_uuid='api-key-test',
+ workspace_uuid='workspace-test',
+ permissions=frozenset(
+ {
+ 'resource.view',
+ 'resource.manage',
+ 'runtime.operate',
+ 'provider_secret.manage',
+ }
+ ),
+ )
+ )
# Bot service
app.bot_service = Mock()
@@ -198,6 +230,25 @@ class TestBotLogsEndpoint:
assert 'logs' in data['data']
assert 'total_count' in data['data']
+ @pytest.mark.asyncio
+ async def test_viewer_can_read_ordinary_bot_logs(self, quart_test_client, fake_bot_app):
+ access = fake_bot_app.workspace_collaboration_service.resolve_account_workspace.return_value
+ original_role = access.membership.role
+ access.membership.role = 'viewer'
+ fake_bot_app.bot_service.list_event_logs.reset_mock()
+ try:
+ response = await quart_test_client.post(
+ '/api/v1/platform/bots/test-bot-uuid/logs',
+ headers={'Authorization': 'Bearer test_token'},
+ json={'from_index': -1, 'max_count': 10},
+ )
+ finally:
+ access.membership.role = original_role
+
+ assert response.status_code == 200
+ assert (await response.get_json())['code'] == 0
+ fake_bot_app.bot_service.list_event_logs.assert_awaited_once()
+
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotSendMessageEndpoint:
diff --git a/tests/integration/api/test_box_security.py b/tests/integration/api/test_box_security.py
new file mode 100644
index 000000000..0a40f230a
--- /dev/null
+++ b/tests/integration/api/test_box_security.py
@@ -0,0 +1,119 @@
+"""Authorization tests for sensitive Box runtime observability."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller.groups.box import BoxRouterGroup
+from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
+
+
+pytestmark = pytest.mark.integration
+WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
+
+
+def _access(account_uuid: str):
+ return SimpleNamespace(
+ workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
+ membership=SimpleNamespace(
+ uuid=f'member-{account_uuid}',
+ role='viewer' if account_uuid == 'viewer-account' else 'owner',
+ projection_revision=1,
+ ),
+ execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
+ )
+
+
+@pytest.fixture
+async def box_security_api():
+ accounts = {
+ 'viewer-token': SimpleNamespace(uuid='viewer-account', user='viewer@example.com'),
+ 'owner-token': SimpleNamespace(uuid='owner-account', user='owner@example.com'),
+ }
+ application = Mock()
+ application.deployment = SimpleNamespace(multi_workspace_enabled=False)
+ application.persistence_mgr = SimpleNamespace(tenant_uow=None)
+ application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
+ application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
+ side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
+ )
+ application.box_service.get_status = AsyncMock(return_value={'enabled': True})
+ application.box_service.get_backend_status = AsyncMock(
+ return_value={'available': True, 'enabled': True, 'backend': {'name': 'nsjail'}}
+ )
+ application.box_service.get_sessions = AsyncMock(return_value=[{'session_id': 'private-session'}])
+ application.box_service.get_recent_errors = Mock(return_value=[{'error': 'private error'}])
+ application.box_service.managed_admission_required = False
+
+ quart_app = quart.Quart(__name__)
+ router = BoxRouterGroup(application, quart_app)
+ await router.initialize()
+ return application, quart_app.test_client()
+
+
+def _headers(token: str) -> dict[str, str]:
+ return {
+ 'Authorization': f'Bearer {token}',
+ 'X-Workspace-Id': WORKSPACE_UUID,
+ }
+
+
+@pytest.mark.asyncio
+async def test_viewer_can_read_status_but_not_sessions_or_errors(box_security_api):
+ application, client = box_security_api
+
+ status = await client.get('/api/v1/box/status', headers=_headers('viewer-token'))
+ sessions = await client.get('/api/v1/box/sessions', headers=_headers('viewer-token'))
+ errors = await client.get('/api/v1/box/errors', headers=_headers('viewer-token'))
+
+ assert status.status_code == 200
+ assert sessions.status_code == 403
+ assert errors.status_code == 403
+ application.box_service.get_sessions.assert_not_awaited()
+ application.box_service.get_recent_errors.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_owner_can_audit_box_sessions_and_errors(box_security_api):
+ application, client = box_security_api
+
+ sessions = await client.get('/api/v1/box/sessions', headers=_headers('owner-token'))
+ errors = await client.get('/api/v1/box/errors', headers=_headers('owner-token'))
+
+ assert sessions.status_code == 200
+ assert errors.status_code == 200
+ application.box_service.get_sessions.assert_awaited_once()
+ application.box_service.get_recent_errors.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_box_status_returns_explicit_403_when_workspace_has_no_managed_sandbox(box_security_api):
+ application, client = box_security_api
+ application.box_service.get_status.side_effect = EntitlementUnavailableError(
+ 'Workspace entitlement does not grant managed_sandbox'
+ )
+
+ response = await client.get('/api/v1/box/status', headers=_headers('viewer-token'))
+
+ assert response.status_code == 403
+ payload = await response.get_json()
+ assert payload['code'] == 'managed_sandbox_unavailable'
+
+
+@pytest.mark.asyncio
+async def test_runtime_status_reports_connector_health_without_consuming_workspace_entitlement(box_security_api):
+ application, client = box_security_api
+ application.box_service.get_status.side_effect = EntitlementUnavailableError(
+ 'Workspace entitlement does not grant managed_sandbox'
+ )
+
+ response = await client.get('/api/v1/box/runtime-status', headers=_headers('viewer-token'))
+
+ assert response.status_code == 200
+ assert (await response.get_json())['data']['available'] is True
+ application.box_service.get_backend_status.assert_awaited_once()
+ application.box_service.get_status.assert_not_awaited()
diff --git a/tests/integration/api/test_embed.py b/tests/integration/api/test_embed.py
index 2862337f5..b31d6fbe2 100644
--- a/tests/integration/api/test_embed.py
+++ b/tests/integration/api/test_embed.py
@@ -8,8 +8,11 @@ Run: uv run pytest tests/integration/api/test_embed.py -q
from __future__ import annotations
+import json
+
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
+from types import SimpleNamespace
from tests.factories import FakeApp
@@ -80,10 +83,18 @@ def fake_embed_app():
mock_runtime_bot = Mock()
mock_runtime_bot.bot_entity = mock_bot_entity
+ mock_runtime_bot.execution_context = SimpleNamespace(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ )
# Platform manager with bots
app.platform_mgr = Mock()
app.platform_mgr.bots = [mock_runtime_bot]
+ app.platform_mgr.resolve_public_bot = AsyncMock(
+ side_effect=lambda route_key: mock_runtime_bot if route_key == mock_bot_entity.uuid else None
+ )
# WebSocket proxy bot with adapter
mock_websocket_adapter = Mock()
@@ -94,6 +105,16 @@ def fake_embed_app():
mock_ws_proxy_bot = Mock()
mock_ws_proxy_bot.adapter = mock_websocket_adapter
app.platform_mgr.websocket_proxy_bot = mock_ws_proxy_bot
+ app.platform_mgr.get_websocket_proxy_bot = AsyncMock(return_value=mock_ws_proxy_bot)
+ app.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ )
+ )
+ )
# Monitoring service for feedback
app.monitoring_service = Mock()
@@ -117,12 +138,13 @@ class TestEmbedWidgetEndpoint:
"""Tests for widget.js endpoint."""
@pytest.mark.asyncio
- async def test_get_widget_js_success(self, quart_test_client):
+ async def test_get_widget_js_success(self, quart_test_client, fake_embed_app):
"""GET /api/v1/embed/{bot_uuid}/widget.js returns JS."""
response = await quart_test_client.get('/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/widget.js')
assert response.status_code == 200
assert 'javascript' in response.content_type
+ fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
@pytest.mark.asyncio
async def test_get_widget_js_invalid_uuid(self, quart_test_client):
@@ -203,9 +225,8 @@ class TestEmbedMessagesEndpoint:
data = await response.get_json()
assert data['code'] == 0
assert 'messages' in data['data']
- fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.get_websocket_messages.assert_called_with(
- 'test-pipeline-uuid', 'person', SESSION_ID
- )
+ proxy_bot = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value
+ proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
@@ -253,9 +274,8 @@ class TestEmbedResetEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
- fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.reset_session.assert_called_with(
- 'test-pipeline-uuid', 'person', SESSION_ID
- )
+ proxy_bot = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value
+ proxy_bot.adapter.reset_session.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_reset_session_requires_session_id(self, quart_test_client):
@@ -316,3 +336,85 @@ class TestEmbedFeedbackEndpoint:
)
assert response.status_code == 400
+
+
+@pytest.mark.usefixtures('mock_circular_import_chain')
+class TestEmbedWebSocketEndpoint:
+ """The public socket authenticates before resolving shared runtime state."""
+
+ @pytest.mark.asyncio
+ async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app):
+ async with quart_test_client.websocket(
+ f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
+ f'?session_type=person&session_id={SESSION_ID}',
+ headers={'Origin': 'http://localhost'},
+ ) as websocket:
+ await websocket.send(json.dumps({'type': 'authenticate', 'token': ''}))
+ connected = json.loads(await websocket.receive())
+ assert connected['type'] == 'connected'
+ assert connected['bot_uuid'] == 'a1b2c3d4-5678-90ab-cdef-123456789abc'
+ await websocket.send(json.dumps({'type': 'disconnect'}))
+
+ fake_embed_app.workspace_service.get_execution_binding.assert_awaited_with(
+ 'workspace-test',
+ expected_generation=1,
+ )
+
+ @pytest.mark.asyncio
+ async def test_rejects_non_auth_first_frame_before_runtime_lookup(self, quart_test_client, fake_embed_app):
+ fake_embed_app.platform_mgr.get_websocket_proxy_bot.reset_mock()
+
+ async with quart_test_client.websocket(
+ f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
+ f'?session_type=person&session_id={SESSION_ID}',
+ headers={'Origin': 'http://localhost'},
+ ) as websocket:
+ await websocket.send(json.dumps({'type': 'message', 'message': []}))
+ response = json.loads(await websocket.receive())
+ assert response == {'type': 'error', 'message': 'Unauthorized'}
+
+ fake_embed_app.platform_mgr.get_websocket_proxy_bot.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_rejects_invalid_turnstile_session_before_runtime_lookup(self, quart_test_client, fake_embed_app):
+ fake_embed_app.platform_mgr.get_websocket_proxy_bot.reset_mock()
+ config = fake_embed_app.platform_mgr.resolve_public_bot.side_effect(
+ 'a1b2c3d4-5678-90ab-cdef-123456789abc'
+ ).bot_entity.adapter_config
+ config['turnstile_secret_key'] = 'test-secret'
+ try:
+ async with quart_test_client.websocket(
+ f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
+ f'?session_type=person&session_id={SESSION_ID}',
+ headers={'Origin': 'http://localhost'},
+ ) as websocket:
+ await websocket.send(json.dumps({'type': 'authenticate', 'token': 'invalid'}))
+ response = json.loads(await websocket.receive())
+ assert response == {'type': 'error', 'message': 'Unauthorized'}
+ finally:
+ config['turnstile_secret_key'] = ''
+
+ fake_embed_app.platform_mgr.get_websocket_proxy_bot.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_rejects_message_when_bot_is_disabled_after_connect(self, quart_test_client, fake_embed_app):
+ runtime_bot = fake_embed_app.platform_mgr.resolve_public_bot.side_effect('a1b2c3d4-5678-90ab-cdef-123456789abc')
+ adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
+ adapter.handle_websocket_message.reset_mock()
+
+ async with quart_test_client.websocket(
+ f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
+ f'?session_type=person&session_id={SESSION_ID}',
+ headers={'Origin': 'http://localhost'},
+ ) as websocket:
+ await websocket.send(json.dumps({'type': 'authenticate', 'token': ''}))
+ assert json.loads(await websocket.receive())['type'] == 'connected'
+ runtime_bot.bot_entity.enable = False
+ try:
+ await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]}))
+ response = json.loads(await websocket.receive())
+ assert response == {'type': 'error', 'message': 'Bot is unavailable'}
+ finally:
+ runtime_bot.bot_entity.enable = True
+
+ adapter.handle_websocket_message.assert_not_awaited()
diff --git a/tests/integration/api/test_fresh_oss_workspace_journey.py b/tests/integration/api/test_fresh_oss_workspace_journey.py
new file mode 100644
index 000000000..36277d658
--- /dev/null
+++ b/tests/integration/api/test_fresh_oss_workspace_journey.py
@@ -0,0 +1,214 @@
+from __future__ import annotations
+
+import json
+import logging
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy as sa
+from quart import Quart
+
+from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
+from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
+from langbot.pkg.api.http.controller.groups.workspaces import WorkspacesRouterGroup
+from langbot.pkg.api.http.service.user import UserService
+from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
+from langbot.pkg.persistence.mgr import PersistenceManager
+from langbot.pkg.utils import constants
+from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
+from langbot.pkg.workspace.service import WorkspaceService
+
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+def _authorization(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
+ headers = {'Authorization': f'Bearer {token}'}
+ if workspace_uuid is not None:
+ headers['X-Workspace-Id'] = workspace_uuid
+ return headers
+
+
+async def test_fresh_oss_workspace_http_journey_uses_real_sqlite_persistence(
+ tmp_path,
+ monkeypatch,
+):
+ """Exercise the first-run Workspace journey through real HTTP handlers."""
+
+ instance_uuid = 'fresh-oss-workspace-journey'
+ monkeypatch.setattr(constants, 'instance_id', instance_uuid)
+
+ application = SimpleNamespace(
+ logger=logging.getLogger('fresh-oss-workspace-journey-test'),
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'use': 'sqlite',
+ 'sqlite': {'path': str(tmp_path / 'langbot.db')},
+ },
+ 'system': {
+ 'jwt': {'secret': 'fresh-oss-workspace-secret', 'expire': 3600},
+ 'allow_modify_login_info': True,
+ 'limitation': {},
+ 'outbound_ips': [],
+ },
+ 'api': {'global_api_key': ''},
+ 'plugin': {'enable_marketplace': True},
+ 'space': {
+ 'url': 'https://space.langbot.app',
+ 'models_gateway_api_url': 'https://api.langbot.cloud/v1',
+ 'disable_models_service': False,
+ },
+ 'mcp': {'stdio': {'enabled': True}},
+ }
+ ),
+ )
+ persistence = PersistenceManager(application)
+ application.persistence_mgr = persistence
+
+ await persistence.initialize()
+ try:
+ application.workspace_service = WorkspaceService(
+ application,
+ instance_uuid=instance_uuid,
+ )
+ application.workspace_collaboration_service = WorkspaceCollaborationService(
+ application,
+ application.workspace_service,
+ )
+ application.user_service = UserService(application)
+
+ quart_app = Quart(__name__)
+ await UserRouterGroup(application, quart_app).initialize()
+ await WorkspacesRouterGroup(application, quart_app).initialize()
+ await SystemRouterGroup(application, quart_app).initialize()
+ client = quart_app.test_client()
+
+ initialization = await client.get('/api/v1/user/init')
+ assert initialization.status_code == 200
+ assert (await initialization.get_json())['data'] == {'initialized': False}
+
+ initialized = await client.post(
+ '/api/v1/user/init',
+ json={'user': 'owner@example.com', 'password': 'owner-password'},
+ )
+ assert initialized.status_code == 200
+
+ authenticated = await client.post(
+ '/api/v1/user/auth',
+ json={'user': 'owner@example.com', 'password': 'owner-password'},
+ )
+ assert authenticated.status_code == 200
+ token = (await authenticated.get_json())['data']['token']
+
+ bootstrap = await client.get(
+ '/api/v1/workspaces/bootstrap',
+ headers=_authorization(token),
+ )
+ assert bootstrap.status_code == 200
+ bootstrap_workspaces = (await bootstrap.get_json())['data']['workspaces']
+ assert len(bootstrap_workspaces) == 1
+ bootstrap_access = bootstrap_workspaces[0]
+ workspace_uuid = bootstrap_access['workspace']['uuid']
+ assert bootstrap_access['workspace'] == {
+ 'uuid': workspace_uuid,
+ 'instance_uuid': instance_uuid,
+ 'name': 'Default Workspace',
+ 'slug': 'default',
+ 'type': 'team',
+ 'status': 'active',
+ 'source': 'local',
+ }
+ assert bootstrap_access['membership']['email'] == 'owner@example.com'
+ assert bootstrap_access['membership']['role'] == 'owner'
+ assert 'workspace.update' in bootstrap_access['permissions']
+
+ current = await client.get(
+ '/api/v1/workspaces/current',
+ headers=_authorization(token, workspace_uuid),
+ )
+ assert current.status_code == 200
+ current_data = (await current.get_json())['data']
+ assert current_data['workspace']['uuid'] == workspace_uuid
+ assert current_data['membership']['account_uuid'] == bootstrap_access['membership']['account_uuid']
+ assert current_data['membership']['role'] == 'owner'
+
+ user_info = await client.get(
+ '/api/v1/user/info',
+ headers=_authorization(token, workspace_uuid),
+ )
+ assert user_info.status_code == 200
+ assert (await user_info.get_json())['data'] == {
+ 'account_uuid': bootstrap_access['membership']['account_uuid'],
+ 'user': 'owner@example.com',
+ 'account_type': 'local',
+ 'has_password': True,
+ }
+
+ initial_system_info = await client.get(
+ '/api/v1/system/info',
+ headers=_authorization(token, workspace_uuid),
+ )
+ assert initial_system_info.status_code == 200
+ assert (await initial_system_info.get_json())['data']['wizard_status'] == 'none'
+ assert (await initial_system_info.get_json())['data']['wizard_progress'] is None
+
+ progress = {'step': 2, 'selected_adapter': 'telegram', 'bot_saved': False}
+ updated_progress = await client.put(
+ '/api/v1/system/wizard/progress',
+ headers=_authorization(token, workspace_uuid),
+ json=progress,
+ )
+ assert updated_progress.status_code == 200
+
+ persisted_progress = await persistence.execute_async(
+ sa.select(WorkspaceMetadata.value).where(
+ WorkspaceMetadata.workspace_uuid == workspace_uuid,
+ WorkspaceMetadata.key == 'wizard_progress',
+ )
+ )
+ assert json.loads(persisted_progress.scalar_one()) == progress
+
+ system_info_with_progress = await client.get(
+ '/api/v1/system/info',
+ headers=_authorization(token, workspace_uuid),
+ )
+ assert system_info_with_progress.status_code == 200
+ progress_data = (await system_info_with_progress.get_json())['data']
+ assert progress_data['wizard_status'] == 'none'
+ assert progress_data['wizard_progress'] == progress
+
+ completed = await client.post(
+ '/api/v1/system/wizard/completed',
+ headers=_authorization(token, workspace_uuid),
+ json={'status': 'completed'},
+ )
+ assert completed.status_code == 200
+
+ completed_system_info = await client.get(
+ '/api/v1/system/info',
+ headers=_authorization(token, workspace_uuid),
+ )
+ assert completed_system_info.status_code == 200
+ completed_data = (await completed_system_info.get_json())['data']
+ assert completed_data['wizard_status'] == 'completed'
+ assert completed_data['wizard_progress'] is None
+
+ rejected_workspace = await client.post(
+ '/api/v1/workspaces',
+ headers=_authorization(token, workspace_uuid),
+ json={'name': 'Second Workspace'},
+ )
+ assert rejected_workspace.status_code == 403
+ rejected_data = await rejected_workspace.get_json()
+ assert rejected_data['code'] == 'edition_limit'
+
+ persisted_wizard_status = await persistence.execute_async(
+ sa.select(WorkspaceMetadata.value).where(
+ WorkspaceMetadata.workspace_uuid == workspace_uuid,
+ WorkspaceMetadata.key == 'wizard_status',
+ )
+ )
+ assert persisted_wizard_status.scalar_one() == 'completed'
+ finally:
+ await persistence.get_db_engine().dispose()
diff --git a/tests/integration/api/test_knowledge.py b/tests/integration/api/test_knowledge.py
index 973356c3e..13b0785c7 100644
--- a/tests/integration/api/test_knowledge.py
+++ b/tests/integration/api/test_knowledge.py
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
+from types import SimpleNamespace
from tests.factories import FakeApp
@@ -69,7 +70,28 @@ def fake_knowledge_app():
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
- app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
+ account = SimpleNamespace(
+ uuid='00000000-0000-0000-0000-000000000001',
+ user='test@example.com',
+ )
+ app.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ app.user_service.get_user_by_email = AsyncMock(return_value=account)
+ app.workspace_collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ execution=SimpleNamespace(
+ instance_uuid='instance-knowledge-api',
+ placement_generation=1,
+ ),
+ workspace=SimpleNamespace(uuid='00000000-0000-0000-0000-00000000000a'),
+ membership=SimpleNamespace(
+ uuid='00000000-0000-0000-0000-000000000010',
+ role='owner',
+ projection_revision=0,
+ ),
+ )
+ )
+ )
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py
index 64c155840..9a10ea61a 100644
--- a/tests/integration/api/test_monitoring.py
+++ b/tests/integration/api/test_monitoring.py
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
+from types import SimpleNamespace
from tests.factories import FakeApp
@@ -66,7 +67,26 @@ def fake_monitoring_app():
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
- app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
+ app.user_service.get_user_by_email = AsyncMock(
+ return_value=SimpleNamespace(
+ uuid='account-uuid',
+ user='test@example.com',
+ email='test@example.com',
+ )
+ )
+ app.workspace_collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ execution=SimpleNamespace(instance_uuid='instance', placement_generation=1),
+ workspace=SimpleNamespace(uuid='00000000-0000-0000-0000-00000000000a'),
+ membership=SimpleNamespace(
+ uuid='membership-uuid',
+ role='owner',
+ projection_revision=1,
+ ),
+ )
+ )
+ )
# Monitoring service
app.monitoring_service = Mock()
@@ -135,6 +155,34 @@ class TestMonitoringOverviewEndpoint:
data = await response.get_json()
assert data['code'] == 0
+ @pytest.mark.asyncio
+ async def test_viewer_can_read_monitoring_but_cannot_export(
+ self,
+ quart_test_client,
+ fake_monitoring_app,
+ ):
+ """Ordinary monitoring is resource.view; export remains data.export."""
+ membership = (
+ fake_monitoring_app.workspace_collaboration_service.resolve_account_workspace.return_value.membership
+ )
+ original_role = membership.role
+ membership.role = 'viewer'
+ try:
+ response = await quart_test_client.get(
+ '/api/v1/monitoring/overview',
+ headers={'Authorization': 'Bearer test_token'},
+ )
+ assert response.status_code == 200
+
+ export_response = await quart_test_client.get(
+ '/api/v1/monitoring/export?type=messages',
+ headers={'Authorization': 'Bearer test_token'},
+ )
+ assert export_response.status_code == 403
+ assert (await export_response.get_json())['code'] == 'permission_denied'
+ finally:
+ membership.role = original_role
+
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringMessagesEndpoint:
diff --git a/tests/integration/api/test_pipelines.py b/tests/integration/api/test_pipelines.py
index 50ac37bc5..80fce9747 100644
--- a/tests/integration/api/test_pipelines.py
+++ b/tests/integration/api/test_pipelines.py
@@ -9,10 +9,13 @@ Run: uv run pytest tests/integration/api/test_pipelines.py -q
from __future__ import annotations
+import json
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
+from types import SimpleNamespace
from tests.factories import FakeApp
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.integration
@@ -54,6 +57,7 @@ def mock_circular_import_chain():
):
# Import groups after mocking to populate preregistered_groups
import langbot.pkg.api.http.controller.groups.pipelines.pipelines as _pipelines # noqa: E402, F401
+ import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as _websocket_chat # noqa: E402, F401
yield
@@ -75,10 +79,25 @@ def fake_pipeline_app():
)
# Auth services
+ account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
- app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
+ app.user_service.get_user_by_email = AsyncMock(return_value=account)
+ app.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ app.workspace_collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ workspace=SimpleNamespace(uuid='workspace-test'),
+ membership=SimpleNamespace(
+ uuid='membership-test',
+ role='owner',
+ projection_revision=0,
+ ),
+ execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
+ )
+ )
+ )
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
@@ -119,6 +138,15 @@ def fake_pipeline_app():
app.bot_service.get_bots = AsyncMock(return_value=[])
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
+ # Workspace-scoped dashboard WebSocket proxy
+ websocket_adapter = Mock()
+ websocket_adapter.get_websocket_messages = Mock(return_value=[])
+ websocket_adapter.reset_session = Mock()
+ websocket_adapter.handle_websocket_message = AsyncMock()
+ websocket_proxy_bot = Mock(adapter=websocket_adapter)
+ app.platform_mgr = Mock()
+ app.platform_mgr.get_websocket_proxy_bot = AsyncMock(return_value=websocket_proxy_bot)
+
# MCP service (for extensions endpoint)
app.mcp_service = Mock()
app.mcp_service.get_mcp_servers = AsyncMock(return_value=[])
@@ -278,3 +306,147 @@ class TestPipelineExtensionsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
+
+ @pytest.mark.asyncio
+ async def test_get_extensions_redacts_available_plugin_secrets(
+ self,
+ quart_test_client,
+ fake_pipeline_app,
+ ):
+ connector = fake_pipeline_app.plugin_connector
+ raw_plugin = {
+ 'plugin_config': {'apiKey': 'plugin-secret'},
+ 'debug': {'plugin_debug_key': 'debug-secret'},
+ }
+ connector.list_plugins.return_value = [raw_plugin]
+ try:
+ response = await quart_test_client.get(
+ '/api/v1/pipelines/test-pipeline-uuid/extensions',
+ headers={'Authorization': 'Bearer test_token'},
+ )
+ finally:
+ connector.list_plugins.return_value = []
+
+ assert response.status_code == 200
+ plugin = (await response.get_json())['data']['available_plugins'][0]
+ assert plugin['plugin_config']['apiKey'] == '***'
+ assert plugin['debug']['plugin_debug_key'] == '***'
+ assert raw_plugin['plugin_config']['apiKey'] == 'plugin-secret'
+
+ @pytest.mark.asyncio
+ async def test_get_extensions_hides_connector_bound_to_another_workspace(
+ self,
+ quart_test_client,
+ fake_pipeline_app,
+ ):
+ connector = fake_pipeline_app.plugin_connector
+ original_enabled = connector.is_enable_plugin
+ connector.is_enable_plugin = True
+ connector.require_workspace_context.reset_mock()
+ connector.list_plugins.reset_mock()
+ connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
+ try:
+ response = await quart_test_client.get(
+ '/api/v1/pipelines/test-pipeline-uuid/extensions',
+ headers={'Authorization': 'Bearer test_token'},
+ )
+ finally:
+ connector.require_workspace_context.side_effect = None
+ connector.is_enable_plugin = original_enabled
+
+ assert response.status_code == 404
+ connector.list_plugins.assert_not_awaited()
+
+
+@pytest.mark.usefixtures('mock_circular_import_chain')
+class TestPipelineDashboardWebSocket:
+ @pytest.mark.asyncio
+ async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app):
+ async with quart_test_client.websocket(
+ '/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
+ headers={'Origin': 'http://localhost'},
+ ) as websocket:
+ await websocket.send(
+ json.dumps(
+ {
+ 'type': 'authenticate',
+ 'token': 'test_token',
+ 'workspace_uuid': 'workspace-test',
+ }
+ )
+ )
+ connected = json.loads(await websocket.receive())
+ assert connected['type'] == 'connected'
+ assert connected['pipeline_uuid'] == 'test-pipeline-uuid'
+ await websocket.send(json.dumps({'type': 'disconnect'}))
+
+ fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with(
+ 'account-test',
+ 'workspace-test',
+ )
+ fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.assert_awaited()
+
+ @pytest.mark.asyncio
+ async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client):
+ async with quart_test_client.websocket(
+ '/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
+ headers={'Origin': 'http://localhost'},
+ ) as websocket:
+ await websocket.send(json.dumps({'type': 'message', 'message': []}))
+ response = json.loads(await websocket.receive())
+ assert response == {'type': 'error', 'message': 'Unauthorized'}
+
+ @pytest.mark.asyncio
+ async def test_websocket_rechecks_revocable_membership_before_each_message(
+ self,
+ quart_test_client,
+ fake_pipeline_app,
+ ):
+ access = fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.return_value
+ adapter = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
+ original_role = access.membership.role
+ adapter.handle_websocket_message.reset_mock()
+ try:
+ async with quart_test_client.websocket(
+ '/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
+ headers={'Origin': 'http://localhost'},
+ ) as websocket:
+ await websocket.send(
+ json.dumps(
+ {
+ 'type': 'authenticate',
+ 'token': 'test_token',
+ 'workspace_uuid': 'workspace-test',
+ }
+ )
+ )
+ assert json.loads(await websocket.receive())['type'] == 'connected'
+
+ access.membership.role = 'viewer'
+ await websocket.send(json.dumps({'type': 'message', 'message': []}))
+ assert json.loads(await websocket.receive()) == {
+ 'type': 'error',
+ 'message': 'Unauthorized',
+ }
+ finally:
+ access.membership.role = original_role
+
+ adapter.handle_websocket_message.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_dashboard_history_requires_runtime_permission(self, quart_test_client, fake_pipeline_app):
+ access = fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.return_value
+ original_role = access.membership.role
+ access.membership.role = 'viewer'
+ try:
+ response = await quart_test_client.get(
+ '/api/v1/pipelines/test-pipeline-uuid/ws/messages/person',
+ headers={
+ 'Authorization': 'Bearer test_token',
+ 'X-Workspace-Id': 'workspace-test',
+ },
+ )
+ finally:
+ access.membership.role = original_role
+
+ assert response.status_code == 403
diff --git a/tests/integration/api/test_plugins_security.py b/tests/integration/api/test_plugins_security.py
new file mode 100644
index 000000000..ba8c0aacf
--- /dev/null
+++ b/tests/integration/api/test_plugins_security.py
@@ -0,0 +1,272 @@
+"""Security regression tests for plugin configuration HTTP responses."""
+
+from __future__ import annotations
+
+import copy
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock, call
+
+import pytest
+import quart
+
+
+pytestmark = pytest.mark.integration
+WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
+RAW_CONFIG = {
+ 'apiKey': 'api-secret',
+ 'nested': {
+ 'headers': {'Authorization': 'Bearer nested-secret', 'Accept': 'application/json'},
+ 'refresh_token': 'refresh-secret',
+ 'public_key': 'public-material',
+ 'tokenizer': 'not-a-secret',
+ },
+ 'credentials': {'username': 'service-user', 'password': 'service-password'},
+ 'secret_list': ['first-secret', {'value': 'second-secret'}],
+ 'empty_secret': '',
+ 'enabled': True,
+}
+
+
+def _access(account_uuid: str):
+ roles = {
+ 'viewer-account': 'viewer',
+ 'operator-account': 'operator',
+ 'manager-account': 'developer',
+ }
+ return SimpleNamespace(
+ workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
+ membership=SimpleNamespace(
+ uuid=f'membership-{account_uuid}',
+ role=roles[account_uuid],
+ projection_revision=1,
+ ),
+ execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
+ )
+
+
+@pytest.fixture(scope='module')
+def plugin_module():
+ """Import the plugin router without following the core HTTP cycle."""
+
+ from tests.utils.import_isolation import MockLifecycleControlScope, isolated_sys_modules
+
+ class FakeMinimalApplication:
+ pass
+
+ mock_app = Mock(Application=FakeMinimalApplication)
+ mock_entities = Mock(LifecycleControlScope=MockLifecycleControlScope)
+ clear = [
+ 'langbot.pkg.core.taskmgr',
+ 'langbot.pkg.api.http.controller.group',
+ 'langbot.pkg.api.http.controller.groups',
+ 'langbot.pkg.api.http.controller.groups.plugins',
+ 'langbot.pkg.api.http.controller.main',
+ ]
+ with isolated_sys_modules(
+ mocks={
+ 'langbot.pkg.core.app': mock_app,
+ 'langbot.pkg.core.entities': mock_entities,
+ },
+ clear=clear,
+ ):
+ import langbot.pkg.api.http.controller.groups.plugins as plugins
+
+ yield plugins
+
+
+@pytest.fixture
+async def plugin_security_api(plugin_module):
+ viewer = SimpleNamespace(uuid='viewer-account', user='viewer@example.com')
+ operator = SimpleNamespace(uuid='operator-account', user='operator@example.com')
+ manager = SimpleNamespace(uuid='manager-account', user='manager@example.com')
+ accounts = {
+ 'viewer-token': viewer,
+ 'operator-token': operator,
+ 'manager-token': manager,
+ }
+
+ application = Mock()
+ application.deployment = SimpleNamespace(multi_workspace_enabled=False)
+ application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
+ application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
+ side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
+ )
+ application.apikey_service.verify_api_key = AsyncMock(return_value=False)
+ application.instance_config.data = {
+ 'plugin': {'display_plugin_debug_url': 'http://localhost:5401'},
+ 'system': {'limitation': {}},
+ }
+
+ raw_plugin = {
+ 'author': 'example',
+ 'name': 'secure-plugin',
+ 'plugin_config': RAW_CONFIG,
+ 'debug': {'plugin_debug_key': 'list-debug-secret'},
+ }
+ application.plugin_connector.require_workspace_context = AsyncMock()
+ application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
+ application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
+ application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
+ application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
+ application.plugin_connector.set_plugin_config = AsyncMock()
+
+ persistence_result = Mock()
+ persistence_result.scalar_one_or_none.return_value = RAW_CONFIG
+ application.persistence_mgr.execute_async = AsyncMock(return_value=persistence_result)
+ application.persistence_mgr.tenant_uow = None
+
+ quart_app = quart.Quart(__name__)
+ router = plugin_module.PluginsRouterGroup(application, quart_app)
+ await router.initialize()
+ return application, quart_app.test_client(), raw_plugin
+
+
+def _headers(token: str) -> dict[str, str]:
+ return {
+ 'Authorization': f'Bearer {token}',
+ 'X-Workspace-Id': WORKSPACE_UUID,
+ }
+
+
+def test_recursive_redaction_preserves_structure_without_mutating_input(plugin_module):
+ redacted = plugin_module.redact_plugin_secrets(RAW_CONFIG)
+
+ assert redacted['apiKey'] == '***'
+ assert redacted['nested']['headers'] == {
+ 'Authorization': '***',
+ 'Accept': 'application/json',
+ }
+ assert redacted['nested']['refresh_token'] == '***'
+ assert redacted['nested']['public_key'] == 'public-material'
+ assert redacted['nested']['tokenizer'] == 'not-a-secret'
+ assert redacted['credentials'] == {'username': '***', 'password': '***'}
+ assert redacted['secret_list'] == ['***', {'value': '***'}]
+ assert redacted['empty_secret'] == ''
+ assert redacted['enabled'] is True
+ assert RAW_CONFIG['apiKey'] == 'api-secret'
+ assert RAW_CONFIG['nested']['headers']['Authorization'] == 'Bearer nested-secret'
+ with pytest.raises(ValueError, match='no existing value'):
+ plugin_module.restore_plugin_secret_placeholders({'api_key': '***'}, {})
+
+
+@pytest.mark.asyncio
+async def test_viewer_plugin_reads_are_recursively_redacted(plugin_security_api):
+ application, client, raw_plugin = plugin_security_api
+
+ list_response = await client.get('/api/v1/plugins', headers=_headers('viewer-token'))
+ detail_response = await client.get(
+ '/api/v1/plugins/example/secure-plugin',
+ headers=_headers('viewer-token'),
+ )
+ config_response = await client.get(
+ '/api/v1/plugins/example/secure-plugin/config',
+ headers=_headers('viewer-token'),
+ )
+
+ assert list_response.status_code == 200
+ assert detail_response.status_code == 200
+ assert config_response.status_code == 200
+ listed_plugin = (await list_response.get_json())['data']['plugins'][0]
+ detailed_plugin = (await detail_response.get_json())['data']['plugin']
+ config = (await config_response.get_json())['data']['config']
+ for plugin in (listed_plugin, detailed_plugin):
+ assert plugin['plugin_config']['apiKey'] == '***'
+ assert plugin['debug']['plugin_debug_key'] == '***'
+ assert config['apiKey'] == '***'
+ assert config['nested']['headers']['Authorization'] == '***'
+ assert raw_plugin['plugin_config']['apiKey'] == 'api-secret'
+ application.plugin_connector.require_workspace_context.assert_awaited()
+
+
+@pytest.mark.asyncio
+async def test_manager_read_is_redacted_but_write_preserves_or_replaces_secrets(
+ plugin_security_api,
+ plugin_module,
+):
+ application, client, _ = plugin_security_api
+
+ read_response = await client.get(
+ '/api/v1/plugins/example/secure-plugin/config',
+ headers=_headers('manager-token'),
+ )
+ masked_update = plugin_module.redact_plugin_secrets(RAW_CONFIG)
+ masked_update['enabled'] = False
+ preserved_write = await client.put(
+ '/api/v1/plugins/example/secure-plugin/config',
+ headers=_headers('manager-token'),
+ json=masked_update,
+ )
+ replacement = copy.deepcopy(RAW_CONFIG)
+ replacement['apiKey'] = 'replacement-secret'
+ replaced_write = await client.put(
+ '/api/v1/plugins/example/secure-plugin/config',
+ headers=_headers('manager-token'),
+ json=replacement,
+ )
+
+ preserved = copy.deepcopy(RAW_CONFIG)
+ preserved['enabled'] = False
+
+ assert read_response.status_code == 200
+ assert (await read_response.get_json())['data']['config']['apiKey'] == '***'
+ assert preserved_write.status_code == 200
+ assert replaced_write.status_code == 200
+ assert application.plugin_connector.set_plugin_config.await_args_list == [
+ call('example', 'secure-plugin', preserved),
+ call('example', 'secure-plugin', replacement),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_debug_key_requires_resource_manage_permission(plugin_security_api):
+ application, client, _ = plugin_security_api
+
+ viewer_denied = await client.get('/api/v1/plugins/debug-info', headers=_headers('viewer-token'))
+ operator_denied = await client.get('/api/v1/plugins/debug-info', headers=_headers('operator-token'))
+ application.plugin_connector.get_debug_info.assert_not_awaited()
+ allowed = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token'))
+
+ assert viewer_denied.status_code == 403
+ assert operator_denied.status_code == 403
+ assert allowed.status_code == 200
+ assert (await allowed.get_json())['data'] == {
+ 'debug_url': 'http://localhost:5401',
+ 'plugin_debug_key': 'runtime-debug-secret',
+ }
+ application.plugin_connector.get_debug_info.assert_awaited_once_with()
+
+
+@pytest.mark.asyncio
+async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
+ application, client, _ = plugin_security_api
+
+ response = await client.get(
+ '/api/v1/plugins/example/secure-plugin/logs',
+ headers=_headers('viewer-token'),
+ )
+
+ assert response.status_code == 403
+ assert (await response.get_json())['code'] == 'permission_denied'
+ application.plugin_connector.get_plugin_logs.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_github_install_rejects_internal_asset_url_before_task_creation(
+ plugin_security_api,
+):
+ application, client, _ = plugin_security_api
+
+ response = await client.post(
+ '/api/v1/plugins/install/github',
+ headers=_headers('manager-token'),
+ json={
+ 'asset_url': 'http://169.254.169.254/latest/meta-data',
+ 'owner': 'langbot-app',
+ 'repo': 'demo-plugin',
+ 'release_tag': 'v1.0.0',
+ },
+ )
+
+ assert response.status_code == 400
+ assert 'HTTPS GitHub release asset URL' in (await response.get_json())['msg']
+ application.task_mgr.create_user_task.assert_not_called()
diff --git a/tests/integration/api/test_providers.py b/tests/integration/api/test_providers.py
index a42a99428..4aa2e1342 100644
--- a/tests/integration/api/test_providers.py
+++ b/tests/integration/api/test_providers.py
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
+from types import SimpleNamespace
from tests.factories import FakeApp
@@ -66,10 +67,25 @@ def fake_provider_app():
)
# Auth services
+ account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
- app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
+ app.user_service.get_user_by_email = AsyncMock(return_value=account)
+ app.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ app.workspace_collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ workspace=SimpleNamespace(uuid='workspace-test'),
+ membership=SimpleNamespace(
+ uuid='membership-test',
+ role='owner',
+ projection_revision=0,
+ ),
+ execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
+ )
+ )
+ )
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
diff --git a/tests/integration/api/test_skills_entitlements.py b/tests/integration/api/test_skills_entitlements.py
new file mode 100644
index 000000000..baafe1f18
--- /dev/null
+++ b/tests/integration/api/test_skills_entitlements.py
@@ -0,0 +1,79 @@
+"""Skills API behavior when a workspace plan has no managed sandbox."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
+from langbot.pkg.cloud.entitlements import (
+ EntitlementFeatureUnavailableError,
+ EntitlementUnavailableError,
+)
+
+pytestmark = pytest.mark.integration
+WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
+
+
+@pytest.fixture
+async def skills_api():
+ account = SimpleNamespace(uuid='owner-account', user='owner@example.com')
+ access = SimpleNamespace(
+ workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
+ membership=SimpleNamespace(uuid='member-owner', role='owner', projection_revision=1),
+ execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
+ )
+ application = Mock()
+ application.deployment = SimpleNamespace(multi_workspace_enabled=False)
+ application.persistence_mgr = SimpleNamespace(tenant_uow=None)
+ application.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
+ application.skill_service.list_skills = AsyncMock(
+ side_effect=EntitlementFeatureUnavailableError(
+ 'managed_sandbox',
+ entitlement_revision=1,
+ )
+ )
+
+ quart_app = quart.Quart(__name__)
+ router = SkillsRouterGroup(application, quart_app)
+ await router.initialize()
+ return application, quart_app.test_client()
+
+
+@pytest.mark.asyncio
+async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api):
+ application, client = skills_api
+ response = await client.get(
+ '/api/v1/skills',
+ headers={
+ 'Authorization': 'Bearer owner-token',
+ 'X-Workspace-Id': WORKSPACE_UUID,
+ },
+ )
+
+ assert response.status_code == 200
+ payload = await response.get_json()
+ assert payload['data'] == {'skills': []}
+ application.skill_service.list_skills.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api):
+ application, client = skills_api
+ application.skill_service.list_skills.side_effect = EntitlementUnavailableError(
+ 'Workspace entitlement revision rolled back'
+ )
+
+ response = await client.get(
+ '/api/v1/skills',
+ headers={
+ 'Authorization': 'Bearer owner-token',
+ 'X-Workspace-Id': WORKSPACE_UUID,
+ },
+ )
+
+ assert response.status_code == 500
diff --git a/tests/integration/api/test_smoke.py b/tests/integration/api/test_smoke.py
index 9f611bb6c..9c2927503 100644
--- a/tests/integration/api/test_smoke.py
+++ b/tests/integration/api/test_smoke.py
@@ -86,7 +86,7 @@ def fake_api_app():
'api': {'port': 5300},
'plugin': {'enable_marketplace': True},
'space': {'url': 'https://space.langbot.app'},
- 'system': {'allow_modify_login_info': True, 'limitation': {}},
+ 'system': {'allow_modify_login_info': True, 'recovery_key': 'recovery-secret', 'limitation': {}},
}
)
@@ -160,7 +160,7 @@ class TestHealthEndpoint:
assert response.status_code == 200
data = await response.get_json()
- assert data == {'code': 0, 'msg': 'ok'}
+ assert data == {'code': 0, 'msg': 'ok', 'resources': {}}
@pytest.mark.asyncio
async def test_healthz_no_auth_required(self, quart_test_client):
@@ -288,6 +288,47 @@ class TestUserInitEndpoint:
assert data['msg'] == 'ok'
assert data['data']['initialized'] is False
+ @pytest.mark.asyncio
+ async def test_account_info_exposes_instance_capabilities_not_first_account(self, quart_test_client, fake_api_app):
+ fake_api_app.user_service.is_initialized.return_value = True
+ fake_api_app.user_service.get_login_capabilities = AsyncMock(
+ return_value={'password_login_enabled': True, 'space_login_enabled': False}
+ )
+ fake_api_app.user_service.get_first_user = AsyncMock(
+ side_effect=AssertionError('public login bootstrap must not inspect an account')
+ )
+
+ response = await quart_test_client.get('/api/v1/user/account-info')
+
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert data['data'] == {
+ 'initialized': True,
+ 'password_login_enabled': True,
+ 'space_login_enabled': False,
+ }
+ fake_api_app.user_service.get_login_capabilities.assert_awaited_once_with()
+ fake_api_app.user_service.get_first_user.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_recovery_key_resets_any_existing_account(self, quart_test_client, fake_api_app, monkeypatch):
+ fake_api_app.user_service.is_initialized.return_value = True
+ fake_api_app.user_service.get_user_by_email.return_value = Mock(user='member@example.com')
+ fake_api_app.user_service.reset_password = AsyncMock()
+ monkeypatch.setattr('langbot.pkg.api.http.controller.groups.user.asyncio.sleep', AsyncMock())
+
+ response = await quart_test_client.post(
+ '/api/v1/user/reset-password',
+ json={
+ 'user': 'member@example.com',
+ 'recovery_key': 'recovery-secret',
+ 'new_password': 'new-member-password',
+ },
+ )
+
+ assert response.status_code == 200
+ fake_api_app.user_service.reset_password.assert_awaited_once_with('member@example.com', 'new-member-password')
+
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRealImports:
diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py
new file mode 100644
index 000000000..17569707c
--- /dev/null
+++ b/tests/integration/api/test_user_space_oauth.py
@@ -0,0 +1,338 @@
+"""Security tests for the LangBot-to-Space OAuth redirect boundary."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+from urllib.parse import parse_qs, urlsplit
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
+
+
+pytestmark = pytest.mark.integration
+WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
+
+
+@pytest.fixture
+async def space_oauth_api():
+ account = SimpleNamespace(uuid='account-a', user='owner@example.com')
+ access = SimpleNamespace(
+ workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
+ membership=SimpleNamespace(uuid='member-a', role='owner', projection_revision=1),
+ execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
+ )
+ application = Mock()
+ application.deployment = SimpleNamespace(multi_workspace_enabled=False)
+ application.persistence_mgr = None
+ application.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ application.user_service.issue_space_oauth_state = AsyncMock(
+ side_effect=lambda purpose, **_: f'opaque-{purpose}-state'
+ )
+ local_account = SimpleNamespace(
+ uuid='account-a',
+ user='owner@example.com',
+ account_type='local',
+ )
+ bound_account = SimpleNamespace(
+ uuid='account-a',
+ user='owner@example.com',
+ account_type='space',
+ )
+ application.user_service.consume_space_oauth_state = AsyncMock(
+ side_effect=lambda state, purpose: (
+ local_account if (state, purpose) == ('opaque-bind-state', 'bind') else None
+ )
+ )
+ application.user_service.consume_space_oauth_state_details = AsyncMock(
+ return_value=SimpleNamespace(launch_workspace_uuid=None)
+ )
+ application.user_service.bind_space_account = AsyncMock(return_value=bound_account)
+ application.user_service.generate_jwt_token = AsyncMock(return_value='rotated-account-token')
+ application.user_service.get_user_by_uuid = AsyncMock(return_value=bound_account)
+ application.user_service.authenticate_space_user = AsyncMock(return_value=('space-login-token', bound_account))
+ application.user_service.verify_jwt_token = AsyncMock()
+ application.space_launch_service.consume_assertion = AsyncMock(
+ return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID}
+ )
+ application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
+ application.space_service.get_oauth_authorize_url = Mock(
+ side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
+ )
+ application.space_service.exchange_oauth_code = AsyncMock(
+ return_value={
+ 'access_token': 'space-access-token',
+ 'refresh_token': 'space-refresh-token',
+ 'expires_in': 3600,
+ }
+ )
+ application.instance_config.data = {
+ 'api': {'webui_url': 'http://localhost'},
+ 'system': {'allow_modify_login_info': True},
+ }
+
+ quart_app = quart.Quart(__name__)
+ router = UserRouterGroup(application, quart_app)
+ await router.initialize()
+ return application, quart_app.test_client()
+
+
+@pytest.mark.asyncio
+async def test_public_login_state_is_server_issued(space_oauth_api):
+ application, client = space_oauth_api
+ response = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': 'http://localhost/auth/space/callback'},
+ headers={'Origin': 'http://localhost'},
+ )
+
+ assert response.status_code == 200
+ authorize_url = (await response.get_json())['data']['authorize_url']
+ assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state']
+ application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
+
+
+@pytest.mark.asyncio
+async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oauth_api):
+ application, client = space_oauth_api
+ application.deployment.multi_workspace_enabled = True
+
+ response = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={
+ 'redirect_uri': 'http://localhost/auth/space/callback',
+ 'launch_workspace_uuid': WORKSPACE_UUID,
+ },
+ headers={'Origin': 'http://localhost'},
+ )
+
+ assert response.status_code == 200
+ authorize_url = (await response.get_json())['data']['authorize_url']
+ assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state']
+ application.user_service.issue_space_oauth_state.assert_awaited_once_with(
+ 'login',
+ launch_workspace_uuid=WORKSPACE_UUID,
+ )
+
+
+@pytest.mark.asyncio
+async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
+ application, client = space_oauth_api
+ response = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={
+ 'redirect_uri': 'http://localhost/auth/space/callback',
+ 'state': 'jwt.must-not-be-used',
+ },
+ headers={'Origin': 'http://localhost'},
+ )
+
+ assert response.status_code == 200
+ assert (await response.get_json())['code'] == 1
+ application.space_service.get_oauth_authorize_url.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_bind_state_is_account_bound_and_requires_authentication(space_oauth_api):
+ application, client = space_oauth_api
+ path = '/api/v1/user/space/bind-authorize-url'
+ query = {'redirect_uri': 'http://localhost/auth/space/callback?mode=bind'}
+
+ unauthorized = await client.get(path, query_string=query, headers={'Origin': 'http://localhost'})
+ response = await client.get(
+ path,
+ query_string=query,
+ headers={
+ 'Origin': 'http://localhost',
+ 'Authorization': 'Bearer user-token',
+ 'X-Workspace-Id': WORKSPACE_UUID,
+ },
+ )
+
+ assert unauthorized.status_code == 401
+ assert response.status_code == 200
+ application.user_service.issue_space_oauth_state.assert_awaited_once_with('bind', account_uuid='account-a')
+
+
+@pytest.mark.asyncio
+async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
+ _, client = space_oauth_api
+
+ wrong_origin = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
+ headers={'Origin': 'http://localhost'},
+ )
+ wrong_path = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': 'http://localhost/arbitrary'},
+ headers={'Origin': 'http://localhost'},
+ )
+ forged_origin = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
+ headers={'Origin': 'https://evil.example'},
+ )
+ forged_host = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
+ headers={'Host': 'evil.example'},
+ )
+
+ assert (await wrong_origin.get_json())['code'] == 1
+ assert (await wrong_path.get_json())['code'] == 1
+ assert (await forged_origin.get_json())['code'] == 1
+ assert (await forged_host.get_json())['code'] == 1
+
+
+@pytest.mark.asyncio
+async def test_explicit_server_side_webui_origin_supports_split_dev_server(space_oauth_api):
+ application, client = space_oauth_api
+ application.instance_config.data['api'] = {'webui_url': 'http://localhost:5173'}
+
+ response = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': 'http://localhost:5173/auth/space/callback'},
+ headers={'Origin': 'https://irrelevant.example'},
+ )
+
+ assert response.status_code == 200
+ assert (await response.get_json())['code'] == 0
+
+
+@pytest.mark.asyncio
+async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
+ application, client = space_oauth_api
+ application.instance_config.data['api'] = {
+ 'webui_url': '',
+ 'webhook_prefix': 'https://langbot.example/base/path',
+ }
+
+ response = await client.get(
+ '/api/v1/user/space/authorize-url',
+ query_string={'redirect_uri': 'https://langbot.example/auth/space/callback'},
+ headers={'Host': 'attacker.example'},
+ )
+
+ assert response.status_code == 200
+ assert (await response.get_json())['code'] == 0
+
+
+@pytest.mark.asyncio
+async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
+ application, client = space_oauth_api
+
+ missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
+ response = await client.post(
+ '/api/v1/user/space/callback',
+ json={'code': 'oauth-code', 'state': 'opaque-login-state'},
+ )
+
+ assert (await missing.get_json())['code'] == 1
+ assert response.status_code == 200
+ assert (await response.get_json())['data']['token'] == 'space-login-token'
+ application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
+ application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
+
+
+@pytest.mark.asyncio
+async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
+ application, client = space_oauth_api
+ application.user_service.consume_space_oauth_state_details.reset_mock()
+ application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
+ launch_workspace_uuid=WORKSPACE_UUID
+ )
+
+ response = await client.post(
+ '/api/v1/user/space/callback',
+ json={'code': 'oauth-code', 'state': 'opaque-login-state'},
+ )
+
+ assert response.status_code == 200
+ data = (await response.get_json())['data']
+ assert data['token'] == 'space-login-token'
+ assert data['workspace_uuid'] == WORKSPACE_UUID
+ application.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with(
+ 'account-a',
+ WORKSPACE_UUID,
+ )
+
+
+@pytest.mark.asyncio
+async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
+ application, client = space_oauth_api
+ application.user_service.get_workspace_owner = AsyncMock(
+ return_value=SimpleNamespace(user='owner@example.com', space_account_uuid='space-owner')
+ )
+ application.space_service.get_credits = AsyncMock(return_value=25000)
+
+ response = await client.get(
+ '/api/v1/user/space-credits',
+ headers={'Authorization': 'Bearer account-token', 'X-Workspace-UUID': WORKSPACE_UUID},
+ )
+
+ assert response.status_code == 200
+ assert (await response.get_json())['data'] == {
+ 'credits': 25000,
+ 'owner_space_bound': True,
+ 'is_workspace_owner': True,
+ }
+ application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
+
+
+@pytest.mark.asyncio
+async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
+ application, client = space_oauth_api
+ application.user_service.consume_space_oauth_state.reset_mock()
+ application.user_service.consume_space_oauth_state.side_effect = [
+ ValueError('invalid state'),
+ SimpleNamespace(
+ uuid='account-a',
+ user='owner@example.com',
+ account_type='local',
+ ),
+ ]
+
+ rejected = await client.post(
+ '/api/v1/user/bind-space',
+ json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
+ )
+ response = await client.post(
+ '/api/v1/user/bind-space',
+ json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
+ )
+
+ assert rejected.status_code == 401
+ assert response.status_code == 200
+ assert (await response.get_json())['data']['token'] == 'rotated-account-token'
+ application.user_service.verify_jwt_token.assert_not_awaited()
+ application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
+
+
+@pytest.mark.asyncio
+async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api):
+ application, client = space_oauth_api
+ application.user_service.consume_space_oauth_state.reset_mock()
+ application.space_service.exchange_oauth_code.reset_mock()
+
+ response = await client.post(
+ '/api/v1/user/space/callback',
+ json={
+ 'state': 'space-generated-state-is-not-oauth-state',
+ 'workspace_uuid': WORKSPACE_UUID,
+ 'launch_assertion': 'signed-launch-token',
+ },
+ )
+
+ assert response.status_code == 200
+ data = (await response.get_json())['data']
+ assert data['token'] == 'rotated-account-token'
+ assert data['workspace_uuid'] == WORKSPACE_UUID
+ application.space_launch_service.consume_assertion.assert_awaited_once_with(
+ 'signed-launch-token',
+ expected_workspace_uuid=WORKSPACE_UUID,
+ )
+ application.user_service.consume_space_oauth_state.assert_not_awaited()
+ application.space_service.exchange_oauth_code.assert_not_awaited()
diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py
new file mode 100644
index 000000000..7547f4800
--- /dev/null
+++ b/tests/integration/api/test_workspaces.py
@@ -0,0 +1,616 @@
+from __future__ import annotations
+
+import json
+import logging
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import sqlalchemy
+import jwt
+from quart import Quart
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.controller.groups.workspaces import (
+ InvitationsRouterGroup,
+ WorkspacesRouterGroup,
+)
+from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
+from langbot.pkg.api.http.controller.groups.apikeys import ApiKeysRouterGroup
+from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
+from langbot.pkg.api.http.service.apikey import ApiKeyService
+from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.entity.persistence.workspace import (
+ Workspace,
+ WorkspaceExecutionState,
+ WorkspaceInvitation,
+ WorkspaceMembership,
+)
+from langbot.pkg.persistence.mgr import PersistenceManager
+from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
+from langbot.pkg.workspace.service import WorkspaceService
+from langbot.pkg.workspace.policy import CloudWorkspacePolicy
+
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+@pytest.fixture
+async def workspace_api(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-api.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+
+ application = SimpleNamespace()
+ application.persistence_mgr = PersistenceManager(application)
+ application.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
+ application.instance_config = SimpleNamespace(
+ data={
+ 'system': {
+ 'jwt': {'secret': 'workspace-api-secret', 'expire': 3600},
+ 'allow_modify_login_info': True,
+ },
+ 'api': {'global_api_key': '', 'webui_url': 'https://langbot.example'},
+ }
+ )
+ application.logger = logging.getLogger('workspace-api-test')
+ application.workspace_service = WorkspaceService(
+ application,
+ instance_uuid='instance-workspace-api',
+ )
+ await application.workspace_service.ensure_singleton_workspace()
+ application.workspace_collaboration_service = WorkspaceCollaborationService(
+ application,
+ application.workspace_service,
+ )
+ application.user_service = UserService(application)
+ application.apikey_service = ApiKeyService(application)
+
+ quart_app = Quart(__name__)
+ await WorkspacesRouterGroup(application, quart_app).initialize()
+ await InvitationsRouterGroup(application, quart_app).initialize()
+ await ApiKeysRouterGroup(application, quart_app).initialize()
+ await UserRouterGroup(application, quart_app).initialize()
+ await SystemRouterGroup(application, quart_app).initialize()
+
+ client = quart_app.test_client()
+ init_response = await client.post(
+ '/api/v1/user/init',
+ json={'user': 'owner@example.com', 'password': 'owner-password'},
+ )
+ assert init_response.status_code == 200
+ auth_response = await client.post(
+ '/api/v1/user/auth',
+ json={'user': 'owner@example.com', 'password': 'owner-password'},
+ )
+ assert auth_response.status_code == 200
+ owner_token = (await auth_response.get_json())['data']['token']
+
+ yield application, client, engine, owner_token
+ await engine.dispose()
+
+
+def _auth(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
+ headers = {'Authorization': f'Bearer {token}'}
+ if workspace_uuid is not None:
+ headers['X-Workspace-Id'] = workspace_uuid
+ return headers
+
+
+async def test_account_bootstrap_uses_the_account_resolved_from_the_token(workspace_api):
+ application, client, _, owner_token = workspace_api
+ account = await application.user_service.get_authenticated_account(owner_token)
+
+ application.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ application.user_service.get_user_by_email = AsyncMock(return_value=None)
+
+ response = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
+
+ assert response.status_code == 200
+ application.user_service.get_user_by_email.assert_not_awaited()
+
+
+async def test_fresh_sqlite_login_returns_current_workspace_and_user_info(workspace_api):
+ _, client, _, owner_token = workspace_api
+
+ current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
+ assert current_response.status_code == 200
+ current = (await current_response.get_json())['data']
+ assert current['workspace']['uuid']
+ assert current['membership']['email'] == 'owner@example.com'
+ assert current['membership']['role'] == 'owner'
+
+ info_response = await client.get('/api/v1/user/info', headers=_auth(owner_token))
+ assert info_response.status_code == 200
+ info = (await info_response.get_json())['data']
+ assert info['account_uuid'] == current['membership']['account_uuid']
+ assert info['user'] == 'owner@example.com'
+
+
+async def test_user_info_uses_the_account_resolved_from_the_token(workspace_api):
+ application, client, _, owner_token = workspace_api
+ account = await application.user_service.get_authenticated_account(owner_token)
+
+ application.user_service.get_authenticated_account = AsyncMock(return_value=account)
+ application.user_service.get_user_by_email = AsyncMock(return_value=None)
+
+ response = await client.get('/api/v1/user/info', headers=_auth(owner_token))
+
+ assert response.status_code == 200
+ info = (await response.get_json())['data']
+ assert info['account_uuid'] == account.uuid
+ assert info['user'] == account.user
+ application.user_service.get_user_by_email.assert_not_awaited()
+
+
+async def test_authenticated_system_info_reads_workspace_wizard_metadata(workspace_api):
+ application, client, _, owner_token = workspace_api
+
+ current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
+ workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
+ progress = {'step': 3, 'selected_adapter': 'telegram'}
+ await application.persistence_mgr.execute_async(
+ sqlalchemy.insert(WorkspaceMetadata),
+ [
+ {
+ 'workspace_uuid': workspace_uuid,
+ 'key': 'wizard_status',
+ 'value': 'completed',
+ },
+ {
+ 'workspace_uuid': workspace_uuid,
+ 'key': 'wizard_progress',
+ 'value': json.dumps(progress),
+ },
+ ],
+ )
+
+ response = await client.get(
+ '/api/v1/system/info',
+ headers=_auth(owner_token, workspace_uuid),
+ )
+
+ assert response.status_code == 200
+ data = (await response.get_json())['data']
+ assert data['wizard_status'] == 'completed'
+ assert data['wizard_progress'] == progress
+
+
+async def test_owner_invites_second_account_and_secret_is_not_persisted(workspace_api):
+ application, client, engine, owner_token = workspace_api
+
+ current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
+ assert current_response.status_code == 200
+ current = (await current_response.get_json())['data']
+ workspace_uuid = current['workspace']['uuid']
+ assert current['membership']['role'] == 'owner'
+ assert 'member.invite' in current['permissions']
+
+ invite_response = await client.post(
+ f'/api/v1/workspaces/{workspace_uuid}/invitations',
+ headers=_auth(owner_token, workspace_uuid),
+ json={'email': 'member@example.com', 'role': 'viewer'},
+ )
+ assert invite_response.status_code == 200
+ invite_data = (await invite_response.get_json())['data']
+ invitation_token = invite_data['token']
+ assert invitation_token.startswith('lbi_')
+ assert invite_data['link'] == f'https://langbot.example/invitations/accept#token={invitation_token}'
+ assert invite_data['delivery'] == {'status': 'link_only', 'provider': None}
+ assert 'token_hash' not in invite_data['invitation']
+
+ async with engine.connect() as connection:
+ persisted_token_hash = await connection.scalar(
+ sqlalchemy.select(WorkspaceInvitation.token_hash).where(
+ WorkspaceInvitation.uuid == invite_data['invitation']['uuid']
+ )
+ )
+ assert persisted_token_hash is not None
+ assert persisted_token_hash != invitation_token
+
+ inspect_response = await client.post(
+ '/api/v1/invitations/inspect',
+ json={'token': invitation_token},
+ )
+ assert inspect_response.status_code == 200
+ inspected = (await inspect_response.get_json())['data']
+ assert inspected['workspace']['uuid'] == workspace_uuid
+ assert inspected['invitation']['normalized_email'] == 'member@example.com'
+
+ accept_response = await client.post(
+ '/api/v1/invitations/accept',
+ json={
+ 'token': invitation_token,
+ 'registration': {
+ 'email': 'member@example.com',
+ 'password': 'member-password',
+ },
+ },
+ )
+ assert accept_response.status_code == 200
+ member_registration = (await accept_response.get_json())['data']
+ assert member_registration == {'workspace_uuid': workspace_uuid, 'login_required': True}
+
+ member_login_response = await client.post(
+ '/api/v1/user/auth',
+ json={'user': 'member@example.com', 'password': 'member-password'},
+ )
+ assert member_login_response.status_code == 200
+ member_token = (await member_login_response.get_json())['data']['token']
+
+ reused_response = await client.post(
+ '/api/v1/invitations/accept',
+ json={
+ 'token': invitation_token,
+ 'registration': {
+ 'email': 'member@example.com',
+ 'password': 'member-password',
+ },
+ },
+ )
+ assert reused_response.status_code == 400
+ assert (await reused_response.get_json())['code'] == 'invitation_used'
+
+ member_current_response = await client.get(
+ '/api/v1/workspaces/current',
+ headers=_auth(member_token, workspace_uuid),
+ )
+ assert member_current_response.status_code == 200
+ member_current = (await member_current_response.get_json())['data']
+ assert member_current['membership']['role'] == 'viewer'
+ assert 'member.invite' not in member_current['permissions']
+
+ forbidden_invite = await client.post(
+ f'/api/v1/workspaces/{workspace_uuid}/invitations',
+ headers=_auth(member_token, workspace_uuid),
+ json={'email': 'third@example.com', 'role': 'viewer'},
+ )
+ assert forbidden_invite.status_code == 403
+ assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
+
+
+async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
+ _, client, _, owner_token = workspace_api
+
+ response = await client.post(
+ '/api/v1/invitations/accept',
+ headers={'Authorization': f'Bearer {owner_token}'},
+ json={'token': 'lbi_pending-invitation'},
+ )
+
+ assert response.status_code == 409
+ assert (await response.get_json())['code'] == 'invitation_logout_required'
+
+
+async def test_invalid_bearer_on_cloud_invitation_is_authentication_failure(workspace_api):
+ application, client, _, _ = workspace_api
+ application.deployment = SimpleNamespace(mode='cloud')
+
+ response = await client.post(
+ '/api/v1/invitations/accept',
+ headers={'Authorization': 'Bearer definitely-not-a-jwt'},
+ json={'token': 'lbi_not-a-real-invitation'},
+ )
+
+ assert response.status_code == 401
+ assert await response.get_json() == {
+ 'code': 'invalid_authentication',
+ 'msg': 'Invalid authentication credentials',
+ }
+
+
+async def test_workspace_selector_and_path_cannot_escape_membership(workspace_api):
+ _, client, _, owner_token = workspace_api
+
+ unknown_uuid = '00000000-0000-0000-0000-000000000099'
+ selector_response = await client.get(
+ '/api/v1/workspaces/current',
+ headers=_auth(owner_token, unknown_uuid),
+ )
+ assert selector_response.status_code == 404
+ assert (await selector_response.get_json())['code'] == 'resource_not_found'
+
+ path_response = await client.get(
+ f'/api/v1/workspaces/{unknown_uuid}',
+ headers=_auth(owner_token),
+ )
+ assert path_response.status_code == 404
+ assert (await path_response.get_json())['code'] == 'resource_not_found'
+
+
+async def test_oss_rejects_second_workspace(workspace_api):
+ _, client, _, owner_token = workspace_api
+
+ response = await client.post('/api/v1/workspaces', headers=_auth(owner_token), json={'name': 'Second'})
+ assert response.status_code == 403
+ assert (await response.get_json())['code'] == 'edition_limit'
+
+
+async def test_jwt_uses_account_uuid_and_disabled_account_is_rejected(workspace_api):
+ _, client, engine, owner_token = workspace_api
+ payload = jwt.decode(
+ owner_token,
+ 'workspace-api-secret',
+ algorithms=['HS256'],
+ audience='langbot-instance:instance-workspace-api',
+ issuer='langbot-core',
+ )
+ assert payload['sub']
+ assert payload['sub'] != payload['user']
+
+ async with engine.begin() as connection:
+ await connection.execute(sqlalchemy.update(User).where(User.uuid == payload['sub']).values(status='disabled'))
+
+ response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
+ assert response.status_code == 401
+ assert (await response.get_json())['code'] == 'invalid_authentication'
+
+
+async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspace_api):
+ application, client, _engine, owner_token = workspace_api
+ current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
+ workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
+
+ create_response = await client.post(
+ '/api/v1/apikeys',
+ headers=_auth(owner_token, workspace_uuid),
+ json={'name': 'E2E automation', 'scopes': ['resource.view']},
+ )
+ assert create_response.status_code == 200
+ created = (await create_response.get_json())['data']['key']
+ assert created['key'].startswith('lbk_')
+ assert created['secret_available'] is True
+ assert 'key_hash' not in created
+
+ list_response = await client.get('/api/v1/apikeys', headers=_auth(owner_token, workspace_uuid))
+ listed = (await list_response.get_json())['data']['keys']
+ assert len(listed) == 1
+ assert 'key' not in listed[0]
+ assert 'key_hash' not in listed[0]
+ assert listed[0]['secret_available'] is False
+ identity = await application.apikey_service.authenticate_api_key(created['key'])
+ assert identity is not None
+ assert identity.workspace_uuid == workspace_uuid
+ assert identity.permissions == frozenset({'resource.view'})
+
+ invite_response = await client.post(
+ f'/api/v1/workspaces/{workspace_uuid}/invitations',
+ headers=_auth(owner_token, workspace_uuid),
+ json={'email': 'viewer@example.com', 'role': 'viewer'},
+ )
+ invitation_token = (await invite_response.get_json())['data']['token']
+ accept_response = await client.post(
+ '/api/v1/invitations/accept',
+ json={
+ 'token': invitation_token,
+ 'registration': {'email': 'viewer@example.com', 'password': 'viewer-password'},
+ },
+ )
+ assert accept_response.status_code == 200
+ assert (await accept_response.get_json())['data']['login_required'] is True
+ login_response = await client.post(
+ '/api/v1/user/auth',
+ json={'user': 'viewer@example.com', 'password': 'viewer-password'},
+ )
+ assert login_response.status_code == 200
+ viewer_token = (await login_response.get_json())['data']['token']
+ forbidden = await client.post(
+ '/api/v1/apikeys',
+ headers=_auth(viewer_token, workspace_uuid),
+ json={'name': 'forbidden'},
+ )
+ assert forbidden.status_code == 403
+ assert (await forbidden.get_json())['code'] == 'permission_denied'
+
+
+async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core(
+ workspace_api,
+):
+ application, client, engine, owner_token = workspace_api
+ application.deployment = SimpleNamespace(mode='cloud')
+ owner_uuid = jwt.decode(
+ owner_token,
+ 'workspace-api-secret',
+ algorithms=['HS256'],
+ audience='langbot-instance:instance-workspace-api',
+ issuer='langbot-core',
+ )['sub']
+ cloud_workspace_uuid = '00000000-0000-0000-0000-000000000777'
+
+ async with engine.begin() as connection:
+ await connection.execute(
+ sqlalchemy.insert(Workspace).values(
+ uuid=cloud_workspace_uuid,
+ instance_uuid='instance-workspace-api',
+ name='Cloud Team',
+ slug='cloud-team',
+ type='team',
+ status='active',
+ source='cloud_projection',
+ projection_revision=12,
+ )
+ )
+ await connection.execute(
+ sqlalchemy.insert(WorkspaceExecutionState).values(
+ workspace_uuid=cloud_workspace_uuid,
+ instance_uuid='instance-workspace-api',
+ active_generation=12,
+ state='active',
+ write_fenced=False,
+ source='cloud',
+ desired_state_revision=12,
+ )
+ )
+ await connection.execute(
+ sqlalchemy.insert(WorkspaceMembership).values(
+ uuid='00000000-0000-0000-0000-000000000778',
+ workspace_uuid=cloud_workspace_uuid,
+ account_uuid=owner_uuid,
+ role='owner',
+ status='active',
+ projection_revision=12,
+ )
+ )
+
+ policy = CloudWorkspacePolicy()
+ application.workspace_service.policy = policy
+ application.workspace_collaboration_service.policy = policy
+
+ with pytest.raises(ControlPlaneDirectoryRequiredError):
+ await application.user_service.create_initial_account(
+ 'forbidden-cloud-local@example.com',
+ 'password',
+ )
+
+ omitted = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
+ assert omitted.status_code == 404
+
+ refreshed_token = await client.get('/api/v1/user/check-token', headers=_auth(owner_token))
+ assert refreshed_token.status_code == 200
+ assert (await refreshed_token.get_json())['data']['token']
+
+ bootstrap_response = await client.get(
+ '/api/v1/workspaces/bootstrap',
+ headers=_auth(owner_token),
+ )
+ assert bootstrap_response.status_code == 200
+ bootstrap = (await bootstrap_response.get_json())['data']
+ singleton_uuid = (await application.workspace_service.get_singleton_workspace()).uuid
+ workspace_uuids = [item['workspace']['uuid'] for item in bootstrap['workspaces']]
+ assert set(workspace_uuids) == {singleton_uuid, cloud_workspace_uuid}
+ repeated = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
+ assert [item['workspace']['uuid'] for item in (await repeated.get_json())['data']['workspaces']] == workspace_uuids
+ by_uuid = {item['workspace']['uuid']: item for item in bootstrap['workspaces']}
+ assert by_uuid[singleton_uuid]['membership']['account_uuid'] == owner_uuid
+ assert by_uuid[singleton_uuid]['membership']['email'] == 'owner@example.com'
+ assert by_uuid[singleton_uuid]['permissions']
+ assert by_uuid[cloud_workspace_uuid]['placement_generation'] == 12
+
+ list_response = await client.get(
+ '/api/v1/workspaces',
+ headers=_auth(owner_token, singleton_uuid),
+ )
+ assert list_response.status_code == 200
+ assert {workspace['uuid'] for workspace in (await list_response.get_json())['data']['workspaces']} == {
+ singleton_uuid,
+ cloud_workspace_uuid,
+ }
+
+ class PlanResolver:
+ async def resolve(self, workspace_uuid: str, *, minimum_revision: int = 0):
+ from langbot.pkg.cloud.entitlements import EntitlementSnapshot
+
+ assert workspace_uuid == cloud_workspace_uuid
+ return EntitlementSnapshot(
+ instance_uuid='instance-workspace-api',
+ workspace_uuid=workspace_uuid,
+ entitlement_revision=max(12, minimum_revision),
+ status='active',
+ not_before=1,
+ expires_at=4102444800,
+ plan_name='free',
+ )
+
+ application.entitlement_resolver = PlanResolver()
+ bootstrap_with_plans = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
+ bootstrap_by_uuid = {
+ item['workspace']['uuid']: item for item in (await bootstrap_with_plans.get_json())['data']['workspaces']
+ }
+ assert bootstrap_by_uuid[cloud_workspace_uuid]['plan_name'] == 'free'
+ assert bootstrap_by_uuid[singleton_uuid]['plan_name'] is None
+ current_response = await client.get(
+ '/api/v1/workspaces/current',
+ headers=_auth(owner_token, cloud_workspace_uuid),
+ )
+ assert current_response.status_code == 200
+ current = (await current_response.get_json())['data']
+ assert current['workspace']['uuid'] == cloud_workspace_uuid
+ assert current['workspace']['source'] == 'cloud_projection'
+ assert current['placement_generation'] == 12
+ assert current['plan_name'] == 'free'
+
+ create_workspace = await client.post(
+ '/api/v1/workspaces',
+ headers=_auth(owner_token, cloud_workspace_uuid),
+ json={'name': 'Not in Core'},
+ )
+ assert create_workspace.status_code == 409
+ assert (await create_workspace.get_json())['code'] == 'control_plane_required'
+
+ create_invitation = await client.post(
+ f'/api/v1/workspaces/{cloud_workspace_uuid}/invitations',
+ headers=_auth(owner_token, cloud_workspace_uuid),
+ json={'email': 'member@example.com', 'role': 'viewer'},
+ )
+ assert create_invitation.status_code == 200
+ created_invitation = (await create_invitation.get_json())['data']
+ assert created_invitation['invitation']['workspace_uuid'] == cloud_workspace_uuid
+ assert created_invitation['link'].startswith('https://langbot.example/invitations/accept#token=lbi_')
+ assert created_invitation['delivery'] == {'status': 'link_only', 'provider': None}
+
+ accept_response = await client.post(
+ '/api/v1/invitations/accept',
+ headers=_auth(owner_token, cloud_workspace_uuid),
+ json={'token': created_invitation['token']},
+ )
+ assert accept_response.status_code == 400
+ assert (await accept_response.get_json())['code'] == 'invitation_email_mismatch'
+
+ registration_response = await client.post(
+ '/api/v1/invitations/accept',
+ json={
+ 'token': created_invitation['token'],
+ 'registration': {'email': 'member@example.com', 'password': 'member-password'},
+ },
+ )
+ assert registration_response.status_code == 401
+ assert (await registration_response.get_json())['code'] == 'account_exists_login_required'
+
+
+async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api):
+ application, client, engine, owner_token = workspace_api
+ foreign_workspace_uuid = '00000000-0000-0000-0000-000000000880'
+
+ async with engine.begin() as connection:
+ await connection.execute(
+ sqlalchemy.insert(Workspace).values(
+ uuid=foreign_workspace_uuid,
+ instance_uuid='instance-workspace-api',
+ name='Foreign Team',
+ slug='foreign-team',
+ type='team',
+ status='active',
+ source='cloud_projection',
+ projection_revision=1,
+ )
+ )
+ await connection.execute(
+ sqlalchemy.insert(WorkspaceExecutionState).values(
+ workspace_uuid=foreign_workspace_uuid,
+ instance_uuid='instance-workspace-api',
+ active_generation=1,
+ state='active',
+ write_fenced=False,
+ source='cloud',
+ desired_state_revision=1,
+ )
+ )
+
+ policy = CloudWorkspacePolicy()
+ application.workspace_service.policy = policy
+ application.workspace_collaboration_service.policy = policy
+
+ response = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
+ assert response.status_code == 200
+ workspace_uuids = {item['workspace']['uuid'] for item in (await response.get_json())['data']['workspaces']}
+ assert foreign_workspace_uuid not in workspace_uuids
+
+ current = await client.get(
+ '/api/v1/workspaces/current',
+ headers=_auth(owner_token, foreign_workspace_uuid),
+ )
+ assert current.status_code == 404
+ assert (await current.get_json())['code'] == 'resource_not_found'
diff --git a/tests/integration/persistence/resource_migration_support.py b/tests/integration/persistence/resource_migration_support.py
new file mode 100644
index 000000000..f3eea0f30
--- /dev/null
+++ b/tests/integration/persistence/resource_migration_support.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+import datetime
+
+import sqlalchemy as sa
+
+
+TENANT_TABLES = (
+ 'api_keys',
+ 'bots',
+ 'bot_admins',
+ 'binary_storages',
+ 'mcp_servers',
+ 'model_providers',
+ 'llm_models',
+ 'embedding_models',
+ 'rerank_models',
+ 'legacy_pipelines',
+ 'pipeline_run_records',
+ 'plugin_settings',
+ 'knowledge_bases',
+ 'knowledge_base_files',
+ 'knowledge_base_chunks',
+ 'webhooks',
+ 'monitoring_messages',
+ 'monitoring_llm_calls',
+ 'monitoring_tool_calls',
+ 'monitoring_sessions',
+ 'monitoring_errors',
+ 'monitoring_embedding_calls',
+ 'monitoring_feedback',
+)
+
+
+def _uuid_table(metadata: sa.MetaData, name: str, *columns: sa.Column) -> sa.Table:
+ return sa.Table(name, metadata, sa.Column('uuid', sa.String(255), primary_key=True), *columns)
+
+
+async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
+ """Create the smallest representative pre-0010 schema with one row/table."""
+ metadata = sa.MetaData()
+ system_metadata = sa.Table(
+ 'metadata',
+ metadata,
+ sa.Column('key', sa.String(255), primary_key=True),
+ sa.Column('value', sa.String(255)),
+ )
+ users = sa.Table(
+ 'users',
+ metadata,
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('user', sa.String(255), nullable=False),
+ sa.Column('password', sa.String(255), nullable=False),
+ )
+ api_keys = sa.Table(
+ 'api_keys',
+ metadata,
+ sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('key', sa.String(255), nullable=False, unique=True),
+ )
+ bots = _uuid_table(
+ metadata,
+ 'bots',
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('updated_at', sa.DateTime, nullable=False),
+ )
+ bot_admins = sa.Table(
+ 'bot_admins',
+ metadata,
+ sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column('bot_uuid', sa.String(255), nullable=False),
+ sa.Column('launcher_type', sa.String(64), nullable=False),
+ sa.Column('launcher_id', sa.String(255), nullable=False),
+ sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),
+ )
+ binary_storages = sa.Table(
+ 'binary_storages',
+ metadata,
+ sa.Column('unique_key', sa.String(255), primary_key=True),
+ sa.Column('key', sa.String(255), nullable=False),
+ sa.Column('owner_type', sa.String(255), nullable=False),
+ sa.Column('owner', sa.String(255), nullable=False),
+ )
+ mcp_servers = _uuid_table(
+ metadata,
+ 'mcp_servers',
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('enable', sa.Boolean, nullable=False),
+ sa.Column('updated_at', sa.DateTime, nullable=False),
+ )
+ model_providers = _uuid_table(
+ metadata,
+ 'model_providers',
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('requester', sa.String(255), nullable=False),
+ )
+ llm_models = _uuid_table(
+ metadata,
+ 'llm_models',
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('provider_uuid', sa.String(255), nullable=False),
+ )
+ embedding_models = _uuid_table(
+ metadata,
+ 'embedding_models',
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('provider_uuid', sa.String(255), nullable=False),
+ )
+ rerank_models = _uuid_table(
+ metadata,
+ 'rerank_models',
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('provider_uuid', sa.String(255), nullable=False),
+ )
+ legacy_pipelines = _uuid_table(
+ metadata,
+ 'legacy_pipelines',
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('is_default', sa.Boolean, nullable=False),
+ sa.Column('updated_at', sa.DateTime, nullable=False),
+ )
+ pipeline_run_records = _uuid_table(
+ metadata,
+ 'pipeline_run_records',
+ sa.Column('pipeline_uuid', sa.String(255), nullable=False),
+ sa.Column('created_at', sa.DateTime, nullable=False),
+ )
+ plugin_settings = sa.Table(
+ 'plugin_settings',
+ metadata,
+ sa.Column('plugin_author', sa.String(255), primary_key=True),
+ sa.Column('plugin_name', sa.String(255), primary_key=True),
+ sa.Column('enabled', sa.Boolean, nullable=False),
+ )
+ knowledge_bases = _uuid_table(
+ metadata,
+ 'knowledge_bases',
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('collection_id', sa.String(255), nullable=True),
+ )
+ knowledge_base_files = _uuid_table(
+ metadata,
+ 'knowledge_base_files',
+ sa.Column('kb_id', sa.String(255), nullable=True),
+ )
+ knowledge_base_chunks = _uuid_table(
+ metadata,
+ 'knowledge_base_chunks',
+ sa.Column('file_id', sa.String(255), nullable=True),
+ )
+ webhooks = sa.Table(
+ 'webhooks',
+ metadata,
+ sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('enabled', sa.Boolean, nullable=False),
+ sa.Column('created_at', sa.DateTime, nullable=False),
+ )
+
+ monitoring_tables: dict[str, sa.Table] = {}
+ for table_name in (
+ 'monitoring_messages',
+ 'monitoring_llm_calls',
+ 'monitoring_tool_calls',
+ 'monitoring_errors',
+ 'monitoring_embedding_calls',
+ ):
+ monitoring_tables[table_name] = sa.Table(
+ table_name,
+ metadata,
+ sa.Column('id', sa.String(255), primary_key=True),
+ sa.Column('timestamp', sa.DateTime, nullable=False),
+ sa.Column('session_id', sa.String(255), nullable=True),
+ sa.Column('message_id', sa.String(255), nullable=True),
+ )
+ monitoring_tables['monitoring_sessions'] = sa.Table(
+ 'monitoring_sessions',
+ metadata,
+ sa.Column('session_id', sa.String(255), primary_key=True),
+ sa.Column('bot_id', sa.String(255), nullable=False),
+ sa.Column('last_activity', sa.DateTime, nullable=False),
+ sa.Column('is_active', sa.Boolean, nullable=False),
+ )
+ monitoring_tables['monitoring_feedback'] = sa.Table(
+ 'monitoring_feedback',
+ metadata,
+ sa.Column('id', sa.String(255), primary_key=True),
+ sa.Column('feedback_id', sa.String(255), nullable=False, unique=True),
+ sa.Column('timestamp', sa.DateTime, nullable=False),
+ sa.Column('session_id', sa.String(255), nullable=True),
+ sa.Column('message_id', sa.String(255), nullable=True),
+ )
+
+ now = datetime.datetime(2026, 1, 1)
+ async with engine.begin() as conn:
+ await conn.run_sync(metadata.create_all)
+ await conn.execute(
+ system_metadata.insert(),
+ [
+ {'key': 'database_version', 'value': '25'},
+ {'key': 'instance_uuid', 'value': instance_uuid},
+ {'key': 'wizard_status', 'value': 'completed'},
+ {'key': 'wizard_progress', 'value': '3'},
+ {'key': 'rag_plugin_migration_needed', 'value': 'true'},
+ ],
+ )
+ await conn.execute(users.insert().values(user='Owner@Example.COM', password='hash'))
+ await conn.execute(api_keys.insert().values(name='legacy', key='lbk_legacy-secret'))
+ await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
+ await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
+ await conn.execute(
+ binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
+ )
+ await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
+ await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
+ for table in (llm_models, embedding_models, rerank_models):
+ await conn.execute(table.insert().values(uuid=f'{table.name}-1', name='model', provider_uuid='provider-1'))
+ await conn.execute(
+ legacy_pipelines.insert().values(uuid='pipeline-1', name='pipeline', is_default=True, updated_at=now)
+ )
+ await conn.execute(
+ pipeline_run_records.insert().values(uuid='run-1', pipeline_uuid='pipeline-1', created_at=now)
+ )
+ await conn.execute(plugin_settings.insert().values(plugin_author='author', plugin_name='plugin', enabled=True))
+ await conn.execute(knowledge_bases.insert().values(uuid='kb-1', name='knowledge', collection_id='collection-1'))
+ await conn.execute(knowledge_base_files.insert().values(uuid='file-1', kb_id='kb-1'))
+ await conn.execute(knowledge_base_chunks.insert().values(uuid='chunk-1', file_id='file-1'))
+ await conn.execute(webhooks.insert().values(name='hook', enabled=True, created_at=now))
+ for table_name, table in monitoring_tables.items():
+ if table_name == 'monitoring_sessions':
+ values = {
+ 'session_id': 'session-1',
+ 'bot_id': 'bot-1',
+ 'last_activity': now,
+ 'is_active': True,
+ }
+ elif table_name == 'monitoring_feedback':
+ values = {
+ 'id': 'feedback-row-1',
+ 'feedback_id': 'feedback-1',
+ 'timestamp': now,
+ 'session_id': 'session-1',
+ 'message_id': 'message-1',
+ }
+ else:
+ values = {
+ 'id': f'{table_name}-1',
+ 'timestamp': now,
+ 'session_id': 'session-1',
+ 'message_id': 'message-1',
+ }
+ await conn.execute(table.insert().values(**values))
diff --git a/tests/integration/persistence/test_migrations.py b/tests/integration/persistence/test_migrations.py
index 25d3e5c82..c1a455fb1 100644
--- a/tests/integration/persistence/test_migrations.py
+++ b/tests/integration/persistence/test_migrations.py
@@ -10,10 +10,12 @@ Run: uv run pytest tests/integration/persistence/test_migrations.py -q
from __future__ import annotations
import pytest
+import sqlalchemy
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import (
+ run_alembic_downgrade,
run_alembic_upgrade,
run_alembic_stamp,
get_alembic_current,
@@ -149,6 +151,45 @@ class TestSQLiteMigrationUpgrade:
rev2 = await get_alembic_current(sqlite_engine)
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
+ @pytest.mark.asyncio
+ async def test_upgrade_from_0012_adds_knowledge_base_embedding_dimension(self, sqlite_engine):
+ """The PostgreSQL pgvector revision also evolves the OSS ORM schema."""
+
+ async with sqlite_engine.begin() as conn:
+ await conn.exec_driver_sql(
+ 'CREATE TABLE knowledge_bases ('
+ 'uuid VARCHAR(255) PRIMARY KEY, workspace_uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL)'
+ )
+
+ await run_alembic_stamp(sqlite_engine, '0012_plugin_identity')
+ await run_alembic_upgrade(sqlite_engine, 'head')
+
+ async with sqlite_engine.connect() as conn:
+ columns = await conn.run_sync(
+ lambda sync_conn: {
+ item['name'] for item in sqlalchemy.inspect(sync_conn).get_columns('knowledge_bases')
+ }
+ )
+ assert 'embedding_dimension' in columns
+
+ @pytest.mark.asyncio
+ async def test_directory_projection_upgrade_downgrade_round_trip(self, sqlite_engine):
+ await run_alembic_stamp(sqlite_engine, '0013_tenant_pgvector')
+
+ await run_alembic_upgrade(sqlite_engine, 'head')
+ async with sqlite_engine.connect() as conn:
+ tables = await conn.run_sync(lambda sync_conn: set(sqlalchemy.inspect(sync_conn).get_table_names()))
+ assert {'directory_projection_states', 'directory_projection_inbox'} <= tables
+
+ await run_alembic_downgrade(sqlite_engine, '0013_tenant_pgvector')
+ async with sqlite_engine.connect() as conn:
+ tables = await conn.run_sync(lambda sync_conn: set(sqlalchemy.inspect(sync_conn).get_table_names()))
+ assert 'directory_projection_states' not in tables
+ assert 'directory_projection_inbox' not in tables
+
+ await run_alembic_upgrade(sqlite_engine, 'head')
+ assert await get_alembic_current(sqlite_engine) == _get_script_head()
+
class TestSQLiteMigrationFreshDatabase:
"""Tests for fresh database workflow."""
diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py
index 7eee23785..4eec1668c 100644
--- a/tests/integration/persistence/test_migrations_postgres.py
+++ b/tests/integration/persistence/test_migrations_postgres.py
@@ -13,12 +13,41 @@ CI runs automatically with PostgreSQL service container.
from __future__ import annotations
+import logging
import os
+import uuid
+import asyncio
+import contextlib
+import datetime
+import hashlib
+import time
+import typing
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
import pytest
-from sqlalchemy.ext.asyncio import create_async_engine
+import sqlalchemy as sa
+from quart import Quart
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy import text
+from langbot.pkg.cloud.directory import DirectoryEvent, DirectoryEventBatch
+from langbot.pkg.cloud.directory_projection import DirectoryProjectionService
from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.cloud_directory import (
+ DirectoryProjectionInbox,
+ DirectoryProjectionState,
+)
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
+from langbot.pkg.persistence.tenant_uow import (
+ TENANT_POLICY_NAME,
+ TENANT_TABLE_COLUMNS,
+ ScopedSessionTransactionError,
+ TenantUnitOfWork,
+ TransactionRollbackOnlyError,
+)
from langbot.pkg.persistence.alembic_runner import (
run_alembic_upgrade,
run_alembic_stamp,
@@ -27,6 +56,90 @@ from langbot.pkg.persistence.alembic_runner import (
)
from alembic.config import Config
from alembic.script import ScriptDirectory
+from langbot.pkg.utils import constants
+from langbot.pkg.workspace.collaboration import normalize_email
+from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
+from langbot.pkg.workspace.policy import CloudWorkspacePolicy
+from langbot.pkg.workspace.service import WorkspaceService
+from langbot.pkg.api.http.authz import Permission
+from langbot.pkg.api.http.controller import group as http_group
+from langbot.pkg.api.http.controller.groups.knowledge.migration import _CURRENT_KNOWLEDGE_BASE
+from langbot.pkg.api.http.controller.groups.system import SystemRouterGroup
+from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup
+from langbot.pkg.api.http.context import ExecutionContext, RequestContext
+from langbot.pkg.api.http.service.apikey import ApiKeyService
+from langbot.pkg.api.http.service.monitoring import MonitoringService
+from langbot.pkg.api.http.service.user import UserService
+from langbot.pkg.api.mcp.context import get_request_context as get_mcp_request_context
+from langbot.pkg.api.mcp.mount import MCPMount
+from langbot.pkg.entity.persistence.apikey import ApiKey
+from langbot.pkg.entity.persistence import bot as persistence_bot
+from langbot.pkg.entity.persistence import mcp as persistence_mcp
+from langbot.pkg.entity.persistence import model as persistence_model
+from langbot.pkg.entity.persistence import pipeline as persistence_pipeline
+from langbot.pkg.entity.persistence import plugin as persistence_plugin
+from langbot.pkg.entity.persistence import rag as persistence_rag
+from langbot.pkg.entity.persistence.metadata import WorkspaceMetadata
+from langbot.pkg.entity.persistence.monitoring import MonitoringFeedback
+from langbot.pkg.entity.persistence.workspace import (
+ Workspace,
+ WorkspaceExecutionState,
+ WorkspaceMembership,
+)
+from langbot.pkg.platform.botmgr import PlatformManager
+from langbot.pkg.pipeline.pipelinemgr import PipelineManager
+from langbot.pkg.plugin.connector import PluginRuntimeConnector
+from langbot.pkg.provider.modelmgr import requester as model_requester
+from langbot.pkg.provider.modelmgr import token as model_token
+from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
+from langbot.pkg.provider.tools.loaders.mcp import MCPLoader
+from langbot.pkg.rag.knowledge.kbmgr import RAGManager
+
+from .resource_migration_support import TENANT_TABLES, create_legacy_resource_schema
+
+
+class _NoopDirectoryProjectionProvider:
+ async def fetch_snapshot(self, instance_uuid: str):
+ raise AssertionError(f'Unexpected snapshot fetch for {instance_uuid}')
+
+ async def fetch_events(self, instance_uuid: str, after_cursor: int, limit: int):
+ raise AssertionError(f'Unexpected event fetch for {instance_uuid} after {after_cursor} with limit {limit}')
+
+ async def fetch_workspaces(self, instance_uuid: str, workspace_uuids: tuple[str, ...]):
+ raise AssertionError(f'Unexpected delta fetch for {instance_uuid}: {workspace_uuids!r}')
+
+
+class _CapacityPluginRuntimeHandler:
+ """Minimal shared Runtime control-plane surface for the startup probe."""
+
+ def __init__(self) -> None:
+ self.bindings: dict[str, typing.Any] = {}
+ self.reconciled: tuple[typing.Any, ...] = ()
+
+ def register_installation_binding(
+ self,
+ binding,
+ *,
+ plugin_author: str,
+ plugin_name: str,
+ ) -> None:
+ self.bindings[binding.installation_uuid] = (
+ binding,
+ plugin_author,
+ plugin_name,
+ )
+
+ def unregister_installation_binding(self, binding) -> None:
+ self.bindings.pop(binding.installation_uuid, None)
+
+ async def reconcile_plugin_installations(self, desired_states) -> dict:
+ self.reconciled = tuple(desired_states)
+ return {
+ 'applied': [],
+ 'removed': [],
+ 'missing_artifacts': [],
+ 'failed_installations': [],
+ }
def _get_script_head() -> str:
@@ -40,6 +153,80 @@ def _get_script_head() -> str:
return ScriptDirectory.from_config(cfg).get_current_head()
+async def _grant_runtime_role_business_objects(
+ conn: AsyncConnection,
+ role_name: str,
+ quote: typing.Callable[[str], str],
+) -> None:
+ """Mirror the release job's object ACLs without overgranting Alembic."""
+
+ business_tables = tuple(sorted({table.name for table in Base.metadata.tables.values()} | {'langbot_vectors'}))
+ quoted_tables = ', '.join(f'public.{quote(table_name)}' for table_name in business_tables)
+ await conn.execute(text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(role_name)}'))
+ await conn.execute(text(f'GRANT SELECT ON TABLE public.alembic_version TO {quote(role_name)}'))
+ sequence_query = text(
+ """
+ SELECT DISTINCT sequence.relname
+ FROM pg_class sequence
+ JOIN pg_namespace sequence_namespace ON sequence_namespace.oid = sequence.relnamespace
+ JOIN pg_depend dependency
+ ON dependency.classid = 'pg_class'::regclass
+ AND dependency.objid = sequence.oid
+ AND dependency.refclassid = 'pg_class'::regclass
+ AND dependency.deptype IN ('a', 'i')
+ JOIN pg_class business_table ON business_table.oid = dependency.refobjid
+ JOIN pg_namespace table_namespace ON table_namespace.oid = business_table.relnamespace
+ WHERE sequence.relkind = 'S'
+ AND sequence_namespace.nspname = 'public'
+ AND table_namespace.nspname = 'public'
+ AND business_table.relname IN :table_names
+ ORDER BY sequence.relname
+ """
+ ).bindparams(sa.bindparam('table_names', expanding=True))
+ sequence_names = tuple((await conn.execute(sequence_query, {'table_names': business_tables})).scalars().all())
+ if sequence_names:
+ quoted_sequences = ', '.join(f'public.{quote(sequence_name)}' for sequence_name in sequence_names)
+ await conn.execute(text(f'GRANT USAGE, SELECT ON SEQUENCE {quoted_sequences} TO {quote(role_name)}'))
+
+
+def _application_for_postgres_url(postgres_url: str, logger_name: str) -> SimpleNamespace:
+ url = sa.engine.make_url(postgres_url)
+ return SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'use': 'postgresql',
+ 'postgresql': {
+ 'host': url.host,
+ 'port': url.port,
+ 'user': url.username,
+ 'password': url.password,
+ 'database': url.database,
+ },
+ }
+ }
+ ),
+ logger=logging.getLogger(logger_name),
+ )
+
+
+async def _dispose_manager(manager: PersistenceManager | None) -> None:
+ if manager is not None and getattr(manager, 'db', None) is not None:
+ await manager.get_db_engine().dispose()
+
+
+def _restore_postgres_manager_registry(monkeypatch) -> None:
+ """Undo the registry isolation used by test_database_decorator.py."""
+ from langbot.pkg.persistence import mgr as persistence_mgr_module
+ from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
+
+ monkeypatch.setattr(
+ persistence_mgr_module.database,
+ 'preregistered_managers',
+ [PostgreSQLDatabaseManager],
+ )
+
+
pytestmark = [pytest.mark.integration, pytest.mark.slow]
@@ -63,15 +250,20 @@ async def postgres_engine(postgres_url):
@pytest.fixture
async def clean_tables(postgres_engine):
"""Drop all tables before and after each test for isolation."""
- # Drop all tables before test
- async with postgres_engine.begin() as conn:
- await conn.run_sync(Base.metadata.drop_all)
+ async def drop_all_tables() -> None:
+ # Alembic can create tables (notably langbot_vectors) outside the ORM
+ # metadata, and legacy migration tests intentionally alter constraints.
+ # Reflect the dedicated test schema instead of relying on stale ORM DDL.
+ async with postgres_engine.begin() as conn:
+ table_names = await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
+ quote = postgres_engine.dialect.identifier_preparer.quote
+ for table_name in table_names:
+ await conn.execute(text(f'DROP TABLE {quote(table_name)} CASCADE'))
+
+ await drop_all_tables()
yield
-
- # Drop all tables after test
- async with postgres_engine.begin() as conn:
- await conn.run_sync(Base.metadata.drop_all)
+ await drop_all_tables()
@pytest.fixture
@@ -130,6 +322,27 @@ class TestPostgreSQLMigrationBaseline:
rev = await get_alembic_current(postgres_engine)
assert rev == '0001_baseline'
+ @pytest.mark.asyncio
+ async def test_fresh_postgres_schema_accepts_application_casefold_identity(
+ self,
+ postgres_engine,
+ clean_tables,
+ clean_alembic_version,
+ ):
+ canonical_email = normalize_email('Ꭰ@Example.COM')
+ async with postgres_engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ await conn.execute(
+ sa.insert(User).values(
+ uuid='00000000-0000-0000-0000-000000000099',
+ user=canonical_email,
+ normalized_email=canonical_email,
+ password='hash',
+ )
+ )
+ async with postgres_engine.connect() as conn:
+ assert await conn.scalar(sa.select(User.normalized_email)) == 'Ꭰ@example.com'
+
class TestPostgreSQLMigrationUpgrade:
"""Tests for upgrade to head workflow on PostgreSQL."""
@@ -190,6 +403,57 @@ class TestPostgreSQLMigrationUpgrade:
rev2 = await get_alembic_current(postgres_engine)
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize('settings_type', ['TEXT', 'JSON'])
+ async def test_legacy_knowledge_restore_statement_accepts_historical_and_fresh_settings_types(
+ self,
+ postgres_engine,
+ clean_tables,
+ clean_alembic_version,
+ settings_type,
+ ):
+ timestamp = datetime.datetime(2026, 7, 20, 3, 0, 0, 123456)
+ async with postgres_engine.begin() as conn:
+ await conn.execute(
+ text(f"""
+ CREATE TABLE knowledge_bases (
+ uuid TEXT PRIMARY KEY,
+ workspace_uuid TEXT NOT NULL,
+ name TEXT,
+ description TEXT,
+ emoji TEXT,
+ created_at TIMESTAMP,
+ updated_at TIMESTAMP,
+ knowledge_engine_plugin_id TEXT,
+ collection_id TEXT,
+ creation_settings {settings_type},
+ retrieval_settings {settings_type}
+ )
+ """)
+ )
+ await conn.execute(
+ _CURRENT_KNOWLEDGE_BASE.insert().values(
+ uuid=f'kb-{settings_type.lower()}',
+ workspace_uuid='workspace-a',
+ name='Legacy KB',
+ description='Compatibility probe',
+ emoji='📚',
+ created_at=timestamp,
+ updated_at=timestamp,
+ knowledge_engine_plugin_id='langbot-team/LangRAG',
+ collection_id=f'kb-{settings_type.lower()}',
+ creation_settings='{"embedding_model_uuid": "embedding-model"}',
+ retrieval_settings='{"top_k": 5}',
+ )
+ )
+ row = (
+ await conn.execute(
+ text('SELECT created_at, creation_settings::text AS creation_settings FROM knowledge_bases')
+ )
+ ).one()
+ assert row.created_at == timestamp
+ assert 'embedding_model_uuid' in row.creation_settings
+
class TestPostgreSQLMigrationGetCurrent:
"""Tests for get_alembic_current behavior on PostgreSQL."""
@@ -223,3 +487,1693 @@ class TestPostgreSQLMigrationGetCurrent:
rev = await get_alembic_current(postgres_engine)
assert rev == '0001_baseline'
+
+
+class TestPostgreSQLWorkspaceMigration:
+ """Focused coverage for upgrading a pre-tenancy PostgreSQL instance."""
+
+ @pytest.mark.asyncio
+ async def test_postgres_legacy_instance_gets_default_workspace(
+ self,
+ postgres_engine,
+ clean_tables,
+ clean_alembic_version,
+ monkeypatch,
+ ):
+ legacy_metadata = sa.MetaData()
+ metadata_table = sa.Table(
+ 'metadata',
+ legacy_metadata,
+ sa.Column('key', sa.String(255), primary_key=True),
+ sa.Column('value', sa.String(255)),
+ )
+ users = sa.Table(
+ 'users',
+ legacy_metadata,
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('user', sa.String(255), nullable=False),
+ sa.Column('password', sa.String(255), nullable=False),
+ sa.Column(
+ 'account_type',
+ sa.String(32),
+ nullable=False,
+ server_default='local',
+ ),
+ sa.Column('created_at', sa.DateTime, server_default=text('now()')),
+ sa.Column('updated_at', sa.DateTime, server_default=text('now()')),
+ )
+ async with postgres_engine.begin() as conn:
+ await conn.run_sync(legacy_metadata.create_all)
+ await conn.execute(metadata_table.insert().values(key='database_version', value='25'))
+ await conn.execute(metadata_table.insert().values(key='instance_uuid', value='instance_postgres_test'))
+ await conn.execute(users.insert().values(user='owner@example.com', password='owner-hash'))
+
+ await run_alembic_stamp(postgres_engine, '0008_mcp_resource_prefs')
+ monkeypatch.setattr(constants, 'instance_id', 'instance_postgres_test')
+ database = type('Database', (), {'get_engine': lambda self: postgres_engine})()
+ application = type('Application', (), {})()
+ application.logger = logging.getLogger('postgres-workspace-startup-test')
+ manager = PersistenceManager(application)
+ manager.db = database
+
+ await manager.create_tables()
+ async with postgres_engine.connect() as conn:
+ tables_before_migration = set(
+ await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
+ )
+ assert 'workspaces' not in tables_before_migration
+
+ await manager._initialize_managed_schema()
+
+ async with postgres_engine.connect() as conn:
+ account = (await conn.execute(text('SELECT uuid, status, source FROM users'))).mappings().one()
+ workspace = (
+ (await conn.execute(text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
+ .mappings()
+ .one()
+ )
+ membership = (await conn.execute(text('SELECT * FROM workspace_memberships'))).mappings().one()
+ execution_state = (await conn.execute(text('SELECT * FROM workspace_execution_states'))).mappings().one()
+
+ assert account['status'] == 'active'
+ assert account['source'] == 'local'
+ assert workspace['instance_uuid'] == 'instance_postgres_test'
+ assert workspace['created_by_account_uuid'] == account['uuid']
+ assert membership['account_uuid'] == account['uuid']
+ assert membership['role'] == 'owner'
+ assert execution_state['active_generation'] == 1
+ assert execution_state['write_fenced'] is False
+
+
+class TestPostgreSQLResourceTenancyMigration:
+ """Legacy backfill and scoped-key enforcement on real PostgreSQL."""
+
+ @pytest.mark.asyncio
+ async def test_postgres_resources_are_backfilled_and_scoped(
+ self,
+ postgres_engine,
+ clean_tables,
+ clean_alembic_version,
+ ):
+ await create_legacy_resource_schema(
+ postgres_engine,
+ instance_uuid='postgres-resource-migration-test',
+ )
+ async with postgres_engine.begin() as conn:
+ await conn.execute(text('UPDATE users SET "user" = \'Straße@Example.COM\''))
+ await conn.execute(
+ text('INSERT INTO users ("user", password) VALUES (:email, :password)'),
+ {'email': 'Ꭰ@Example.COM', 'password': 'cherokee-hash'},
+ )
+ await run_alembic_stamp(postgres_engine, '0008_mcp_resource_prefs')
+ await run_alembic_upgrade(postgres_engine, 'head')
+
+ async with postgres_engine.connect() as conn:
+ workspace_uuid = await conn.scalar(text("SELECT uuid FROM workspaces WHERE source = 'local'"))
+ for table_name in TENANT_TABLES:
+ count, distinct_workspaces = (
+ await conn.execute(text(f'SELECT COUNT(*), COUNT(DISTINCT workspace_uuid) FROM {table_name}'))
+ ).one()
+ assert (count, distinct_workspaces) == (1, 1), table_name
+ columns = await conn.run_sync(
+ lambda sync_conn, name=table_name: {
+ column['name']: column for column in sa.inspect(sync_conn).get_columns(name)
+ }
+ )
+ assert columns['workspace_uuid']['nullable'] is False, table_name
+
+ api_columns = await conn.run_sync(
+ lambda sync_conn: {column['name'] for column in sa.inspect(sync_conn).get_columns('api_keys')}
+ )
+ assert 'key' not in api_columns
+ assert await conn.scalar(text('SELECT scopes FROM api_keys')) == ['*']
+ assert (await conn.execute(text('SELECT normalized_email FROM users ORDER BY id'))).scalars().all() == [
+ 'strasse@example.com',
+ 'Ꭰ@example.com',
+ ]
+
+ second_workspace_uuid = '00000000-0000-0000-0000-000000000002'
+ async with postgres_engine.begin() as conn:
+ await conn.execute(
+ text(
+ 'INSERT INTO workspaces '
+ '(uuid, instance_uuid, name, slug, type, status, source, projection_revision) '
+ "VALUES (:uuid, 'postgres-resource-migration-test', 'Second', 'second', "
+ "'team', 'active', 'cloud_projection', 0)"
+ ),
+ {'uuid': second_workspace_uuid},
+ )
+ await conn.execute(
+ text(
+ 'INSERT INTO mcp_servers (uuid, workspace_uuid, name, enable, updated_at) '
+ "VALUES ('mcp-2', :workspace_uuid, 'shared-name', true, now())"
+ ),
+ {'workspace_uuid': second_workspace_uuid},
+ )
+ await conn.execute(
+ text(
+ 'INSERT INTO plugin_settings '
+ '(workspace_uuid, plugin_author, plugin_name, enabled, '
+ 'installation_uuid, artifact_digest, runtime_revision) '
+ "VALUES (:workspace_uuid, 'author', 'plugin', true, "
+ ':installation_uuid, :artifact_digest, 1)'
+ ),
+ {
+ 'workspace_uuid': second_workspace_uuid,
+ 'installation_uuid': str(uuid.uuid4()),
+ 'artifact_digest': hashlib.sha256(b'test-plugin-artifact').hexdigest(),
+ },
+ )
+
+ with pytest.raises(IntegrityError):
+ async with postgres_engine.begin() as conn:
+ await conn.execute(
+ text(
+ 'INSERT INTO mcp_servers '
+ '(uuid, workspace_uuid, name, enable, updated_at) '
+ "VALUES ('mcp-duplicate', :workspace_uuid, 'shared-name', true, now())"
+ ),
+ {'workspace_uuid': workspace_uuid},
+ )
+
+ with pytest.raises(IntegrityError):
+ async with postgres_engine.begin() as conn:
+ await conn.execute(
+ text(
+ 'INSERT INTO llm_models (uuid, workspace_uuid, name, provider_uuid) '
+ "VALUES ('cross-workspace-model', :workspace_uuid, 'model', 'provider-1')"
+ ),
+ {'workspace_uuid': second_workspace_uuid},
+ )
+
+
+class TestPostgreSQLTenantRuntime:
+ """Release bootstrap, RLS enforcement, and runtime-role safety."""
+
+ @pytest.mark.asyncio
+ async def test_oss_postgres_defaults_to_the_singleton_workspace(
+ self,
+ postgres_url,
+ clean_tables,
+ clean_alembic_version,
+ monkeypatch,
+ ):
+ instance_uuid = 'oss-postgres-rls-compatibility-test'
+ _restore_postgres_manager_registry(monkeypatch)
+ monkeypatch.setattr(constants, 'instance_id', instance_uuid)
+ manager = PersistenceManager(
+ _application_for_postgres_url(postgres_url, 'postgres-oss-rls-compatibility-test'),
+ mode=PersistenceMode.OSS_COMPAT,
+ )
+ try:
+ await manager.initialize()
+ async with manager.get_db_engine().connect() as conn:
+ workspace_uuid = await conn.scalar(text("SELECT uuid FROM workspaces WHERE source = 'local'"))
+ tenant_setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)"))
+ visible_workspaces = await conn.scalar(text('SELECT COUNT(*) FROM workspaces'))
+ assert workspace_uuid == tenant_setting
+ assert visible_workspaces == 1
+ finally:
+ await _dispose_manager(manager)
+
+ @pytest.mark.asyncio
+ async def test_populated_cloud_startup_is_linear_and_task_bounded(
+ self,
+ postgres_url,
+ postgres_engine,
+ clean_tables,
+ clean_alembic_version,
+ monkeypatch,
+ ):
+ """Run the real Cloud startup query graph against populated RLS tenants.
+
+ The default is intentionally small enough for CI. Audit runs can raise
+ ``LANGBOT_PG_CAPACITY_WORKSPACES`` without changing the test contract.
+ Every tenant owns one representative startup resource of each kind.
+ """
+
+ workspace_count = int(os.environ.get('LANGBOT_PG_CAPACITY_WORKSPACES', '25'))
+ if not 1 <= workspace_count <= 2_000:
+ raise ValueError('LANGBOT_PG_CAPACITY_WORKSPACES must be between 1 and 2000')
+ max_elapsed_raw = os.environ.get(
+ 'LANGBOT_PG_CAPACITY_MAX_SECONDS',
+ )
+ max_elapsed = float(max_elapsed_raw) if max_elapsed_raw is not None else None
+ instance_uuid = 'cloud-populated-startup-capacity-test'
+ role_name = f'lb_capacity_{uuid.uuid4().hex[:12]}'
+ role_password = f'Lb{uuid.uuid4().hex}'
+ quote = postgres_engine.dialect.identifier_preparer.quote
+ managers: list[PersistenceManager] = []
+ role_created = False
+ statement_counts = {
+ table_name: 0
+ for table_name in (
+ 'model_providers',
+ 'llm_models',
+ 'embedding_models',
+ 'rerank_models',
+ 'bots',
+ 'legacy_pipelines',
+ 'knowledge_bases',
+ 'mcp_servers',
+ 'plugin_settings',
+ )
+ }
+ measured_engine = None
+ model_manager = None
+ platform_manager = None
+ mcp_loader = None
+
+ _restore_postgres_manager_registry(monkeypatch)
+ monkeypatch.setattr(constants, 'instance_id', instance_uuid)
+ release_manager = PersistenceManager(
+ _application_for_postgres_url(
+ postgres_url,
+ 'postgres-capacity-release-test',
+ ),
+ mode=PersistenceMode.RELEASE_MIGRATION,
+ )
+ managers.append(release_manager)
+
+ def role_url() -> str:
+ return (
+ sa.engine.make_url(postgres_url)
+ .set(username=role_name, password=role_password)
+ .render_as_string(hide_password=False)
+ )
+
+ def count_resource_statements(
+ _conn,
+ _cursor,
+ statement,
+ _parameters,
+ _context,
+ _executemany,
+ ) -> None:
+ normalized = ' '.join(str(statement).lower().split())
+ for table_name in statement_counts:
+ if f' from {table_name}' in normalized or f' from "{table_name}"' in normalized:
+ statement_counts[table_name] += 1
+
+ try:
+ await release_manager.initialize()
+ for index in range(workspace_count):
+ workspace_uuid = f'ca{index:06x}-0000-4000-8000-{index:012x}'
+ suffix = f'{index:06d}'
+ provider_uuid = f'capacity-provider-{suffix}'
+ async with release_manager.tenant_uow(workspace_uuid) as uow:
+ uow.session.add(
+ Workspace(
+ uuid=workspace_uuid,
+ instance_uuid=instance_uuid,
+ name=f'Capacity {suffix}',
+ slug=f'capacity-{suffix}',
+ type='team',
+ status='active',
+ source='cloud_projection',
+ projection_revision=1,
+ )
+ )
+ await uow.session.flush()
+ uow.session.add_all(
+ [
+ WorkspaceExecutionState(
+ workspace_uuid=workspace_uuid,
+ instance_uuid=instance_uuid,
+ active_generation=1,
+ state='active',
+ write_fenced=False,
+ source='cloud',
+ desired_state_revision=1,
+ ),
+ persistence_model.ModelProvider(
+ uuid=provider_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Capacity Provider',
+ requester='capacity-probe',
+ base_url='https://capacity.invalid',
+ api_keys=[],
+ ),
+ ]
+ )
+ await uow.session.flush()
+ uow.session.add_all(
+ [
+ persistence_model.LLMModel(
+ uuid=f'capacity-llm-{suffix}',
+ workspace_uuid=workspace_uuid,
+ name='Capacity LLM',
+ provider_uuid=provider_uuid,
+ abilities=[],
+ extra_args={},
+ ),
+ persistence_model.EmbeddingModel(
+ uuid=f'capacity-embedding-{suffix}',
+ workspace_uuid=workspace_uuid,
+ name='Capacity Embedding',
+ provider_uuid=provider_uuid,
+ extra_args={},
+ ),
+ persistence_model.RerankModel(
+ uuid=f'capacity-rerank-{suffix}',
+ workspace_uuid=workspace_uuid,
+ name='Capacity Rerank',
+ provider_uuid=provider_uuid,
+ extra_args={},
+ ),
+ persistence_bot.Bot(
+ uuid=f'capacity-bot-{suffix}',
+ workspace_uuid=workspace_uuid,
+ name='Capacity Bot',
+ description='',
+ adapter='capacity-probe',
+ adapter_config={},
+ enable=False,
+ pipeline_routing_rules=[],
+ ),
+ persistence_pipeline.LegacyPipeline(
+ uuid=f'capacity-pipeline-{suffix}',
+ workspace_uuid=workspace_uuid,
+ name='Capacity Pipeline',
+ description='',
+ for_version='capacity-probe',
+ is_default=True,
+ stages=[],
+ config={},
+ extensions_preferences={},
+ ),
+ persistence_rag.KnowledgeBase(
+ uuid=f'capacity-kb-{suffix}',
+ workspace_uuid=workspace_uuid,
+ name='Capacity Knowledge Base',
+ description='',
+ collection_id=f'capacity-collection-{suffix}',
+ legacy_vector_collection=False,
+ ),
+ persistence_mcp.MCPServer(
+ uuid=f'capacity-mcp-{suffix}',
+ workspace_uuid=workspace_uuid,
+ name=f'capacity-mcp-{suffix}',
+ enable=False,
+ mode='remote',
+ extra_args={},
+ ),
+ persistence_plugin.PluginSetting(
+ workspace_uuid=workspace_uuid,
+ plugin_author='capacity',
+ plugin_name=f'plugin-{suffix}',
+ installation_uuid=str(
+ uuid.uuid5(
+ uuid.NAMESPACE_URL,
+ f'langbot-capacity:{workspace_uuid}',
+ )
+ ),
+ artifact_digest=hashlib.sha256(workspace_uuid.encode()).hexdigest(),
+ runtime_revision=1,
+ enabled=False,
+ config={},
+ install_source='github',
+ install_info={},
+ ),
+ ]
+ )
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f"CREATE ROLE {quote(role_name)} LOGIN PASSWORD '{role_password}'"))
+ role_created = True
+ await conn.execute(
+ text(
+ f'GRANT CONNECT ON DATABASE '
+ f'{quote(sa.engine.make_url(postgres_url).database)} '
+ f'TO {quote(role_name)}'
+ )
+ )
+ await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(role_name)}'))
+ await _grant_runtime_role_business_objects(
+ conn,
+ role_name,
+ quote,
+ )
+
+ runtime_application = _application_for_postgres_url(
+ role_url(),
+ 'postgres-capacity-runtime-test',
+ )
+ runtime_application.instance_config.data.update(
+ {
+ 'plugin': {'enable': True},
+ 'mcp': {'lifecycle_concurrency': 8},
+ }
+ )
+ runtime_application.deployment = SimpleNamespace(
+ mode='cloud',
+ multi_workspace_enabled=False,
+ )
+ runtime_application.task_mgr = SimpleNamespace(
+ cancel_by_scope=lambda *_args, **_kwargs: None,
+ )
+ cloud_manager = PersistenceManager(
+ runtime_application,
+ mode=PersistenceMode.CLOUD_RUNTIME,
+ )
+ managers.append(cloud_manager)
+ await cloud_manager.initialize()
+ runtime_application.persistence_mgr = cloud_manager
+ cloud_manager.ap = runtime_application
+ runtime_application.workspace_service = WorkspaceService(
+ runtime_application,
+ policy=CloudWorkspacePolicy(),
+ instance_uuid=instance_uuid,
+ )
+
+ measured_engine = cloud_manager.get_db_engine().sync_engine
+ sa.event.listen(
+ measured_engine,
+ 'before_cursor_execute',
+ count_resource_statements,
+ )
+ wall_started = time.monotonic()
+ cpu_started = time.process_time()
+
+ bindings = await runtime_application.workspace_service.prime_startup_execution_bindings()
+ assert len(bindings) == workspace_count
+
+ model_manager = ModelManager(runtime_application)
+
+ async def build_capacity_provider(
+ context,
+ provider_entity,
+ ):
+ return model_requester.RuntimeProvider(
+ context,
+ provider_entity,
+ model_token.TokenManager(
+ provider_entity.uuid,
+ provider_entity.api_keys or [],
+ ),
+ SimpleNamespace(aclose=AsyncMock()),
+ )
+
+ model_manager._build_provider = build_capacity_provider
+ await model_manager.load_models_from_db()
+
+ platform_manager = PlatformManager(runtime_application)
+ platform_manager.load_bot = AsyncMock()
+ await platform_manager.load_bots_from_db()
+
+ pipeline_manager = PipelineManager(runtime_application)
+ pipeline_manager.stage_dict = {}
+ await pipeline_manager.load_pipelines_from_db()
+
+ rag_manager = RAGManager(runtime_application)
+ await rag_manager.load_knowledge_bases_from_db()
+
+ mcp_loader = MCPLoader(runtime_application)
+ mcp_loader.host_mcp_server = AsyncMock()
+ await mcp_loader.load_mcp_servers_from_db()
+ dispatch_tasks = tuple(mcp_loader._host_dispatch_tasks)
+ if dispatch_tasks:
+ await asyncio.gather(*dispatch_tasks)
+ await asyncio.sleep(0)
+
+ plugin_connector = PluginRuntimeConnector(
+ runtime_application,
+ AsyncMock(),
+ )
+ plugin_handler = _CapacityPluginRuntimeHandler()
+ plugin_connector.handler = plugin_handler
+ contexts = [
+ ExecutionContext(
+ instance_uuid=binding.instance_uuid,
+ workspace_uuid=binding.workspace_uuid,
+ placement_generation=binding.placement_generation,
+ )
+ for binding in bindings
+ ]
+ await plugin_connector.reconcile_projected_workspaces(contexts)
+
+ elapsed = time.monotonic() - wall_started
+ cpu_seconds = time.process_time() - cpu_started
+ logging.getLogger('postgres-capacity-runtime-test').info(
+ 'Populated Cloud startup capacity: workspaces=%d elapsed=%.3fs cpu=%.3fs statements=%s',
+ workspace_count,
+ elapsed,
+ cpu_seconds,
+ statement_counts,
+ )
+
+ assert len(model_manager.provider_dict) == workspace_count
+ assert len(model_manager.llm_model_dict) == workspace_count
+ assert len(model_manager.embedding_model_dict) == workspace_count
+ assert len(model_manager.rerank_model_dict) == workspace_count
+ assert platform_manager.load_bot.await_count == workspace_count
+ assert len(pipeline_manager.pipelines) == workspace_count
+ assert len(rag_manager.knowledge_bases) == workspace_count
+ assert mcp_loader.host_mcp_server.await_count == workspace_count
+ assert not mcp_loader._host_dispatch_tasks
+ assert not mcp_loader._hosted_mcp_tasks
+ assert len(plugin_handler.reconciled) == workspace_count
+ assert len(plugin_handler.bindings) == workspace_count
+ assert all(count == workspace_count for count in statement_counts.values()), statement_counts
+ if max_elapsed is not None:
+ assert elapsed <= max_elapsed
+ finally:
+ cleanup_errors: list[BaseException] = []
+ if measured_engine is not None:
+ sa.event.remove(
+ measured_engine,
+ 'before_cursor_execute',
+ count_resource_statements,
+ )
+ if mcp_loader is not None:
+ try:
+ await mcp_loader.shutdown()
+ except BaseException as exc:
+ cleanup_errors.append(exc)
+ if platform_manager is not None:
+ try:
+ await platform_manager.shutdown()
+ except BaseException as exc:
+ cleanup_errors.append(exc)
+ if model_manager is not None:
+ try:
+ await model_manager.shutdown()
+ except BaseException as exc:
+ cleanup_errors.append(exc)
+ for manager in reversed(managers):
+ try:
+ await _dispose_manager(manager)
+ except BaseException as exc:
+ cleanup_errors.append(exc)
+ if role_created:
+ try:
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'DROP OWNED BY {quote(role_name)}'))
+ await conn.execute(text(f'DROP ROLE {quote(role_name)}'))
+ except BaseException as exc:
+ cleanup_errors.append(exc)
+ if cleanup_errors:
+ raise cleanup_errors[0]
+
+ @pytest.mark.asyncio
+ async def test_release_bootstrap_and_runtime_isolation(
+ self,
+ postgres_url,
+ postgres_engine,
+ clean_tables,
+ clean_alembic_version,
+ monkeypatch,
+ ):
+ instance_uuid = 'cloud-runtime-persistence-test'
+ workspace_a = '10000000-0000-0000-0000-000000000001'
+ workspace_b = '20000000-0000-0000-0000-000000000002'
+ workspace_other = '30000000-0000-0000-0000-000000000003'
+ workspace_local = '40000000-0000-0000-0000-000000000004'
+ directory_account = '50000000-0000-0000-0000-000000000005'
+ workspace_projected = '60000000-0000-0000-0000-000000000006'
+ role_suffix = uuid.uuid4().hex[:12]
+ runtime_role = f'lb_runtime_{role_suffix}'
+ bypass_role = f'lb_bypass_{role_suffix}'
+ owner_role = f'lb_owner_{role_suffix}'
+ role_password = f'Lb{uuid.uuid4().hex}'
+ created_roles: list[str] = []
+ managers: list[PersistenceManager] = []
+ runtime_engine: AsyncEngine | None = None
+ owner_changed = False
+
+ _restore_postgres_manager_registry(monkeypatch)
+ monkeypatch.setattr(constants, 'instance_id', instance_uuid)
+ release_manager = PersistenceManager(
+ _application_for_postgres_url(postgres_url, 'postgres-release-bootstrap-test'),
+ mode=PersistenceMode.RELEASE_MIGRATION,
+ )
+ managers.append(release_manager)
+
+ async with postgres_engine.connect() as conn:
+ admin_user = await conn.scalar(text('SELECT current_user'))
+ quote = postgres_engine.dialect.identifier_preparer.quote
+
+ def role_url(role_name: str) -> str:
+ return (
+ sa.engine.make_url(postgres_url)
+ .set(
+ username=role_name,
+ password=role_password,
+ )
+ .render_as_string(hide_password=False)
+ )
+
+ async def create_role(role_name: str, *, bypass_rls: bool = False) -> None:
+ bypass_clause = ' BYPASSRLS' if bypass_rls else ''
+ async with postgres_engine.connect() as conn:
+ await conn.execute(
+ text(f"CREATE ROLE {quote(role_name)} LOGIN{bypass_clause} PASSWORD '{role_password}'")
+ )
+ await conn.execute(
+ text(
+ f'GRANT CONNECT ON DATABASE {quote(sa.engine.make_url(postgres_url).database)} '
+ f'TO {quote(role_name)}'
+ )
+ )
+ await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(role_name)}'))
+ await _grant_runtime_role_business_objects(conn, role_name, quote)
+ created_roles.append(role_name)
+
+ try:
+ await release_manager.initialize()
+ release_engine = release_manager.get_db_engine()
+
+ assert await get_alembic_current(release_engine) == _get_script_head()
+ async with release_engine.connect() as conn:
+ assert await conn.scalar(text('SELECT COUNT(*) FROM workspaces')) == 0
+ rls_rows = (
+ (
+ await conn.execute(
+ text(
+ """
+ SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity,
+ EXISTS (
+ SELECT 1 FROM pg_policy p
+ WHERE p.polrelid = c.oid AND p.polname = :policy_name
+ ) AS has_policy
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = current_schema() AND c.relname IN :table_names
+ """
+ ).bindparams(sa.bindparam('table_names', expanding=True)),
+ {
+ 'policy_name': TENANT_POLICY_NAME,
+ 'table_names': tuple(TENANT_TABLE_COLUMNS),
+ },
+ )
+ )
+ .mappings()
+ .all()
+ )
+ assert {row['relname'] for row in rls_rows} == set(TENANT_TABLE_COLUMNS)
+ assert all(row['relrowsecurity'] and row['relforcerowsecurity'] and row['has_policy'] for row in rls_rows)
+
+ for workspace_uuid, slug, target_instance, source in (
+ (workspace_a, 'workspace-a', instance_uuid, 'cloud_projection'),
+ (workspace_b, 'workspace-b', instance_uuid, 'cloud_projection'),
+ (workspace_other, 'workspace-other', 'other-instance', 'cloud_projection'),
+ (workspace_local, 'workspace-local', instance_uuid, 'local'),
+ ):
+ async with TenantUnitOfWork(release_engine, workspace_uuid) as uow:
+ await uow.execute(
+ sa.insert(Workspace).values(
+ uuid=workspace_uuid,
+ instance_uuid=target_instance,
+ name=slug,
+ slug=slug,
+ type='team',
+ status='active',
+ source=source,
+ projection_revision=0,
+ )
+ )
+ await uow.execute(
+ sa.insert(WorkspaceMetadata).values(
+ workspace_uuid=workspace_uuid,
+ key='seed',
+ value=slug,
+ )
+ )
+ async with release_engine.begin() as conn:
+ await conn.execute(
+ sa.insert(User).values(
+ uuid=directory_account,
+ user='directory@example.com',
+ normalized_email='directory@example.com',
+ password='closed-directory',
+ account_type='space',
+ status='active',
+ source='cloud_projection',
+ projection_revision=1,
+ )
+ )
+ async with TenantUnitOfWork(release_engine, workspace_local) as uow:
+ uow.session.add(
+ WorkspaceMembership(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_local,
+ account_uuid=directory_account,
+ role='owner',
+ status='active',
+ projection_revision=1,
+ )
+ )
+
+ await create_role(runtime_role)
+ runtime_engine = create_async_engine(role_url(runtime_role), pool_size=1, max_overflow=0)
+
+ async with runtime_engine.connect() as conn:
+ assert (await conn.execute(text('SELECT uuid FROM workspaces'))).all() == []
+ assert (await conn.execute(text('SELECT * FROM workspace_metadata'))).all() == []
+
+ with pytest.raises(sa.exc.DBAPIError):
+ async with runtime_engine.begin() as conn:
+ await conn.execute(
+ text(
+ 'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
+ "VALUES (:workspace_uuid, 'no-scope', 'rejected')"
+ ),
+ {'workspace_uuid': workspace_a},
+ )
+
+ async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
+ assert (await uow.execute(sa.select(Workspace.uuid))).scalars().all() == [workspace_a]
+ assert (await uow.execute(sa.select(Workspace.uuid).where(Workspace.uuid == workspace_b))).all() == []
+
+ with pytest.raises(sa.exc.DBAPIError):
+ async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
+ await uow.execute(
+ sa.insert(WorkspaceMetadata).values(
+ workspace_uuid=workspace_b,
+ key='cross-scope',
+ value='rejected',
+ )
+ )
+
+ async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
+ assert (await uow.execute(sa.select(WorkspaceMetadata.value))).scalars().all() == ['workspace-a']
+ async with TenantUnitOfWork(runtime_engine, workspace_b) as uow:
+ assert (await uow.execute(sa.select(WorkspaceMetadata.value))).scalars().all() == ['workspace-b']
+
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
+ with pytest.raises(ScopedSessionTransactionError, match='SQL function'):
+ await uow.execute(
+ sa.select(
+ sa.func.query_to_xml(
+ sa.literal("SELECT set_config('langbot.workspace_uuid', 'workspace-b', true)"),
+ sa.literal(True),
+ sa.literal(False),
+ sa.literal(''),
+ )
+ )
+ )
+ assert (await uow.execute(sa.select(Workspace.uuid))).scalars().all() == [workspace_a]
+
+ async with runtime_engine.connect() as conn:
+ setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)"))
+ assert setting in (None, '')
+ assert await conn.scalar(text('SELECT COUNT(*) FROM workspace_metadata')) == 0
+
+ with pytest.raises(RuntimeError, match='force rollback'):
+ async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
+ await uow.execute(
+ sa.insert(WorkspaceMetadata).values(
+ workspace_uuid=workspace_a,
+ key='rolled-back',
+ value='no',
+ )
+ )
+ raise RuntimeError('force rollback')
+ async with TenantUnitOfWork(runtime_engine, workspace_a) as uow:
+ assert (
+ await uow.session.scalar(
+ sa.select(sa.func.count())
+ .select_from(WorkspaceMetadata)
+ .where(WorkspaceMetadata.key == 'rolled-back')
+ )
+ == 0
+ )
+ async with runtime_engine.connect() as conn:
+ setting = await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)"))
+ assert setting in (None, '')
+
+ cloud_manager = PersistenceManager(
+ _application_for_postgres_url(role_url(runtime_role), 'postgres-cloud-runtime-test'),
+ mode=PersistenceMode.CLOUD_RUNTIME,
+ )
+ cloud_manager.create_tables = AsyncMock(side_effect=AssertionError('Cloud runtime attempted create_all'))
+ cloud_manager._run_alembic_migrations = AsyncMock(
+ side_effect=AssertionError('Cloud runtime attempted an Alembic upgrade')
+ )
+ managers.append(cloud_manager)
+ await cloud_manager.initialize()
+ cloud_manager.create_tables.assert_not_awaited()
+ cloud_manager._run_alembic_migrations.assert_not_awaited()
+
+ directory_fingerprint = hashlib.sha256(b'directory-snapshot').hexdigest()
+ async with cloud_manager.directory_projection_uow(instance_uuid) as directory:
+ assert set((await directory.session.scalars(sa.select(Workspace.uuid))).all()) == {
+ workspace_a,
+ workspace_b,
+ }
+ directory.session.add(
+ Workspace(
+ uuid=workspace_projected,
+ instance_uuid=instance_uuid,
+ name='workspace-projected',
+ slug='workspace-projected',
+ type='team',
+ status='active',
+ source='cloud_projection',
+ projection_revision=1,
+ )
+ )
+ await directory.session.flush()
+ directory.session.add(
+ WorkspaceMembership(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_projected,
+ account_uuid=directory_account,
+ role='owner',
+ status='active',
+ projection_revision=1,
+ )
+ )
+ directory.session.add(
+ WorkspaceExecutionState(
+ workspace_uuid=workspace_projected,
+ instance_uuid=instance_uuid,
+ active_generation=1,
+ state='active',
+ write_fenced=False,
+ source='cloud',
+ desired_state_revision=1,
+ )
+ )
+ directory.session.add(
+ DirectoryProjectionState(
+ instance_uuid=instance_uuid,
+ cursor=1,
+ snapshot_fingerprint=directory_fingerprint,
+ last_applied_at=datetime.datetime.now(datetime.UTC),
+ )
+ )
+ directory.session.add(
+ DirectoryProjectionInbox(
+ instance_uuid=instance_uuid,
+ event_uuid=str(uuid.uuid4()),
+ cursor=1,
+ event_type='workspace.changed',
+ revision=1,
+ fingerprint=directory_fingerprint,
+ )
+ )
+ await directory.session.flush()
+ assert await directory.session.scalar(sa.select(sa.func.count()).select_from(WorkspaceMembership)) == 1
+ assert (await directory.session.execute(sa.select(WorkspaceMetadata))).all() == []
+
+ async with cloud_manager.tenant_uow(workspace_projected) as tenant:
+ workspace_update = await tenant.session.execute(
+ sa.update(Workspace)
+ .where(Workspace.uuid == workspace_projected)
+ .values(name='tenant-must-not-update-cloud')
+ )
+ membership_update = await tenant.session.execute(
+ sa.update(WorkspaceMembership)
+ .where(WorkspaceMembership.workspace_uuid == workspace_projected)
+ .values(role='admin')
+ )
+ execution_update = await tenant.session.execute(
+ sa.update(WorkspaceExecutionState)
+ .where(WorkspaceExecutionState.workspace_uuid == workspace_projected)
+ .values(write_fenced=True)
+ )
+ assert workspace_update.rowcount == 0
+ assert membership_update.rowcount == 1
+ assert execution_update.rowcount == 0
+
+ async with cloud_manager.tenant_uow(workspace_local) as tenant:
+ local_update = await tenant.session.execute(
+ sa.update(Workspace).where(Workspace.uuid == workspace_local).values(name='tenant-can-update-local')
+ )
+ assert local_update.rowcount == 1
+
+ async with cloud_manager.directory_projection_uow(instance_uuid) as directory:
+ local_update = await directory.session.execute(
+ sa.update(Workspace)
+ .where(Workspace.uuid == workspace_local)
+ .values(name='directory-must-not-update-local')
+ )
+ assert local_update.rowcount == 0
+
+ projection_application = SimpleNamespace(
+ persistence_mgr=cloud_manager,
+ logger=logging.getLogger('postgres-directory-projection-concurrency'),
+ )
+ first_projection = DirectoryProjectionService(
+ projection_application,
+ _NoopDirectoryProjectionProvider(),
+ instance_uuid,
+ )
+ second_projection = DirectoryProjectionService(
+ projection_application,
+ _NoopDirectoryProjectionProvider(),
+ instance_uuid,
+ )
+ concurrent_event = DirectoryEvent(
+ cursor=2,
+ uuid='70000000-0000-0000-0000-000000000007',
+ aggregate_uuid=workspace_projected,
+ event_type='entitlement.changed',
+ revision=2,
+ payload={
+ 'workspace_uuid': workspace_projected,
+ 'entitlement_revision': 2,
+ },
+ created_at=datetime.datetime.now(datetime.UTC),
+ )
+ concurrent_batch = DirectoryEventBatch(
+ instance_uuid=instance_uuid,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[concurrent_event],
+ )
+ await asyncio.gather(
+ first_projection.apply_event_batch(concurrent_batch),
+ second_projection.apply_event_batch(concurrent_batch),
+ )
+ async with cloud_manager.directory_projection_uow(instance_uuid) as directory:
+ projection_state = await directory.session.get(DirectoryProjectionState, instance_uuid)
+ concurrent_receipts = (
+ await directory.session.scalars(
+ sa.select(DirectoryProjectionInbox).where(
+ DirectoryProjectionInbox.event_uuid == concurrent_event.uuid
+ )
+ )
+ ).all()
+ assert projection_state.cursor == 2
+ assert len(concurrent_receipts) == 1
+ assert concurrent_receipts[0].applied_at is not None
+
+ async with cloud_manager.tenant_uow(workspace_a) as tenant:
+ assert (await tenant.session.execute(sa.select(DirectoryProjectionState))).all() == []
+ assert (await tenant.session.execute(sa.select(DirectoryProjectionInbox))).all() == []
+
+ async with cloud_manager.instance_discovery_uow(instance_uuid) as discovery:
+ assert (await discovery.session.execute(sa.select(DirectoryProjectionState))).all() == []
+ update_result = await discovery.session.execute(sa.update(DirectoryProjectionState).values(cursor=2))
+ assert update_result.rowcount == 0
+
+ with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
+ async with cloud_manager.tenant_uow(workspace_a):
+ duplicate_statement = sa.insert(WorkspaceMetadata).values(
+ workspace_uuid=workspace_a,
+ key='rollback-only-unique',
+ value='must-not-commit',
+ )
+ await cloud_manager.execute_async(duplicate_statement)
+ after_commit_gate = cloud_manager.create_after_commit_gate()
+ assert after_commit_gate is not None
+ try:
+ await cloud_manager.execute_async(duplicate_statement)
+ except IntegrityError:
+ pass
+
+ assert after_commit_gate.cancelled()
+ async with cloud_manager.tenant_uow(workspace_a):
+ assert (
+ await cloud_manager.execute_async(
+ sa.select(sa.func.count())
+ .select_from(WorkspaceMetadata)
+ .where(WorkspaceMetadata.key == 'rollback-only-unique')
+ )
+ ).scalar_one() == 0
+
+ superuser_manager = PersistenceManager(
+ _application_for_postgres_url(postgres_url, 'postgres-superuser-runtime-test'),
+ mode=PersistenceMode.CLOUD_RUNTIME,
+ )
+ managers.append(superuser_manager)
+ with pytest.raises(RuntimeError, match='must not be a superuser'):
+ await superuser_manager.initialize()
+
+ await create_role(bypass_role, bypass_rls=True)
+ bypass_manager = PersistenceManager(
+ _application_for_postgres_url(role_url(bypass_role), 'postgres-bypass-runtime-test'),
+ mode=PersistenceMode.CLOUD_RUNTIME,
+ )
+ managers.append(bypass_manager)
+ with pytest.raises(RuntimeError, match='must not have BYPASSRLS'):
+ await bypass_manager.initialize()
+
+ await create_role(owner_role)
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER TABLE workspace_metadata OWNER TO {quote(owner_role)}'))
+ owner_changed = True
+ owner_manager = PersistenceManager(
+ _application_for_postgres_url(role_url(owner_role), 'postgres-owner-runtime-test'),
+ mode=PersistenceMode.CLOUD_RUNTIME,
+ )
+ managers.append(owner_manager)
+ with pytest.raises(RuntimeError, match='must not own tenant tables'):
+ await owner_manager.initialize()
+ finally:
+ if runtime_engine is not None:
+ await runtime_engine.dispose()
+ for manager in reversed(managers):
+ await _dispose_manager(manager)
+
+ async with postgres_engine.connect() as conn:
+ if owner_changed:
+ await conn.execute(text(f'ALTER TABLE workspace_metadata OWNER TO {quote(admin_user)}'))
+ for role_name in reversed(created_roles):
+ await conn.execute(text(f'DROP OWNED BY {quote(role_name)}'))
+ await conn.execute(text(f'DROP ROLE {quote(role_name)}'))
+
+ @pytest.mark.asyncio
+ async def test_cloud_discovery_http_and_transaction_contract(
+ self,
+ postgres_url,
+ postgres_engine,
+ clean_tables,
+ clean_alembic_version,
+ monkeypatch,
+ ):
+ """Exercise the complete request/discovery path as a non-owner role."""
+
+ instance_uuid = 'cloud-request-rls-test'
+ other_instance_uuid = 'other-cloud-instance'
+ workspace_a = '31000000-0000-0000-0000-000000000001'
+ workspace_b = '32000000-0000-0000-0000-000000000002'
+ workspace_other = '33000000-0000-0000-0000-000000000003'
+ workspace_fenced = '34000000-0000-0000-0000-000000000004'
+ account_a_uuid = '41000000-0000-0000-0000-000000000001'
+ shared_account_uuid = '42000000-0000-0000-0000-000000000002'
+ active_secret = 'lbk_active-request-key'
+ revoked_secret = 'lbk_revoked-request-key'
+ expired_secret = 'lbk_expired-request-key'
+ role_name = f'lb_request_{uuid.uuid4().hex[:12]}'
+ role_password = f'Lb{uuid.uuid4().hex}'
+ quote = postgres_engine.dialect.identifier_preparer.quote
+ managers: list[PersistenceManager] = []
+ role_created = False
+
+ _restore_postgres_manager_registry(monkeypatch)
+ monkeypatch.setattr(constants, 'instance_id', instance_uuid)
+ release_manager = PersistenceManager(
+ _application_for_postgres_url(postgres_url, 'postgres-request-release-test'),
+ mode=PersistenceMode.RELEASE_MIGRATION,
+ )
+ managers.append(release_manager)
+
+ def role_url() -> str:
+ return (
+ sa.engine.make_url(postgres_url)
+ .set(username=role_name, password=role_password)
+ .render_as_string(hide_password=False)
+ )
+
+ async def seed_workspace(
+ workspace_uuid: str,
+ *,
+ target_instance: str,
+ state: str = 'active',
+ write_fenced: bool = False,
+ ) -> None:
+ async with release_manager.tenant_uow(workspace_uuid) as uow:
+ uow.session.add(
+ Workspace(
+ uuid=workspace_uuid,
+ instance_uuid=target_instance,
+ name=workspace_uuid[-4:],
+ slug=f'workspace-{workspace_uuid[-4:]}',
+ type='team',
+ status='active',
+ source='cloud_projection',
+ projection_revision=1,
+ )
+ )
+ await uow.session.flush()
+ uow.session.add(
+ WorkspaceExecutionState(
+ workspace_uuid=workspace_uuid,
+ instance_uuid=target_instance,
+ active_generation=1,
+ state=state,
+ write_fenced=write_fenced,
+ source='cloud',
+ desired_state_revision=1,
+ )
+ )
+ uow.session.add(
+ WorkspaceMetadata(
+ workspace_uuid=workspace_uuid,
+ key='tenant-marker',
+ value=workspace_uuid,
+ )
+ )
+
+ try:
+ await release_manager.initialize()
+ async with release_manager.get_db_engine().begin() as conn:
+ await conn.execute(
+ sa.insert(User),
+ [
+ {
+ 'uuid': account_a_uuid,
+ 'user': 'account-a@example.com',
+ 'normalized_email': 'account-a@example.com',
+ 'password': 'closed-directory',
+ 'account_type': 'local',
+ 'status': 'active',
+ 'source': 'cloud_projection',
+ 'projection_revision': 1,
+ },
+ {
+ 'uuid': shared_account_uuid,
+ 'user': 'shared@example.com',
+ 'normalized_email': 'shared@example.com',
+ 'password': 'closed-directory',
+ 'account_type': 'local',
+ 'status': 'active',
+ 'source': 'cloud_projection',
+ 'projection_revision': 1,
+ },
+ ],
+ )
+
+ await seed_workspace(workspace_a, target_instance=instance_uuid)
+ await seed_workspace(workspace_b, target_instance=instance_uuid)
+ await seed_workspace(workspace_other, target_instance=other_instance_uuid)
+ await seed_workspace(
+ workspace_fenced,
+ target_instance=instance_uuid,
+ write_fenced=True,
+ )
+
+ async with release_manager.tenant_uow(workspace_a) as uow:
+ uow.session.add_all(
+ [
+ WorkspaceMembership(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_a,
+ account_uuid=account_a_uuid,
+ role='owner',
+ status='active',
+ projection_revision=1,
+ ),
+ WorkspaceMembership(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_a,
+ account_uuid=shared_account_uuid,
+ role='viewer',
+ status='active',
+ projection_revision=1,
+ ),
+ ApiKey(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_a,
+ name='active-key',
+ key_hash=hashlib.sha256(active_secret.encode()).hexdigest(),
+ scopes=[Permission.WORKSPACE_VIEW.value],
+ status='active',
+ ),
+ ]
+ )
+ async with release_manager.tenant_uow(workspace_b) as uow:
+ uow.session.add_all(
+ [
+ WorkspaceMembership(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_b,
+ account_uuid=shared_account_uuid,
+ role='viewer',
+ status='active',
+ projection_revision=1,
+ ),
+ ApiKey(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_b,
+ name='revoked-key',
+ key_hash=hashlib.sha256(revoked_secret.encode()).hexdigest(),
+ scopes=[Permission.WORKSPACE_VIEW.value],
+ status='revoked',
+ ),
+ ApiKey(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_b,
+ name='expired-key',
+ key_hash=hashlib.sha256(expired_secret.encode()).hexdigest(),
+ scopes=[Permission.WORKSPACE_VIEW.value],
+ status='active',
+ expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+ - datetime.timedelta(minutes=1),
+ ),
+ ]
+ )
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f"CREATE ROLE {quote(role_name)} LOGIN PASSWORD '{role_password}'"))
+ role_created = True
+ await conn.execute(
+ text(
+ f'GRANT CONNECT ON DATABASE {quote(sa.engine.make_url(postgres_url).database)} '
+ f'TO {quote(role_name)}'
+ )
+ )
+ await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(role_name)}'))
+ await _grant_runtime_role_business_objects(conn, role_name, quote)
+
+ runtime_application = _application_for_postgres_url(role_url(), 'postgres-request-runtime-test')
+ runtime_application.instance_config.data.update(
+ {
+ 'system': {
+ 'jwt': {'secret': 'postgres-request-jwt', 'expire': 3600},
+ },
+ 'api': {'global_api_key': ''},
+ }
+ )
+ runtime_application.logger = logging.getLogger('postgres-request-runtime-test')
+ cloud_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
+ managers.append(cloud_manager)
+ await cloud_manager.initialize()
+
+ runtime_application.persistence_mgr = cloud_manager
+ cloud_manager.ap = runtime_application
+ runtime_application.workspace_service = WorkspaceService(
+ runtime_application,
+ policy=CloudWorkspacePolicy(),
+ instance_uuid=instance_uuid,
+ )
+ runtime_application.workspace_collaboration_service = WorkspaceCollaborationService(
+ runtime_application,
+ runtime_application.workspace_service,
+ policy=CloudWorkspacePolicy(),
+ )
+ runtime_application.user_service = UserService(runtime_application)
+ runtime_application.apikey_service = ApiKeyService(runtime_application)
+ runtime_application.monitoring_service = MonitoringService(runtime_application)
+
+ bindings = await runtime_application.workspace_service.list_active_execution_bindings()
+ assert {binding.workspace_uuid for binding in bindings} == {workspace_a, workspace_b}
+
+ # No scope is an application error before SQL reaches PostgreSQL.
+ with pytest.raises(RuntimeError, match='explicit Workspace or discovery'):
+ await cloud_manager.execute_async(sa.select(WorkspaceMetadata))
+
+ # Discovery exposes only its index rows and cannot write.
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with cloud_manager.account_discovery_uow(shared_account_uuid) as discovery:
+ assert set(
+ (
+ await discovery.session.scalars(
+ sa.select(WorkspaceMembership.workspace_uuid).order_by(
+ WorkspaceMembership.workspace_uuid
+ )
+ )
+ ).all()
+ ) == {workspace_a, workspace_b}
+ with pytest.raises(sa.exc.DBAPIError):
+ await discovery.session.execute(
+ sa.insert(WorkspaceMembership).values(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=workspace_a,
+ account_uuid=shared_account_uuid,
+ role='viewer',
+ status='active',
+ projection_revision=1,
+ )
+ )
+
+ async with cloud_manager.api_key_discovery_uow(
+ hashlib.sha256(active_secret.encode()).hexdigest()
+ ) as discovery:
+ assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) == workspace_a
+ update_result = await discovery.session.execute(
+ sa.update(ApiKey)
+ .where(ApiKey.key_hash == hashlib.sha256(active_secret.encode()).hexdigest())
+ .values(name='discovery-must-not-write')
+ )
+ assert update_result.rowcount == 0
+ async with cloud_manager.api_key_discovery_uow(
+ hashlib.sha256(revoked_secret.encode()).hexdigest()
+ ) as discovery:
+ assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) is None
+ async with cloud_manager.api_key_discovery_uow(
+ hashlib.sha256(expired_secret.encode()).hexdigest()
+ ) as discovery:
+ assert await discovery.session.scalar(sa.select(ApiKey.workspace_uuid)) is None
+
+ async with cloud_manager.instance_discovery_uow(instance_uuid) as discovery:
+ assert set(
+ (await discovery.session.scalars(sa.select(WorkspaceExecutionState.workspace_uuid))).all()
+ ) == {workspace_a, workspace_b}
+ update_result = await discovery.session.execute(
+ sa.update(WorkspaceExecutionState)
+ .where(WorkspaceExecutionState.workspace_uuid == workspace_a)
+ .values(write_fenced=True)
+ )
+ assert update_result.rowcount == 0
+ # Instance discovery deliberately cannot see business rows.
+ assert (await discovery.session.execute(sa.select(WorkspaceMetadata))).all() == []
+
+ platform_manager = PlatformManager(runtime_application)
+ platform_manager._load_workspace_bots = AsyncMock()
+ await platform_manager.load_bots_from_db()
+ assert {call.args[0] for call in platform_manager._load_workspace_bots.await_args_list} == {
+ workspace_a,
+ workspace_b,
+ }
+
+ # Every startup cache loader traverses the instance index first,
+ # then reads business resources in one tenant transaction at a time.
+ await ModelManager(runtime_application).load_models_from_db()
+ await PipelineManager(runtime_application).load_pipelines_from_db()
+ await MCPLoader(runtime_application).load_mcp_servers_from_db()
+ await RAGManager(runtime_application).load_knowledge_bases_from_db()
+
+ # An omitted Workspace predicate remains isolated by RLS.
+ async with cloud_manager.tenant_uow(workspace_a):
+ values = (await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
+ assert values == [workspace_a]
+
+ async def read_tenant_repeatedly(workspace_uuid: str) -> list[str]:
+ observed: list[str] = []
+ for _ in range(5):
+ async with cloud_manager.tenant_uow(workspace_uuid):
+ observed.extend(
+ (await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
+ )
+ await asyncio.sleep(0)
+ return observed
+
+ observed_a, observed_b = await asyncio.gather(
+ read_tenant_repeatedly(workspace_a),
+ read_tenant_repeatedly(workspace_b),
+ )
+ assert observed_a == [workspace_a] * 5
+ assert observed_b == [workspace_b] * 5
+
+ accesses = await runtime_application.workspace_collaboration_service.list_account_workspaces(
+ shared_account_uuid
+ )
+ assert {access.workspace.uuid for access in accesses} == {workspace_a, workspace_b}
+ assert await runtime_application.apikey_service.authenticate_api_key(revoked_secret) is None
+ assert await runtime_application.apikey_service.authenticate_api_key(expired_secret) is None
+ active_identity = await runtime_application.apikey_service.authenticate_api_key(active_secret)
+ assert active_identity is not None
+ assert active_identity.workspace_uuid == workspace_a
+
+ async def record_feedback(feedback_type: int) -> str | None:
+ async with cloud_manager.tenant_scope(workspace_a):
+ return await runtime_application.monitoring_service.record_feedback(
+ ExecutionContext(
+ instance_uuid=instance_uuid,
+ workspace_uuid=workspace_a,
+ placement_generation=1,
+ ),
+ feedback_id='concurrent-feedback',
+ feedback_type=feedback_type,
+ )
+
+ feedback_ids = await asyncio.gather(record_feedback(1), record_feedback(2))
+ assert feedback_ids[0] == feedback_ids[1]
+ async with cloud_manager.tenant_uow(workspace_a) as uow:
+ feedback_rows = (
+ (
+ await uow.execute(
+ sa.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == 'concurrent-feedback')
+ )
+ )
+ .scalars()
+ .all()
+ )
+ assert len(feedback_rows) == 1
+ assert feedback_rows[0].feedback_type in {1, 2}
+ await uow.execute(
+ sa.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == 'concurrent-feedback')
+ )
+
+ class TenantRuntimeRouter(http_group.RouterGroup):
+ name = 'postgres-tenant-runtime'
+ path = '/tenant-runtime'
+
+ async def initialize(self) -> None:
+ @self.route('/account', permission=Permission.WORKSPACE_VIEW)
+ async def account_route(request_context: RequestContext):
+ assert self.ap.persistence_mgr.current_session() is None
+ if self.quart_app.config.get('FORCE_HANDLER_FAILURE'):
+ async with self.ap.persistence_mgr.tenant_uow(request_context.workspace_uuid):
+ await self.ap.persistence_mgr.execute_async(
+ sa.insert(WorkspaceMetadata).values(
+ workspace_uuid=request_context.workspace_uuid,
+ key='rolled-back-handler',
+ value='must-not-commit',
+ )
+ )
+ raise RuntimeError('forced handler failure')
+ values = (
+ (await self.ap.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value)))
+ .scalars()
+ .all()
+ )
+ assert self.ap.persistence_mgr.current_session() is None
+ return self.success(data={'workspace_uuid': request_context.workspace_uuid, 'values': values})
+
+ @self.route('/key', auth_type=http_group.AuthType.API_KEY)
+ async def key_route(request_context: RequestContext):
+ values = (
+ (await self.ap.persistence_mgr.execute_async(sa.select(WorkspaceMetadata.value)))
+ .scalars()
+ .all()
+ )
+ return self.success(data={'workspace_uuid': request_context.workspace_uuid, 'values': values})
+
+ @self.route('/bootstrap', auth_type=http_group.AuthType.ACCOUNT_TOKEN)
+ async def bootstrap_route(user_email: str):
+ account = await self.ap.user_service.get_user_by_email(user_email)
+ accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
+ return self.success(data=sorted(access.workspace.uuid for access in accesses))
+
+ quart_app = Quart(__name__)
+ await TenantRuntimeRouter(runtime_application, quart_app).initialize()
+
+ webhook_bot_uuid = str(uuid.uuid4())
+
+ class TenantAwareWebhookAdapter:
+ async def handle_unified_webhook(self, **_kwargs):
+ assert cloud_manager.current_session() is None
+ await asyncio.sleep(0)
+ assert cloud_manager.current_session() is None
+ values = (
+ (
+ await cloud_manager.execute_async(
+ sa.select(WorkspaceMetadata.value).where(WorkspaceMetadata.key == 'tenant-marker')
+ )
+ )
+ .scalars()
+ .all()
+ )
+ assert cloud_manager.current_session() is None
+ return {'values': values}
+
+ runtime_application.platform_mgr = SimpleNamespace(
+ resolve_public_bot=AsyncMock(
+ return_value=SimpleNamespace(
+ workspace_uuid=workspace_a,
+ placement_generation=1,
+ enable=True,
+ adapter=TenantAwareWebhookAdapter(),
+ )
+ )
+ )
+ await WebhookRouterGroup(runtime_application, quart_app).initialize()
+ await SystemRouterGroup(runtime_application, quart_app).initialize()
+ client = quart_app.test_client()
+ account_a = await runtime_application.user_service.get_user_by_uuid(account_a_uuid)
+ shared_account = await runtime_application.user_service.get_user_by_uuid(shared_account_uuid)
+ assert account_a is not None and shared_account is not None
+ account_token = await runtime_application.user_service.generate_jwt_token(account_a)
+ shared_token = await runtime_application.user_service.generate_jwt_token(shared_account)
+
+ async with cloud_manager.tenant_uow(workspace_a):
+ await cloud_manager.execute_async(
+ sa.insert(WorkspaceMetadata).values(
+ workspace_uuid=workspace_a,
+ key='wizard_status',
+ value='completed',
+ )
+ )
+ response = await client.get(
+ '/api/v1/system/info',
+ headers={
+ 'Authorization': f'Bearer {account_token}',
+ 'X-Workspace-Id': workspace_a,
+ },
+ )
+ assert response.status_code == 200
+ assert (await response.get_json())['data']['wizard_status'] == 'completed'
+ async with cloud_manager.tenant_uow(workspace_a):
+ await cloud_manager.execute_async(
+ sa.delete(WorkspaceMetadata).where(
+ WorkspaceMetadata.workspace_uuid == workspace_a,
+ WorkspaceMetadata.key == 'wizard_status',
+ )
+ )
+
+ response = await client.post(f'/bots/{webhook_bot_uuid}')
+ assert response.status_code == 200
+ assert await response.get_json() == {'values': [workspace_a]}
+
+ response = await client.get(
+ '/tenant-runtime/account',
+ headers={
+ 'Authorization': f'Bearer {account_token}',
+ 'X-Workspace-Id': workspace_a,
+ },
+ )
+ assert response.status_code == 200
+ assert (await response.get_json())['data'] == {
+ 'workspace_uuid': workspace_a,
+ 'values': [workspace_a],
+ }
+
+ response = await client.get(
+ '/tenant-runtime/account',
+ headers={
+ 'Authorization': f'Bearer {account_token}',
+ 'X-Workspace-Id': workspace_b,
+ },
+ )
+ assert response.status_code == 404
+ response = await client.get(
+ '/tenant-runtime/account',
+ headers={'Authorization': f'Bearer {shared_token}'},
+ )
+ assert response.status_code == 404
+
+ response = await client.get(
+ '/tenant-runtime/bootstrap',
+ headers={'Authorization': f'Bearer {shared_token}'},
+ )
+ assert response.status_code == 200
+ assert (await response.get_json())['data'] == [workspace_a, workspace_b]
+
+ response = await client.get(
+ '/tenant-runtime/key',
+ headers={'X-API-Key': active_secret, 'X-Workspace-Id': workspace_b},
+ )
+ assert response.status_code == 200
+ assert (await response.get_json())['data'] == {
+ 'workspace_uuid': workspace_a,
+ 'values': [workspace_a],
+ }
+
+ # The parallel MCP ASGI entrypoint authenticates the same key and
+ # retains only a trusted Workspace scope for the full tool request.
+ # Each DB call gets its own short RLS transaction.
+ mcp_observation: dict[str, typing.Any] = {}
+
+ async def fake_mcp_asgi(scope, receive, send):
+ del scope, receive
+ context = get_mcp_request_context()
+ assert cloud_manager.current_session() is None
+ values = (await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
+ assert cloud_manager.current_session() is None
+ await asyncio.sleep(0)
+ assert cloud_manager.current_session() is None
+ repeated_values = (
+ (await cloud_manager.execute_async(sa.select(WorkspaceMetadata.value))).scalars().all()
+ )
+ assert repeated_values == values
+ assert cloud_manager.current_session() is None
+ mcp_observation.update(
+ workspace_uuid=context.workspace_uuid,
+ values=values,
+ )
+ await send({'type': 'http.response.start', 'status': 200, 'headers': []})
+ await send({'type': 'http.response.body', 'body': b'{}'})
+
+ async def unused_quart_asgi(scope, receive, send): # pragma: no cover - routing assertion
+ del scope, receive, send
+ raise AssertionError('MCP request was routed to Quart')
+
+ mount = MCPMount.__new__(MCPMount)
+ mount.ap = runtime_application
+ mount._mcp_asgi = fake_mcp_asgi
+ sent_messages: list[dict[str, typing.Any]] = []
+
+ async def receive():
+ return {'type': 'http.request', 'body': b'', 'more_body': False}
+
+ async def send(message):
+ sent_messages.append(message)
+
+ await mount.wrap(unused_quart_asgi)(
+ {
+ 'type': 'http',
+ 'path': '/mcp',
+ 'headers': [
+ (b'x-api-key', active_secret.encode()),
+ (b'x-workspace-id', workspace_b.encode()),
+ ],
+ },
+ receive,
+ send,
+ )
+ assert sent_messages[0]['status'] == 200
+ assert mcp_observation == {'workspace_uuid': workspace_a, 'values': [workspace_a]}
+
+ original_api_key_discovery = cloud_manager.api_key_discovery_uow
+
+ @contextlib.asynccontextmanager
+ async def revoke_after_discovery(key_hash: str):
+ async with original_api_key_discovery(key_hash) as discovery:
+ yield discovery
+ async with cloud_manager.tenant_uow(workspace_a):
+ await cloud_manager.execute_async(
+ sa.update(ApiKey).where(ApiKey.key_hash == key_hash).values(status='revoked')
+ )
+
+ monkeypatch.setattr(cloud_manager, 'api_key_discovery_uow', revoke_after_discovery)
+ assert await runtime_application.apikey_service.authenticate_api_key(active_secret) is None
+ monkeypatch.setattr(cloud_manager, 'api_key_discovery_uow', original_api_key_discovery)
+
+ quart_app.config['FORCE_HANDLER_FAILURE'] = True
+ response = await client.get(
+ '/tenant-runtime/account',
+ headers={
+ 'Authorization': f'Bearer {account_token}',
+ 'X-Workspace-Id': workspace_a,
+ },
+ )
+ assert response.status_code == 500
+ quart_app.config['FORCE_HANDLER_FAILURE'] = False
+ async with cloud_manager.tenant_uow(workspace_a):
+ assert (
+ await cloud_manager.execute_async(
+ sa.select(sa.func.count())
+ .select_from(WorkspaceMetadata)
+ .where(WorkspaceMetadata.key == 'rolled-back-handler')
+ )
+ ).scalar_one() == 0
+
+ # Transaction-local settings are gone when pooled connections are reused.
+ async with cloud_manager.get_db_engine().connect() as conn:
+ assert await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) in (
+ None,
+ '',
+ )
+ assert await conn.scalar(text('SELECT COUNT(*) FROM workspace_metadata')) == 0
+
+ # Runtime validation rejects both extra permissive policies and a
+ # modified expression even if the expected policy name remains.
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text('CREATE POLICY injected_policy ON bots FOR SELECT USING (true)'))
+ with pytest.raises(RuntimeError, match='policy set does not match'):
+ await cloud_manager._validate_postgres_tenant_schema(validate_runtime_role=True)
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text('DROP POLICY injected_policy ON bots'))
+ await conn.execute(text('DROP POLICY langbot_workspace_isolation ON workspace_metadata'))
+ await conn.execute(
+ text(
+ 'CREATE POLICY langbot_workspace_isolation ON workspace_metadata '
+ 'FOR ALL TO PUBLIC USING (true) WITH CHECK (true)'
+ )
+ )
+ with pytest.raises(RuntimeError, match='policy definitions are invalid'):
+ await cloud_manager._validate_postgres_tenant_schema(validate_runtime_role=True)
+ finally:
+ for manager in reversed(managers):
+ await _dispose_manager(manager)
+ if role_created:
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'DROP OWNED BY {quote(role_name)}'))
+ await conn.execute(text(f'DROP ROLE IF EXISTS {quote(role_name)}'))
diff --git a/tests/integration/persistence/test_pgvector_postgres.py b/tests/integration/persistence/test_pgvector_postgres.py
new file mode 100644
index 000000000..7764fb7e0
--- /dev/null
+++ b/tests/integration/persistence/test_pgvector_postgres.py
@@ -0,0 +1,553 @@
+"""Real PostgreSQL/pgvector tenant isolation and CRUD verification."""
+
+from __future__ import annotations
+
+import logging
+import os
+import uuid
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.rag import KnowledgeBase
+from langbot.pkg.entity.persistence.workspace import Workspace
+from langbot.pkg.persistence.alembic_runner import get_alembic_current, run_alembic_stamp, run_alembic_upgrade
+from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
+from langbot.pkg.utils import constants
+from langbot.pkg.vector.vdbs.pgvector_db import PgVectorDatabase, PgVectorEntry, PgVectorScope
+
+
+pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
+
+
+def _application(postgres_url: str, logger_name: str) -> SimpleNamespace:
+ url = sa.engine.make_url(postgres_url)
+ return SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'use': 'postgresql',
+ 'postgresql': {
+ 'host': url.host,
+ 'port': url.port,
+ 'user': url.username,
+ 'password': url.password,
+ 'database': url.database,
+ },
+ }
+ }
+ ),
+ logger=logging.getLogger(logger_name),
+ )
+
+
+def _restore_postgres_registry(monkeypatch) -> None:
+ from langbot.pkg.persistence import mgr as persistence_mgr_module
+ from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
+
+ monkeypatch.setattr(
+ persistence_mgr_module.database,
+ 'preregistered_managers',
+ [PostgreSQLDatabaseManager],
+ )
+
+
+@pytest.fixture
+def postgres_url() -> str:
+ url = os.environ.get('TEST_POSTGRES_URL')
+ if not url:
+ pytest.skip('TEST_POSTGRES_URL not set')
+ return url
+
+
+@pytest.fixture
+async def postgres_engine(postgres_url: str):
+ engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT')
+ yield engine
+ await engine.dispose()
+
+
+@pytest.fixture
+async def clean_database(postgres_engine: AsyncEngine):
+ async def clean() -> None:
+ async with postgres_engine.begin() as conn:
+ await conn.execute(text('DROP TABLE IF EXISTS langbot_vectors_legacy_0013 CASCADE'))
+ await conn.execute(text('DROP TABLE IF EXISTS langbot_vectors CASCADE'))
+ await conn.run_sync(Base.metadata.drop_all)
+ await conn.execute(text('DROP TABLE IF EXISTS alembic_version'))
+
+ await clean()
+ yield
+ await clean()
+
+
+async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner(
+ postgres_url: str,
+ postgres_engine: AsyncEngine,
+ clean_database,
+) -> None:
+ workspace_uuid = '30000000-0000-0000-0000-000000000303'
+ knowledge_base_uuid = 'legacy-knowledge-base'
+ migrator_role = f'lb_vector_migrator_{uuid.uuid4().hex[:12]}'
+ migrator_password = f'Lb{uuid.uuid4().hex}'
+ quote = postgres_engine.dialect.identifier_preparer.quote
+ database_name = sa.engine.make_url(postgres_url).database
+ migrator_url = (
+ sa.engine.make_url(postgres_url)
+ .set(username=migrator_role, password=migrator_password)
+ .render_as_string(hide_password=False)
+ )
+ migrator_engine: AsyncEngine | None = None
+ role_created = False
+ admin_role: str | None = None
+ source_tables = ('knowledge_bases', 'knowledge_base_files', 'knowledge_base_chunks')
+
+ async def read_rls_states(conn, table_names: tuple[str, ...]) -> dict[str, tuple[bool, bool]]:
+ rows = (
+ (
+ await conn.execute(
+ text(
+ """
+ SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
+ FROM pg_class AS c
+ JOIN pg_namespace AS n ON n.oid = c.relnamespace
+ WHERE n.nspname = current_schema()
+ AND c.relname IN :table_names
+ """
+ ).bindparams(sa.bindparam('table_names', expanding=True)),
+ {'table_names': table_names},
+ )
+ )
+ .mappings()
+ .all()
+ )
+ return {str(row['relname']): (bool(row['relrowsecurity']), bool(row['relforcerowsecurity'])) for row in rows}
+
+ try:
+ async with postgres_engine.begin() as conn:
+ admin_role = await conn.scalar(text('SELECT current_user'))
+ await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
+ await conn.run_sync(Base.metadata.create_all)
+ await conn.execute(
+ text(
+ 'ALTER TABLE knowledge_bases DROP CONSTRAINT IF EXISTS '
+ 'ck_knowledge_bases_embedding_dimension_positive'
+ )
+ )
+ await conn.execute(text('ALTER TABLE knowledge_bases DROP COLUMN IF EXISTS embedding_dimension'))
+
+ await run_alembic_stamp(postgres_engine, '0010_scope_resources')
+ await run_alembic_upgrade(postgres_engine, '0012_plugin_identity')
+ assert await get_alembic_current(postgres_engine) == '0012_plugin_identity'
+
+ embedding_a = '[' + ','.join(['0.125'] * 384) + ']'
+ embedding_b = '[' + ','.join(['0.25'] * 384) + ']'
+ async with postgres_engine.begin() as conn:
+ await conn.execute(
+ text(
+ """
+ INSERT INTO workspaces
+ (uuid, instance_uuid, name, slug, type, status, source, projection_revision)
+ VALUES
+ (:uuid, 'legacy-vector-instance', 'legacy', 'legacy-vector',
+ 'team', 'active', 'cloud_projection', 0)
+ """
+ ),
+ {'uuid': workspace_uuid},
+ )
+ await conn.execute(
+ text(
+ """
+ INSERT INTO knowledge_bases
+ (uuid, workspace_uuid, name, collection_id, legacy_vector_collection)
+ VALUES
+ (:uuid, :workspace_uuid, 'legacy', 'legacy-collection', true)
+ """
+ ),
+ {'uuid': knowledge_base_uuid, 'workspace_uuid': workspace_uuid},
+ )
+ await conn.execute(
+ text(
+ """
+ INSERT INTO knowledge_base_files
+ (uuid, workspace_uuid, kb_id, file_name, extension, status)
+ VALUES
+ ('legacy-file', :workspace_uuid, :kb_uuid, 'legacy.txt', 'txt', 'completed')
+ """
+ ),
+ {'workspace_uuid': workspace_uuid, 'kb_uuid': knowledge_base_uuid},
+ )
+ await conn.execute(
+ text(
+ """
+ INSERT INTO knowledge_base_chunks (uuid, workspace_uuid, file_id, text)
+ VALUES ('legacy-chunk', :workspace_uuid, 'legacy-file', 'chunk text')
+ """
+ ),
+ {'workspace_uuid': workspace_uuid},
+ )
+ await conn.execute(
+ text(
+ """
+ CREATE TABLE langbot_vectors (
+ id VARCHAR(255) PRIMARY KEY,
+ collection VARCHAR(255),
+ embedding vector NOT NULL,
+ text TEXT,
+ file_id VARCHAR(255),
+ chunk_uuid VARCHAR(255)
+ )
+ """
+ )
+ )
+ await conn.execute(
+ text(
+ """
+ INSERT INTO langbot_vectors
+ (id, collection, embedding, text, file_id, chunk_uuid)
+ VALUES
+ ('legacy-by-collection', 'legacy-collection', CAST(:embedding_a AS vector),
+ 'collection row', NULL, NULL),
+ ('legacy-by-chunk', 'unmatched-collection', CAST(:embedding_b AS vector),
+ 'chunk row', NULL, 'legacy-chunk')
+ """
+ ),
+ {'embedding_a': embedding_a, 'embedding_b': embedding_b},
+ )
+
+ # Exercise exact restoration rather than assuming all source tables
+ # arrived with identical flags.
+ await conn.execute(text('ALTER TABLE knowledge_base_files NO FORCE ROW LEVEL SECURITY'))
+ await conn.execute(text('ALTER TABLE knowledge_base_chunks NO FORCE ROW LEVEL SECURITY'))
+ await conn.execute(text('ALTER TABLE knowledge_base_chunks DISABLE ROW LEVEL SECURITY'))
+ expected_source_rls = await read_rls_states(conn, source_tables)
+ assert expected_source_rls == {
+ 'knowledge_bases': (True, True),
+ 'knowledge_base_files': (True, False),
+ 'knowledge_base_chunks': (False, False),
+ }
+
+ await conn.execute(
+ text(f"CREATE ROLE {quote(migrator_role)} LOGIN PASSWORD '{migrator_password}' NOSUPERUSER NOBYPASSRLS")
+ )
+ role_created = True
+ await conn.execute(text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(migrator_role)}'))
+ await conn.execute(text(f'GRANT USAGE, CREATE ON SCHEMA public TO {quote(migrator_role)}'))
+ await conn.execute(text(f'GRANT SELECT, UPDATE ON alembic_version TO {quote(migrator_role)}'))
+ for table_name in (*source_tables, 'langbot_vectors'):
+ await conn.execute(text(f'ALTER TABLE {quote(table_name)} OWNER TO {quote(migrator_role)}'))
+
+ role = (
+ await conn.execute(
+ text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = :role'),
+ {'role': migrator_role},
+ )
+ ).one()
+ assert role == (False, False)
+ owned_tables = set(
+ (
+ await conn.execute(
+ text(
+ """
+ SELECT c.relname
+ FROM pg_class AS c
+ JOIN pg_namespace AS n ON n.oid = c.relnamespace
+ WHERE n.nspname = current_schema()
+ AND c.relname IN :table_names
+ AND pg_get_userbyid(c.relowner) = :role
+ """
+ ).bindparams(sa.bindparam('table_names', expanding=True)),
+ {'table_names': (*source_tables, 'langbot_vectors'), 'role': migrator_role},
+ )
+ ).scalars()
+ )
+ assert owned_tables == {*source_tables, 'langbot_vectors'}
+
+ migrator_engine = create_async_engine(migrator_url)
+ await run_alembic_upgrade(migrator_engine, '0013_tenant_pgvector')
+ assert await get_alembic_current(migrator_engine) == '0013_tenant_pgvector'
+
+ async with postgres_engine.connect() as conn:
+ migrated_rows = (
+ (
+ await conn.execute(
+ text(
+ """
+ SELECT workspace_uuid, knowledge_base_uuid, vector_id,
+ embedding_dimension, text, file_id, chunk_uuid
+ FROM langbot_vectors
+ ORDER BY vector_id
+ """
+ )
+ )
+ )
+ .mappings()
+ .all()
+ )
+ assert [dict(row) for row in migrated_rows] == [
+ {
+ 'workspace_uuid': workspace_uuid,
+ 'knowledge_base_uuid': knowledge_base_uuid,
+ 'vector_id': 'legacy-by-chunk',
+ 'embedding_dimension': 384,
+ 'text': 'chunk row',
+ 'file_id': None,
+ 'chunk_uuid': 'legacy-chunk',
+ },
+ {
+ 'workspace_uuid': workspace_uuid,
+ 'knowledge_base_uuid': knowledge_base_uuid,
+ 'vector_id': 'legacy-by-collection',
+ 'embedding_dimension': 384,
+ 'text': 'collection row',
+ 'file_id': None,
+ 'chunk_uuid': None,
+ },
+ ]
+ assert (
+ await conn.scalar(
+ text('SELECT embedding_dimension FROM knowledge_bases WHERE uuid = :uuid'),
+ {'uuid': knowledge_base_uuid},
+ )
+ == 384
+ )
+ assert await conn.scalar(text("SELECT to_regclass('langbot_vectors_legacy_0013') IS NULL")) is True
+ assert await read_rls_states(conn, source_tables) == expected_source_rls
+ assert await read_rls_states(conn, ('langbot_vectors',)) == {'langbot_vectors': (True, True)}
+ assert (
+ await conn.scalar(
+ text(
+ """
+ SELECT COUNT(*)
+ FROM pg_policy AS p
+ JOIN pg_class AS c ON c.oid = p.polrelid
+ JOIN pg_namespace AS n ON n.oid = c.relnamespace
+ WHERE n.nspname = current_schema()
+ AND c.relname = 'langbot_vectors'
+ AND p.polname = 'langbot_workspace_isolation'
+ """
+ )
+ )
+ == 1
+ )
+
+ async with migrator_engine.connect() as conn:
+ assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 0
+ async with migrator_engine.begin() as conn:
+ await conn.execute(
+ text("SELECT set_config('langbot.workspace_uuid', :workspace_uuid, true)"),
+ {'workspace_uuid': workspace_uuid},
+ )
+ assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 2
+ finally:
+ if migrator_engine is not None:
+ await migrator_engine.dispose()
+ if role_created:
+ async with postgres_engine.connect() as conn:
+ if admin_role is None: # pragma: no cover - setup cannot create the role without an admin
+ admin_role = await conn.scalar(text('SELECT current_user'))
+ await conn.execute(text(f'REASSIGN OWNED BY {quote(migrator_role)} TO {quote(admin_role)}'))
+ await conn.execute(text(f'DROP OWNED BY {quote(migrator_role)}'))
+ await conn.execute(text(f'DROP ROLE IF EXISTS {quote(migrator_role)}'))
+
+
+async def test_pgvector_shared_database_is_scoped_indexed_and_ddl_free_at_runtime(
+ postgres_url: str,
+ postgres_engine: AsyncEngine,
+ clean_database,
+ monkeypatch,
+) -> None:
+ instance_uuid = 'pgvector-tenant-integration'
+ workspace_a = '10000000-0000-0000-0000-000000000101'
+ workspace_b = '20000000-0000-0000-0000-000000000202'
+ kb_a = 'knowledge-base-a'
+ kb_b = 'knowledge-base-b'
+ role_suffix = uuid.uuid4().hex[:12]
+ runtime_role = f'lb_vector_runtime_{role_suffix}'
+ role_password = f'Lb{uuid.uuid4().hex}'
+ release_manager: PersistenceManager | None = None
+ runtime_manager: PersistenceManager | None = None
+ role_created = False
+
+ _restore_postgres_registry(monkeypatch)
+ monkeypatch.setattr(constants, 'instance_id', instance_uuid)
+ quote = postgres_engine.dialect.identifier_preparer.quote
+ database_name = sa.engine.make_url(postgres_url).database
+ runtime_url = (
+ sa.engine.make_url(postgres_url)
+ .set(username=runtime_role, password=role_password)
+ .render_as_string(hide_password=False)
+ )
+
+ try:
+ release_app = _application(postgres_url, 'pgvector-release-migration-test')
+ release_manager = PersistenceManager(release_app, mode=PersistenceMode.RELEASE_MIGRATION)
+ release_app.persistence_mgr = release_manager
+ await release_manager.initialize()
+
+ for workspace_uuid, kb_uuid, slug in (
+ (workspace_a, kb_a, 'vector-a'),
+ (workspace_b, kb_b, 'vector-b'),
+ ):
+ async with release_manager.tenant_uow(workspace_uuid) as uow:
+ await uow.execute(
+ sa.insert(Workspace).values(
+ uuid=workspace_uuid,
+ instance_uuid=instance_uuid,
+ name=slug,
+ slug=slug,
+ type='team',
+ status='active',
+ source='cloud_projection',
+ projection_revision=0,
+ )
+ )
+ await uow.execute(
+ sa.insert(KnowledgeBase).values(
+ uuid=kb_uuid,
+ workspace_uuid=workspace_uuid,
+ name=slug,
+ embedding_dimension=384,
+ )
+ )
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{role_password}'"))
+ await conn.execute(text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(runtime_role)}'))
+ await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(runtime_role)}'))
+ business_tables = release_manager._runtime_business_table_names()
+ quoted_tables = ', '.join(f'public.{quote(table_name)}' for table_name in business_tables)
+ await conn.execute(
+ text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(runtime_role)}')
+ )
+ await conn.execute(text(f'GRANT SELECT ON TABLE public.alembic_version TO {quote(runtime_role)}'))
+ sequence_names = await release_manager._runtime_business_sequence_names(conn, business_tables)
+ if sequence_names:
+ quoted_sequences = ', '.join(f'public.{quote(sequence_name)}' for sequence_name in sequence_names)
+ await conn.execute(text(f'GRANT USAGE, SELECT ON SEQUENCE {quoted_sequences} TO {quote(runtime_role)}'))
+ role_created = True
+
+ runtime_app = _application(runtime_url, 'pgvector-runtime-test')
+ runtime_manager = PersistenceManager(runtime_app, mode=PersistenceMode.CLOUD_RUNTIME)
+ runtime_app.persistence_mgr = runtime_manager
+ await runtime_manager.initialize()
+
+ adapter = PgVectorDatabase(
+ runtime_app,
+ use_business_database=True,
+ allowed_dimensions=[384],
+ )
+ scope_a = PgVectorScope(workspace_a, kb_a, 384)
+ scope_b = PgVectorScope(workspace_b, kb_b, 384)
+
+ # The same vector ID is valid in two Workspaces because the relational
+ # primary key includes Workspace and knowledge base.
+ await adapter.add_embeddings(
+ 'opaque-a',
+ ['same-vector'],
+ [[0.1] * 384],
+ [{'text': 'workspace-a', 'file_id': 'file-a', 'uuid': 'chunk-a'}],
+ scope=scope_a,
+ )
+ await adapter.add_embeddings(
+ 'opaque-b',
+ ['same-vector'],
+ [[0.2] * 384],
+ [{'text': 'workspace-b', 'file_id': 'file-b', 'uuid': 'chunk-b'}],
+ scope=scope_b,
+ )
+
+ result_a = await adapter.search('opaque-a', [0.1] * 384, scope=scope_a)
+ result_b = await adapter.search('opaque-b', [0.2] * 384, scope=scope_b)
+ assert result_a['metadatas'][0][0]['text'] == 'workspace-a'
+ assert result_b['metadatas'][0][0]['text'] == 'workspace-b'
+
+ # Guessing another knowledge-base UUID while retaining A's Workspace
+ # cannot escape either the explicit conditions or PostgreSQL RLS.
+ guessed = await adapter.search(
+ 'attacker-controlled-name',
+ [0.2] * 384,
+ scope=PgVectorScope(workspace_a, kb_b, 384),
+ )
+ assert guessed['ids'] == [[]]
+
+ with pytest.raises(ValueError, match='trusted PgVectorScope'):
+ await adapter.search('opaque-a', [0.1] * 384)
+ with pytest.raises(ValueError, match='selected dimension'):
+ await adapter.add_embeddings(
+ 'opaque-a',
+ ['bad-dimension'],
+ [[0.1] * 383],
+ [{}],
+ scope=scope_a,
+ )
+
+ # Deliberately omit the application Workspace predicate. FORCE RLS is
+ # still the second isolation boundary and returns only A.
+ async with runtime_manager.tenant_uow(workspace_a) as uow:
+ rows = (
+ await uow.execute(
+ sa.select(PgVectorEntry.workspace_uuid, PgVectorEntry.vector_id).where(
+ PgVectorEntry.vector_id == 'same-vector'
+ )
+ )
+ ).all()
+ assert rows == [(workspace_a, 'same-vector')]
+
+ # Index-plan inspection is deployment diagnostics, not a public tenant
+ # Session capability. Establish the same transaction-local RLS scope on
+ # a test-only connection and keep raw EXPLAIN outside TenantUnitOfWork.
+ runtime_engine = runtime_manager.get_db_engine()
+ async with runtime_engine.begin() as conn:
+ await conn.execute(
+ text('SELECT set_config(:setting_name, :setting_value, true)'),
+ {'setting_name': 'langbot.workspace_uuid', 'setting_value': workspace_a},
+ )
+ await conn.execute(text('SET LOCAL enable_seqscan = off'))
+ plan = '\n'.join(
+ (
+ await conn.execute(
+ text(
+ """
+ EXPLAIN SELECT vector_id
+ FROM langbot_vectors
+ WHERE workspace_uuid = :workspace_uuid
+ AND knowledge_base_uuid = :knowledge_base_uuid
+ AND embedding_dimension = 384
+ ORDER BY (embedding::vector(384)) <=> CAST(:query AS vector(384))
+ LIMIT 5
+ """
+ ),
+ {
+ 'workspace_uuid': workspace_a,
+ 'knowledge_base_uuid': kb_a,
+ 'query': '[' + ','.join(['0.1'] * 384) + ']',
+ },
+ )
+ ).scalars()
+ )
+ assert 'ix_langbot_vectors_hnsw_cosine_384' in plan
+
+ async with runtime_engine.connect() as conn:
+ assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 0
+ assert await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) in (None, '')
+
+ items_a, total_a = await adapter.list_by_filter('opaque-a', scope=scope_a)
+ assert total_a == 1
+ assert items_a[0]['metadata']['file_id'] == 'file-a'
+ await adapter.delete_by_file_id('opaque-a', 'file-a', scope=scope_a)
+ assert (await adapter.list_by_filter('opaque-a', scope=scope_a))[1] == 0
+ assert (await adapter.list_by_filter('opaque-b', scope=scope_b))[1] == 1
+ finally:
+ if runtime_manager is not None and getattr(runtime_manager, 'db', None) is not None:
+ await runtime_manager.get_db_engine().dispose()
+ if release_manager is not None and getattr(release_manager, 'db', None) is not None:
+ await release_manager.get_db_engine().dispose()
+ if role_created:
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
+ await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
diff --git a/tests/integration/persistence/test_plugin_identity_migration.py b/tests/integration/persistence/test_plugin_identity_migration.py
new file mode 100644
index 000000000..d64d3cc43
--- /dev/null
+++ b/tests/integration/persistence/test_plugin_identity_migration.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import hashlib
+import uuid
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.persistence.alembic_runner import (
+ get_alembic_current,
+ run_alembic_stamp,
+ run_alembic_upgrade,
+)
+
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+async def test_legacy_plugin_settings_receive_stable_random_installation_identities(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "plugin-identity.db"}')
+ metadata = sa.MetaData()
+ plugin_settings = sa.Table(
+ 'plugin_settings',
+ metadata,
+ sa.Column('workspace_uuid', sa.String(36), primary_key=True),
+ sa.Column('plugin_author', sa.String(255), primary_key=True),
+ sa.Column('plugin_name', sa.String(255), primary_key=True),
+ sa.Column('enabled', sa.Boolean, nullable=False, server_default=sa.true()),
+ sa.Column('priority', sa.Integer, nullable=False, server_default='0'),
+ sa.Column('config', sa.JSON, nullable=False, server_default='{}'),
+ sa.Column('install_source', sa.String(255), nullable=False, server_default='local'),
+ sa.Column('install_info', sa.JSON, nullable=False, server_default='{}'),
+ )
+ try:
+ async with engine.begin() as connection:
+ await connection.run_sync(metadata.create_all)
+ await connection.execute(
+ plugin_settings.insert(),
+ [
+ {
+ 'workspace_uuid': '11111111-1111-4111-8111-111111111111',
+ 'plugin_author': 'author',
+ 'plugin_name': 'one',
+ },
+ {
+ 'workspace_uuid': '22222222-2222-4222-8222-222222222222',
+ 'plugin_author': 'author',
+ 'plugin_name': 'two',
+ },
+ ],
+ )
+ await run_alembic_stamp(engine, '0011_postgres_tenant_rls')
+ await run_alembic_upgrade(engine, '0012_plugin_identity')
+
+ async with engine.connect() as connection:
+ rows = (
+ (
+ await connection.execute(
+ sa.text(
+ 'SELECT installation_uuid, artifact_digest, runtime_revision '
+ 'FROM plugin_settings ORDER BY workspace_uuid'
+ )
+ )
+ )
+ .mappings()
+ .all()
+ )
+ columns = await connection.run_sync(
+ lambda sync_connection: {
+ column['name']: column for column in sa.inspect(sync_connection).get_columns('plugin_settings')
+ }
+ )
+ indexes = await connection.run_sync(
+ lambda sync_connection: {
+ index['name']: index for index in sa.inspect(sync_connection).get_indexes('plugin_settings')
+ }
+ )
+
+ assert await get_alembic_current(engine) == '0012_plugin_identity'
+ assert columns['installation_uuid']['nullable'] is False
+ assert columns['artifact_digest']['nullable'] is False
+ assert columns['runtime_revision']['nullable'] is False
+ assert indexes['ix_plugin_settings_workspace_installation']['unique'] == 1
+ assert len({row['installation_uuid'] for row in rows}) == 2
+ for row in rows:
+ uuid.UUID(row['installation_uuid'])
+ assert row['runtime_revision'] == 1
+ assert (
+ row['artifact_digest']
+ == hashlib.sha256(f'legacy-installation:{row["installation_uuid"]}'.encode()).hexdigest()
+ )
+ finally:
+ await engine.dispose()
diff --git a/tests/integration/persistence/test_release_migration_postgres.py b/tests/integration/persistence/test_release_migration_postgres.py
new file mode 100644
index 000000000..b6637afda
--- /dev/null
+++ b/tests/integration/persistence/test_release_migration_postgres.py
@@ -0,0 +1,734 @@
+"""Real PostgreSQL coverage for the one-shot Cloud release migration job."""
+
+from __future__ import annotations
+
+import logging
+import os
+import uuid
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+
+from langbot.pkg.persistence import release_migration
+from langbot.pkg.persistence.alembic_runner import (
+ get_alembic_current,
+ get_alembic_head,
+ run_alembic_stamp,
+ run_alembic_upgrade,
+)
+from langbot.pkg.persistence.mgr import (
+ PersistenceManager,
+ PersistenceMode,
+ _RELEASE_MIGRATION_ADVISORY_LOCK_ID,
+)
+from langbot.pkg.utils import constants
+
+
+pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
+_RUNTIME_PASSWORD = 'runtime-secret-not-used-by-migration'
+
+
+@pytest.fixture
+def postgres_url() -> str:
+ url = os.environ.get('TEST_POSTGRES_URL')
+ if not url:
+ pytest.skip('TEST_POSTGRES_URL not set')
+ return url
+
+
+@pytest.fixture
+async def postgres_engine(postgres_url: str):
+ engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT')
+ yield engine
+ await engine.dispose()
+
+
+@pytest.fixture
+async def clean_database(postgres_engine: AsyncEngine):
+ async def clean() -> None:
+ async with postgres_engine.begin() as conn:
+ table_names = await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
+ quote = postgres_engine.dialect.identifier_preparer.quote
+ for table_name in table_names:
+ await conn.execute(text(f'DROP TABLE {quote(table_name)} CASCADE'))
+
+ await clean()
+ yield
+ await clean()
+
+
+def _restore_postgres_registry(monkeypatch) -> None:
+ from langbot.pkg.persistence import mgr as persistence_mgr_module
+ from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager
+
+ monkeypatch.setattr(
+ persistence_mgr_module.database,
+ 'preregistered_managers',
+ [PostgreSQLDatabaseManager],
+ )
+
+
+def _application(postgres_url: str, *, runtime_role: str = 'langbot_runtime_not_used_by_migration') -> SimpleNamespace:
+ url = sa.engine.make_url(postgres_url)
+ return SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'use': 'postgresql',
+ 'postgresql': {
+ 'host': url.host,
+ 'port': url.port,
+ # This is deliberately not the operator role in the DSN.
+ 'user': runtime_role,
+ 'password': _RUNTIME_PASSWORD,
+ 'database': url.database,
+ },
+ 'cloud_migration': {'operator_dsn_env': 'TEST_RELEASE_OPERATOR_DSN'},
+ },
+ 'vdb': {
+ 'use': 'pgvector',
+ 'pgvector': {
+ 'use_business_database': True,
+ 'allowed_dimensions': [384, 512, 768, 1024, 1536],
+ },
+ },
+ }
+ ),
+ logger=logging.getLogger('cloud-release-migration-entrypoint-test'),
+ persistence_mgr=None,
+ )
+
+
+async def test_release_entrypoint_holds_lock_migrates_validates_and_disposes(
+ postgres_url: str,
+ postgres_engine: AsyncEngine,
+ clean_database,
+ monkeypatch,
+) -> None:
+ _restore_postgres_registry(monkeypatch)
+ monkeypatch.setattr(constants, 'instance_id', 'release-migration-entrypoint-test')
+ original_validate = PersistenceManager._validate_release_schema
+ validation_observed_lock = False
+ runtime_role = f'lb_release_runtime_{uuid.uuid4().hex[:12]}'
+ quote = postgres_engine.dialect.identifier_preparer.quote
+
+ async def validate_while_asserting_lock(self: PersistenceManager) -> None:
+ nonlocal validation_observed_lock
+ async with postgres_engine.connect() as conn:
+ acquired = await conn.scalar(
+ text('SELECT pg_try_advisory_lock(:lock_id)'),
+ {'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
+ )
+ if acquired:
+ await conn.scalar(
+ text('SELECT pg_advisory_unlock(:lock_id)'),
+ {'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
+ )
+ assert acquired is False
+ validation_observed_lock = True
+ await original_validate(self)
+
+ monkeypatch.setattr(PersistenceManager, '_validate_release_schema', validate_while_asserting_lock)
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{_RUNTIME_PASSWORD}'"))
+ # The release job must make this otherwise bare LOGIN usable without
+ # relying on pre-provisioned object ACLs.
+ assert (
+ await conn.scalar(
+ text(
+ """
+ SELECT NOT EXISTS (
+ SELECT 1
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ CROSS JOIN LATERAL aclexplode(c.relacl) acl
+ JOIN pg_roles grantee ON grantee.oid = acl.grantee
+ WHERE n.nspname = current_schema()
+ AND grantee.rolname = :runtime_role
+ )
+ """
+ ),
+ {'runtime_role': runtime_role},
+ )
+ is True
+ )
+ ap = _application(postgres_url, runtime_role=runtime_role)
+ try:
+ await release_migration.run_cloud_release_migration(
+ ap,
+ environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
+ )
+
+ assert validation_observed_lock is True
+ assert await get_alembic_current(postgres_engine) == get_alembic_head()
+ manager = ap.persistence_mgr
+ assert isinstance(manager, PersistenceManager)
+ business_tables = set(manager._runtime_business_table_names())
+ async with postgres_engine.connect() as conn:
+ assert await conn.scalar(text("SELECT to_regclass('langbot_vectors') IS NOT NULL")) is True
+ assert (
+ await conn.scalar(text("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector')")) is True
+ )
+ runtime_role_state = (
+ (
+ await conn.execute(
+ text(
+ """
+ SELECT
+ rolcanlogin,
+ rolsuper,
+ rolbypassrls,
+ rolcreatedb,
+ rolcreaterole,
+ rolreplication
+ FROM pg_roles
+ WHERE rolname = :runtime_role
+ """
+ ),
+ {'runtime_role': runtime_role},
+ )
+ )
+ .mappings()
+ .one()
+ )
+ assert dict(runtime_role_state) == {
+ 'rolcanlogin': True,
+ 'rolsuper': False,
+ 'rolbypassrls': False,
+ 'rolcreatedb': False,
+ 'rolcreaterole': False,
+ 'rolreplication': False,
+ }
+
+ database_privileges = set(
+ (
+ await conn.execute(
+ text(
+ """
+ SELECT acl.privilege_type
+ FROM pg_database database
+ CROSS JOIN LATERAL aclexplode(database.datacl) acl
+ JOIN pg_roles grantee ON grantee.oid = acl.grantee
+ WHERE database.datname = current_database()
+ AND grantee.rolname = :runtime_role
+ AND acl.is_grantable IS FALSE
+ """
+ ),
+ {'runtime_role': runtime_role},
+ )
+ )
+ .scalars()
+ .all()
+ )
+ schema_privileges = set(
+ (
+ await conn.execute(
+ text(
+ """
+ SELECT acl.privilege_type
+ FROM pg_namespace namespace
+ CROSS JOIN LATERAL aclexplode(namespace.nspacl) acl
+ JOIN pg_roles grantee ON grantee.oid = acl.grantee
+ WHERE namespace.nspname = current_schema()
+ AND grantee.rolname = :runtime_role
+ AND acl.is_grantable IS FALSE
+ """
+ ),
+ {'runtime_role': runtime_role},
+ )
+ )
+ .scalars()
+ .all()
+ )
+ assert database_privileges == {'CONNECT'}
+ assert schema_privileges == {'USAGE'}
+ assert (
+ await conn.scalar(
+ text("SELECT has_database_privilege(:runtime_role, current_database(), 'CREATE')"),
+ {'runtime_role': runtime_role},
+ )
+ is False
+ )
+ assert (
+ await conn.scalar(
+ text("SELECT has_schema_privilege(:runtime_role, current_schema(), 'CREATE')"),
+ {'runtime_role': runtime_role},
+ )
+ is False
+ )
+ # PostgreSQL grants TEMP to PUBLIC by default. The first Cloud
+ # release deliberately tolerates that inherited compatibility
+ # privilege while granting no direct TEMP ACL to the runtime role.
+ assert (
+ await conn.scalar(
+ text("SELECT has_database_privilege(:runtime_role, current_database(), 'TEMP')"),
+ {'runtime_role': runtime_role},
+ )
+ is True
+ )
+
+ object_grants = (
+ (
+ await conn.execute(
+ text(
+ """
+ SELECT
+ c.relname,
+ c.relkind::text AS relkind,
+ acl.privilege_type,
+ acl.is_grantable
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ CROSS JOIN LATERAL aclexplode(c.relacl) acl
+ JOIN pg_roles grantee ON grantee.oid = acl.grantee
+ WHERE n.nspname = current_schema()
+ AND c.relkind IN ('r', 'p', 'S')
+ AND grantee.rolname = :runtime_role
+ ORDER BY c.relname, acl.privilege_type
+ """
+ ),
+ {'runtime_role': runtime_role},
+ )
+ )
+ .mappings()
+ .all()
+ )
+ direct_table_grants: dict[str, set[str]] = {}
+ direct_sequence_grants: dict[str, set[str]] = {}
+ for grant in object_grants:
+ assert grant['is_grantable'] is False
+ target = direct_sequence_grants if grant['relkind'] == 'S' else direct_table_grants
+ target.setdefault(grant['relname'], set()).add(grant['privilege_type'])
+
+ assert set(direct_table_grants) == business_tables | {'alembic_version'}
+ assert all(
+ privileges == {'SELECT', 'INSERT', 'UPDATE', 'DELETE'}
+ for table_name, privileges in direct_table_grants.items()
+ if table_name != 'alembic_version'
+ )
+ assert direct_table_grants['alembic_version'] == {'SELECT'}
+ assert direct_sequence_grants
+ assert all(privileges == {'USAGE', 'SELECT'} for privileges in direct_sequence_grants.values())
+ # The session-level lock must be released before the one-shot job exits.
+ assert (
+ await conn.scalar(
+ text('SELECT pg_try_advisory_lock(:lock_id)'),
+ {'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
+ )
+ is True
+ )
+ assert (
+ await conn.scalar(
+ text('SELECT pg_advisory_unlock(:lock_id)'),
+ {'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
+ )
+ is True
+ )
+
+ runtime_url = (
+ sa.engine.make_url(postgres_url)
+ .set(username=runtime_role, password=_RUNTIME_PASSWORD)
+ .render_as_string(hide_password=False)
+ )
+ runtime_engine = create_async_engine(runtime_url)
+ try:
+ async with runtime_engine.begin() as conn:
+ assert await conn.scalar(text('SELECT current_user')) == runtime_role
+ await conn.execute(text("INSERT INTO metadata (key, value) VALUES ('runtime-grant-smoke', 'created')"))
+ await conn.execute(text("UPDATE metadata SET value = 'updated' WHERE key = 'runtime-grant-smoke'"))
+ assert (
+ await conn.scalar(text("SELECT value FROM metadata WHERE key = 'runtime-grant-smoke'")) == 'updated'
+ )
+ await conn.execute(text("DELETE FROM metadata WHERE key = 'runtime-grant-smoke'"))
+ await conn.scalar(
+ text('SELECT nextval(CAST(:sequence_name AS regclass))'),
+ {'sequence_name': f'public.{sorted(direct_sequence_grants)[0]}'},
+ )
+ finally:
+ await runtime_engine.dispose()
+
+ runtime_application = _application(postgres_url, runtime_role=runtime_role)
+ runtime_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
+ runtime_application.persistence_mgr = runtime_manager
+ try:
+ # This reads alembic_version as the actual runtime role and then
+ # reruns the complete grant/catalog validator before startup.
+ await runtime_manager.initialize()
+ finally:
+ await runtime_manager.get_db_engine().dispose()
+
+ # The catalog validator must fail closed if the role is no longer
+ # deployable, even though the remaining grants still look plausible.
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'REVOKE DELETE ON TABLE public.metadata FROM {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match="table 'metadata' grants are incomplete"):
+ await manager._validate_configured_runtime_postgres_role(require_grants=True)
+ finally:
+ manager = getattr(ap, 'persistence_mgr', None)
+ if isinstance(manager, PersistenceManager):
+ # The one-shot entrypoint disposes its pool before returning. This
+ # test deliberately reuses the manager for catalog mutation checks,
+ # which can open a fresh pool and therefore owns a second shutdown.
+ await manager.shutdown()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
+ await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
+
+
+async def test_release_entrypoint_rejects_privileged_or_table_owning_runtime_role(
+ postgres_url: str,
+ postgres_engine: AsyncEngine,
+ clean_database,
+ monkeypatch,
+) -> None:
+ _restore_postgres_registry(monkeypatch)
+ monkeypatch.setattr(constants, 'instance_id', 'release-runtime-role-validation-test')
+ runtime_role = f'lb_release_runtime_{uuid.uuid4().hex[:12]}'
+ quote = postgres_engine.dialect.identifier_preparer.quote
+ async with postgres_engine.connect() as conn:
+ operator_role = await conn.scalar(text('SELECT current_user'))
+ await conn.execute(text(f'CREATE ROLE {quote(runtime_role)} LOGIN'))
+
+ ap = _application(postgres_url, runtime_role=runtime_role)
+ try:
+ await release_migration.run_cloud_release_migration(
+ ap,
+ environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
+ )
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SUPERUSER'))
+ with pytest.raises(RuntimeError, match='must not be superuser or BYPASSRLS'):
+ await release_migration.run_cloud_release_migration(
+ ap,
+ environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
+ )
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} NOSUPERUSER BYPASSRLS'))
+ with pytest.raises(RuntimeError, match='must not be superuser or BYPASSRLS'):
+ await release_migration.run_cloud_release_migration(
+ ap,
+ environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
+ )
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} NOBYPASSRLS'))
+ await conn.execute(text(f'ALTER TABLE bots OWNER TO {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match='owns tenant tables'):
+ await release_migration.run_cloud_release_migration(
+ ap,
+ environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
+ )
+ finally:
+ async with postgres_engine.connect() as conn:
+ table_exists = await conn.scalar(text("SELECT to_regclass('bots') IS NOT NULL"))
+ if table_exists:
+ await conn.execute(text(f'ALTER TABLE bots OWNER TO {quote(operator_role)}'))
+ await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
+ await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
+
+
+async def test_runtime_role_catalog_validator_rejects_delegation_and_escape_hatches(
+ postgres_url: str,
+ postgres_engine: AsyncEngine,
+ clean_database,
+ monkeypatch,
+) -> None:
+ _restore_postgres_registry(monkeypatch)
+ monkeypatch.setattr(constants, 'instance_id', 'release-runtime-role-catalog-test')
+ suffix = uuid.uuid4().hex[:12]
+ runtime_role = f'lb_release_runtime_{suffix}'
+ delegated_role = f'lb_release_delegate_{suffix}'
+ extra_schema = f'lb_release_schema_{suffix}'
+ extra_view = f'lb_release_view_{suffix}'
+ owned_routine = f'lb_release_owned_routine_{suffix}'
+ security_definer = f'lb_release_definer_{suffix}'
+ foreign_wrapper = f'lb_release_fdw_{suffix}'
+ foreign_server = f'lb_release_server_{suffix}'
+ persistent_setting_secret = f'lb_release_secret_{suffix}'
+ database_name = sa.engine.make_url(postgres_url).database
+ assert database_name
+ quote = postgres_engine.dialect.identifier_preparer.quote
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{_RUNTIME_PASSWORD}'"))
+ await conn.execute(text(f'CREATE ROLE {quote(delegated_role)}'))
+
+ ap = _application(postgres_url, runtime_role=runtime_role)
+ try:
+ await release_migration.run_cloud_release_migration(
+ ap,
+ environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
+ )
+ manager = ap.persistence_mgr
+ assert isinstance(manager, PersistenceManager)
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'GRANT pg_read_all_data TO {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match='must not participate in role memberships'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'REVOKE pg_read_all_data FROM {quote(runtime_role)}'))
+
+ # Reject delegation in the other direction too: no role may be
+ # allowed to SET ROLE to the runtime identity or administer it.
+ await conn.execute(text(f'GRANT {quote(runtime_role)} TO {quote(delegated_role)} WITH ADMIN OPTION'))
+ with pytest.raises(RuntimeError, match='must not participate in role memberships'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'REVOKE {quote(runtime_role)} FROM {quote(delegated_role)}'))
+
+ await conn.execute(
+ text(f'GRANT SELECT ON TABLE public.metadata TO {quote(runtime_role)} WITH GRANT OPTION')
+ )
+ with pytest.raises(RuntimeError, match='GRANT OPTION'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(
+ text(f'REVOKE GRANT OPTION FOR SELECT ON TABLE public.metadata FROM {quote(runtime_role)}')
+ )
+
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SET search_path TO public'))
+ with pytest.raises(RuntimeError, match='persistent session overrides'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET search_path'))
+
+ await conn.execute(text(f'ALTER DATABASE {quote(database_name)} SET search_path TO public'))
+ with pytest.raises(RuntimeError, match='persistent session overrides'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER DATABASE {quote(database_name)} RESET search_path'))
+
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} SET session_replication_role TO replica'))
+ with pytest.raises(RuntimeError, match='persistent session overrides'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET session_replication_role'))
+
+ await conn.execute(
+ text(f"ALTER ROLE {quote(runtime_role)} SET application_name TO '{persistent_setting_secret}'")
+ )
+ with pytest.raises(RuntimeError, match='persistent session overrides') as error:
+ await manager._validate_configured_runtime_postgres_role()
+ assert persistent_setting_secret not in str(error.value)
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET application_name'))
+
+ await conn.execute(text('CREATE EXTENSION dblink'))
+ with pytest.raises(RuntimeError, match='extensions must include vector and be limited'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text('DROP EXTENSION dblink'))
+ await manager._validate_configured_runtime_postgres_role()
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'GRANT CREATE ON DATABASE {quote(database_name)} TO {quote(runtime_role)}'))
+ await conn.execute(text(f'GRANT CREATE ON SCHEMA public TO {quote(runtime_role)}'))
+ runtime_url = (
+ sa.engine.make_url(postgres_url)
+ .set(username=runtime_role, password=_RUNTIME_PASSWORD)
+ .render_as_string(hide_password=False)
+ )
+ runtime_engine = create_async_engine(runtime_url)
+ try:
+ async with runtime_engine.begin() as conn:
+ # hstore is a trusted extension in the production PG16 image,
+ # so this creates a real runtime-owned extension catalog row.
+ await conn.execute(text('CREATE EXTENSION hstore'))
+ finally:
+ await runtime_engine.dispose()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'REVOKE CREATE ON SCHEMA public FROM {quote(runtime_role)}'))
+ await conn.execute(text(f'REVOKE CREATE ON DATABASE {quote(database_name)} FROM {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match='must not own extensions'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text('DROP EXTENSION hstore CASCADE'))
+ await manager._validate_configured_runtime_postgres_role()
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'CREATE FOREIGN DATA WRAPPER {quote(foreign_wrapper)}'))
+ await conn.execute(
+ text(f'CREATE SERVER {quote(foreign_server)} FOREIGN DATA WRAPPER {quote(foreign_wrapper)}')
+ )
+ await conn.execute(text(f'CREATE USER MAPPING FOR {quote(runtime_role)} SERVER {quote(foreign_server)}'))
+ with pytest.raises(RuntimeError, match='foreign data wrappers, servers, or user mappings'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'DROP FOREIGN DATA WRAPPER {quote(foreign_wrapper)} CASCADE'))
+ await manager._validate_configured_runtime_postgres_role()
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'CREATE SCHEMA {quote(extra_schema)} AUTHORIZATION {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match='non-business schemas'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'DROP SCHEMA {quote(extra_schema)} CASCADE'))
+
+ await conn.execute(text(f'CREATE VIEW public.{quote(extra_view)} AS SELECT key FROM public.metadata'))
+ await conn.execute(text(f'GRANT SELECT ON public.{quote(extra_view)} TO {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match='non-business objects|table privileges are unsafe'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'DROP VIEW public.{quote(extra_view)}'))
+
+ await conn.execute(text(f'GRANT SELECT (key) ON public.metadata TO {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match='column-level ACLs'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'REVOKE SELECT (key) ON public.metadata FROM {quote(runtime_role)}'))
+
+ # System file access functions are not SECURITY DEFINER, so the
+ # validator must reject their explicit EXECUTE ACL independently.
+ await conn.execute(
+ text(f'GRANT EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) TO {quote(runtime_role)}')
+ )
+ runtime_application = _application(postgres_url, runtime_role=runtime_role)
+ runtime_manager = PersistenceManager(runtime_application, mode=PersistenceMode.CLOUD_RUNTIME)
+ runtime_application.persistence_mgr = runtime_manager
+ try:
+ with pytest.raises(RuntimeError, match='explicit EXECUTE privileges on routines'):
+ await runtime_manager.initialize()
+ finally:
+ await runtime_manager.get_db_engine().dispose()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(
+ text(f'REVOKE EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) FROM {quote(runtime_role)}')
+ )
+ await manager._validate_configured_runtime_postgres_role()
+
+ async with postgres_engine.connect() as conn:
+ # replica disables ordinary triggers/rules and foreign-key
+ # enforcement; it must never reach the runtime identity.
+ await conn.execute(text(f'GRANT SET ON PARAMETER session_replication_role TO {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match='explicit SET or ALTER SYSTEM parameter privileges'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'REVOKE SET ON PARAMETER session_replication_role FROM {quote(runtime_role)}'))
+ await manager._validate_configured_runtime_postgres_role()
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(
+ text(f"CREATE FUNCTION public.{quote(owned_routine)}() RETURNS integer LANGUAGE sql AS 'SELECT 1'")
+ )
+ await conn.execute(text(f'ALTER FUNCTION public.{quote(owned_routine)}() OWNER TO {quote(runtime_role)}'))
+ with pytest.raises(RuntimeError, match='must not own routines'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'DROP FUNCTION public.{quote(owned_routine)}()'))
+
+ async with postgres_engine.connect() as conn:
+ await conn.execute(
+ text(
+ f'CREATE FUNCTION public.{quote(security_definer)}() RETURNS integer '
+ "LANGUAGE sql SECURITY DEFINER AS 'SELECT 1'"
+ )
+ )
+ # Extension membership must not exempt an executable definer from
+ # the runtime audit, even for an allowlisted extension.
+ await conn.execute(text(f'ALTER EXTENSION vector ADD FUNCTION public.{quote(security_definer)}()'))
+ with pytest.raises(RuntimeError, match='SECURITY DEFINER'):
+ await manager._validate_configured_runtime_postgres_role()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER EXTENSION vector DROP FUNCTION public.{quote(security_definer)}()'))
+ finally:
+ manager = getattr(ap, 'persistence_mgr', None)
+ if isinstance(manager, PersistenceManager):
+ # The entrypoint disposed the release pool before returning; all
+ # validator calls above happened afterward and can reopen it.
+ await manager.shutdown()
+ async with postgres_engine.connect() as conn:
+ await conn.execute(text(f'ALTER DATABASE {quote(database_name)} RESET search_path'))
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET search_path'))
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET session_replication_role'))
+ await conn.execute(text(f'ALTER ROLE {quote(runtime_role)} RESET application_name'))
+ await conn.execute(text(f'REVOKE pg_read_all_data FROM {quote(runtime_role)}'))
+ await conn.execute(text(f'REVOKE {quote(runtime_role)} FROM {quote(delegated_role)}'))
+ await conn.execute(text(f'REVOKE CREATE ON SCHEMA public FROM {quote(runtime_role)}'))
+ await conn.execute(text(f'REVOKE CREATE ON DATABASE {quote(database_name)} FROM {quote(runtime_role)}'))
+ await conn.execute(
+ text(f'REVOKE EXECUTE ON FUNCTION pg_catalog.pg_read_file(text) FROM {quote(runtime_role)}')
+ )
+ await conn.execute(text(f'REVOKE SET ON PARAMETER session_replication_role FROM {quote(runtime_role)}'))
+ await conn.execute(text('DROP EXTENSION IF EXISTS dblink CASCADE'))
+ await conn.execute(text('DROP EXTENSION IF EXISTS hstore CASCADE'))
+ await conn.execute(text(f'DROP FOREIGN DATA WRAPPER IF EXISTS {quote(foreign_wrapper)} CASCADE'))
+ await conn.execute(text(f'DROP FUNCTION IF EXISTS public.{quote(owned_routine)}()'))
+ security_definer_is_extension_member = await conn.scalar(
+ text(
+ """
+ SELECT EXISTS (
+ SELECT 1
+ FROM pg_depend dependency
+ JOIN pg_extension extension ON extension.oid = dependency.refobjid
+ WHERE dependency.classid = 'pg_proc'::regclass
+ AND dependency.objid = to_regprocedure(:routine)
+ AND dependency.refclassid = 'pg_extension'::regclass
+ AND dependency.deptype = 'e'
+ AND extension.extname = 'vector'
+ )
+ """
+ ),
+ {'routine': f'public.{security_definer}()'},
+ )
+ if security_definer_is_extension_member:
+ await conn.execute(text(f'ALTER EXTENSION vector DROP FUNCTION public.{quote(security_definer)}()'))
+ await conn.execute(text(f'DROP FUNCTION IF EXISTS public.{quote(security_definer)}()'))
+ await conn.execute(text(f'DROP VIEW IF EXISTS public.{quote(extra_view)}'))
+ await conn.execute(text(f'DROP SCHEMA IF EXISTS {quote(extra_schema)} CASCADE'))
+ await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}'))
+ await conn.execute(text(f'DROP ROLE IF EXISTS {quote(delegated_role)}'))
+ await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))
+
+
+async def test_direct_postgres_head_stamp_fails_without_business_schema(
+ postgres_engine: AsyncEngine,
+ clean_database,
+) -> None:
+ await run_alembic_stamp(postgres_engine, '0012_plugin_identity')
+
+ with pytest.raises(RuntimeError, match='requires the knowledge_bases table'):
+ await run_alembic_upgrade(postgres_engine)
+
+ assert await get_alembic_current(postgres_engine) == '0012_plugin_identity'
+
+
+async def test_release_entrypoint_fails_immediately_when_another_job_holds_lock(
+ postgres_url: str,
+ postgres_engine: AsyncEngine,
+ clean_database,
+ monkeypatch,
+) -> None:
+ _restore_postgres_registry(monkeypatch)
+ ap = _application(postgres_url)
+
+ async with postgres_engine.connect() as lock_connection:
+ assert (
+ await lock_connection.scalar(
+ text('SELECT pg_try_advisory_lock(:lock_id)'),
+ {'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
+ )
+ is True
+ )
+ try:
+ with pytest.raises(RuntimeError, match='already holds the advisory lock'):
+ await release_migration.run_cloud_release_migration(
+ ap,
+ environ={'TEST_RELEASE_OPERATOR_DSN': postgres_url},
+ )
+ finally:
+ assert (
+ await lock_connection.scalar(
+ text('SELECT pg_advisory_unlock(:lock_id)'),
+ {'lock_id': _RELEASE_MIGRATION_ADVISORY_LOCK_ID},
+ )
+ is True
+ )
+
+ async with postgres_engine.connect() as conn:
+ assert await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names()) == []
diff --git a/tests/integration/persistence/test_resource_tenancy_migration.py b/tests/integration/persistence/test_resource_tenancy_migration.py
new file mode 100644
index 000000000..6ae288061
--- /dev/null
+++ b/tests/integration/persistence/test_resource_tenancy_migration.py
@@ -0,0 +1,292 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import uuid
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.entity import persistence
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
+from langbot.pkg.utils import importutil
+
+from .resource_migration_support import TENANT_TABLES, create_legacy_resource_schema
+
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+async def _inspect(engine, callback):
+ async with engine.connect() as conn:
+ return await conn.run_sync(callback)
+
+
+async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "legacy-resources.db"}')
+ try:
+ await create_legacy_resource_schema(engine, instance_uuid='resource-migration-test')
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ await run_alembic_upgrade(engine, 'head')
+
+ async with engine.connect() as conn:
+ workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
+ assert workspace_uuid is not None
+ for table_name in TENANT_TABLES:
+ count, distinct_workspaces = (
+ await conn.execute(sa.text(f'SELECT COUNT(*), COUNT(DISTINCT workspace_uuid) FROM {table_name}'))
+ ).one()
+ assert count == 1, table_name
+ assert distinct_workspaces == 1, table_name
+ assert (
+ await conn.scalar(
+ sa.text(f'SELECT COUNT(*) FROM {table_name} WHERE workspace_uuid != :workspace_uuid'),
+ {'workspace_uuid': workspace_uuid},
+ )
+ == 0
+ )
+
+ api_key = (
+ (await conn.execute(sa.text('SELECT key_hash, scopes, status, created_by_account_uuid FROM api_keys')))
+ .mappings()
+ .one()
+ )
+ assert api_key['key_hash'] == hashlib.sha256(b'lbk_legacy-secret').hexdigest()
+ stored_scopes = api_key['scopes']
+ if isinstance(stored_scopes, str):
+ stored_scopes = json.loads(stored_scopes)
+ assert stored_scopes == ['*']
+ assert api_key['status'] == 'active'
+ assert api_key['created_by_account_uuid'] is not None
+ assert await conn.scalar(sa.text('SELECT normalized_email FROM users')) == 'owner@example.com'
+ legacy_kb = (
+ (
+ await conn.execute(
+ sa.text(
+ 'SELECT collection_id, legacy_vector_collection FROM knowledge_bases WHERE uuid = :uuid'
+ ),
+ {'uuid': 'kb-1'},
+ )
+ )
+ .mappings()
+ .one()
+ )
+ assert legacy_kb['collection_id'] == 'collection-1'
+ assert legacy_kb['legacy_vector_collection'] == 1
+ assert (
+ await conn.scalar(
+ sa.text(
+ 'SELECT COUNT(*) FROM metadata '
+ "WHERE key IN ('wizard_status', 'wizard_progress', 'rag_plugin_migration_needed')"
+ )
+ )
+ == 0
+ )
+ assert (
+ await conn.scalar(
+ sa.text('SELECT COUNT(*) FROM workspace_metadata WHERE workspace_uuid = :workspace_uuid'),
+ {'workspace_uuid': workspace_uuid},
+ )
+ == 3
+ )
+
+ api_columns = await _inspect(
+ engine,
+ lambda conn: {column['name'] for column in sa.inspect(conn).get_columns('api_keys')},
+ )
+ assert 'key' not in api_columns
+ assert {'uuid', 'key_hash', 'scopes', 'status', 'expires_at', 'last_used_at'} <= api_columns
+ for table_name in TENANT_TABLES:
+ columns = await _inspect(
+ engine,
+ lambda conn, name=table_name: {column['name']: column for column in sa.inspect(conn).get_columns(name)},
+ )
+ assert columns['workspace_uuid']['nullable'] is False, table_name
+ if table_name == 'knowledge_bases':
+ assert columns['legacy_vector_collection']['nullable'] is False
+
+ pk_columns = {
+ table_name: tuple(
+ (
+ await _inspect(
+ engine,
+ lambda conn, name=table_name: sa.inspect(conn).get_pk_constraint(name),
+ )
+ )['constrained_columns']
+ )
+ for table_name in ('binary_storages', 'plugin_settings', 'monitoring_sessions')
+ }
+ assert pk_columns == {
+ 'binary_storages': ('workspace_uuid', 'unique_key'),
+ 'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
+ 'monitoring_sessions': ('workspace_uuid', 'session_id'),
+ }
+
+ pipeline_run_foreign_keys = await _inspect(
+ engine,
+ lambda conn: sa.inspect(conn).get_foreign_keys('pipeline_run_records'),
+ )
+ assert any(
+ tuple(foreign_key['constrained_columns']) == ('workspace_uuid', 'pipeline_uuid')
+ and foreign_key['referred_table'] == 'legacy_pipelines'
+ and tuple(foreign_key['referred_columns']) == ('workspace_uuid', 'uuid')
+ for foreign_key in pipeline_run_foreign_keys
+ )
+ finally:
+ await engine.dispose()
+
+
+async def test_legacy_vector_marker_backfill_resumes_from_nullable_expand_step(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "legacy-vector-retry.db"}')
+ try:
+ await create_legacy_resource_schema(engine, instance_uuid='legacy-vector-retry')
+ async with engine.begin() as conn:
+ await conn.execute(sa.text('ALTER TABLE knowledge_bases ADD COLUMN legacy_vector_collection BOOLEAN NULL'))
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ await run_alembic_upgrade(engine, 'head')
+
+ async with engine.connect() as conn:
+ assert (
+ await conn.scalar(
+ sa.text('SELECT legacy_vector_collection FROM knowledge_bases WHERE uuid = :uuid'),
+ {'uuid': 'kb-1'},
+ )
+ == 1
+ )
+ columns = await _inspect(
+ engine,
+ lambda conn: {column['name']: column for column in sa.inspect(conn).get_columns('knowledge_bases')},
+ )
+ assert columns['legacy_vector_collection']['nullable'] is False
+ finally:
+ await engine.dispose()
+
+
+async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspace(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "scoped-keys.db"}')
+ try:
+ await create_legacy_resource_schema(engine, instance_uuid='scoped-key-test')
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ await run_alembic_upgrade(engine, 'head')
+
+ second_workspace_uuid = str(uuid.uuid4())
+ async with engine.begin() as conn:
+ await conn.execute(sa.text('PRAGMA foreign_keys=ON'))
+ first_workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO workspaces '
+ '(uuid, instance_uuid, name, slug, type, status, source, projection_revision) '
+ "VALUES (:uuid, 'scoped-key-test', 'Second', 'second', 'team', 'active', "
+ "'cloud_projection', 0)"
+ ),
+ {'uuid': second_workspace_uuid},
+ )
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO mcp_servers (uuid, workspace_uuid, name, enable, updated_at) '
+ "VALUES ('mcp-2', :workspace_uuid, 'shared-name', 1, CURRENT_TIMESTAMP)"
+ ),
+ {'workspace_uuid': second_workspace_uuid},
+ )
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO plugin_settings '
+ '(workspace_uuid, plugin_author, plugin_name, enabled, '
+ 'installation_uuid, artifact_digest, runtime_revision) '
+ "VALUES (:workspace_uuid, 'author', 'plugin', 1, "
+ ':installation_uuid, :artifact_digest, 1)'
+ ),
+ {
+ 'workspace_uuid': second_workspace_uuid,
+ 'installation_uuid': str(uuid.uuid4()),
+ 'artifact_digest': hashlib.sha256(b'test-plugin-artifact').hexdigest(),
+ },
+ )
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO binary_storages '
+ '(workspace_uuid, unique_key, key, owner_type, owner) '
+ "VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
+ ),
+ {'workspace_uuid': second_workspace_uuid},
+ )
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO monitoring_sessions '
+ '(workspace_uuid, session_id, bot_id, last_activity, is_active) '
+ "VALUES (:workspace_uuid, 'session-1', 'bot-2', CURRENT_TIMESTAMP, 1)"
+ ),
+ {'workspace_uuid': second_workspace_uuid},
+ )
+
+ with pytest.raises(IntegrityError):
+ async with engine.begin() as conn:
+ await conn.execute(sa.text('PRAGMA foreign_keys=ON'))
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO mcp_servers (uuid, workspace_uuid, name, enable, updated_at) '
+ "VALUES ('mcp-duplicate', :workspace_uuid, 'shared-name', 1, CURRENT_TIMESTAMP)"
+ ),
+ {'workspace_uuid': first_workspace_uuid},
+ )
+
+ with pytest.raises(IntegrityError):
+ async with engine.begin() as conn:
+ await conn.execute(sa.text('PRAGMA foreign_keys=ON'))
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO llm_models (uuid, workspace_uuid, name, provider_uuid) '
+ "VALUES ('cross-workspace-model', :workspace_uuid, 'model', 'provider-1')"
+ ),
+ {'workspace_uuid': second_workspace_uuid},
+ )
+
+ with pytest.raises(IntegrityError):
+ async with engine.begin() as conn:
+ await conn.execute(sa.text('PRAGMA foreign_keys=ON'))
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO pipeline_run_records '
+ '(uuid, workspace_uuid, pipeline_uuid, created_at) '
+ "VALUES ('cross-workspace-run', :workspace_uuid, 'pipeline-1', CURRENT_TIMESTAMP)"
+ ),
+ {'workspace_uuid': second_workspace_uuid},
+ )
+
+ with pytest.raises(IntegrityError):
+ async with engine.begin() as conn:
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO mcp_servers (uuid, name, enable, updated_at) '
+ "VALUES ('unscoped-mcp', 'unscoped', 1, CURRENT_TIMESTAMP)"
+ )
+ )
+ finally:
+ await engine.dispose()
+
+
+async def test_fresh_sqlite_schema_matches_resource_tenancy_contract(tmp_path):
+ importutil.import_modules_in_pkg(persistence)
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "fresh-resources.db"}')
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ await run_alembic_stamp(engine, '0001_baseline')
+ await run_alembic_upgrade(engine, 'head')
+
+ tables = await _inspect(engine, lambda conn: set(sa.inspect(conn).get_table_names()))
+ assert set(TENANT_TABLES) | {'workspace_metadata'} <= tables
+ for table_name in TENANT_TABLES:
+ columns = await _inspect(
+ engine,
+ lambda conn, name=table_name: {column['name']: column for column in sa.inspect(conn).get_columns(name)},
+ )
+ assert columns['workspace_uuid']['nullable'] is False, table_name
+ if table_name == 'knowledge_bases':
+ assert columns['legacy_vector_collection']['nullable'] is False
+ finally:
+ await engine.dispose()
diff --git a/tests/integration/persistence/test_sqlite_migration_backup.py b/tests/integration/persistence/test_sqlite_migration_backup.py
new file mode 100644
index 000000000..dab50dd96
--- /dev/null
+++ b/tests/integration/persistence/test_sqlite_migration_backup.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+import json
+import logging
+import pathlib
+import sqlite3
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.persistence import alembic_runner
+from langbot.pkg.persistence.mgr import PersistenceManager
+
+from .resource_migration_support import create_legacy_resource_schema
+
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+def _manager(engine) -> PersistenceManager:
+ database = type('Database', (), {'get_engine': lambda self: engine})()
+ application = type('Application', (), {})()
+ application.logger = logging.getLogger('sqlite-migration-backup-test')
+ manager = PersistenceManager(application)
+ manager.db = database
+ return manager
+
+
+def _manifest_payloads(backup_directory) -> list[dict]:
+ return [json.loads(path.read_text(encoding='utf-8')) for path in sorted(backup_directory.glob('*.json'))]
+
+
+def _assert_verified_backup(payload: dict) -> None:
+ backup_path = pathlib.Path(payload['backup_path'])
+ with sqlite3.connect(f'{backup_path.as_uri()}?mode=ro', uri=True) as connection:
+ assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
+ assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
+
+
+async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
+ database_path = tmp_path / 'legacy-with-backups.db'
+ engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
+ try:
+ await create_legacy_resource_schema(engine, instance_uuid='backup-success')
+ await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+
+ await _manager(engine)._run_alembic_migrations()
+
+ assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
+ payloads = _manifest_payloads(tmp_path / 'migration-backups')
+ assert len(payloads) == 2
+ assert {
+ (payload['source_revision'], payload['target_revision'], payload['status']) for payload in payloads
+ } == {
+ ('0008_mcp_resource_prefs', '0009_workspace_tenancy', 'migration_succeeded'),
+ ('0009_workspace_tenancy', '0010_scope_resources', 'migration_succeeded'),
+ }
+ for payload in payloads:
+ _assert_verified_backup(payload)
+ finally:
+ await engine.dispose()
+
+
+async def test_failed_tenancy_migration_restores_backup_and_revision(
+ tmp_path,
+ monkeypatch,
+):
+ database_path = tmp_path / 'legacy-fault-injection.db'
+ engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
+ real_upgrade = alembic_runner.run_alembic_upgrade
+
+ async def injected_upgrade(async_engine, revision='head'):
+ if revision != '0010_scope_resources':
+ return await real_upgrade(async_engine, revision)
+ async with async_engine.begin() as connection:
+ await connection.execute(sa.text('CREATE TABLE injected_partial_migration (value TEXT NOT NULL)'))
+ await alembic_runner.run_alembic_stamp(async_engine, '0010_scope_resources')
+ raise RuntimeError('injected migration failure after a fake revision stamp')
+
+ try:
+ await create_legacy_resource_schema(engine, instance_uuid='backup-failure')
+ await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', injected_upgrade)
+
+ with pytest.raises(RuntimeError, match='injected migration failure'):
+ await _manager(engine)._run_alembic_migrations()
+
+ assert await alembic_runner.get_alembic_current(engine) == '0009_workspace_tenancy'
+ async with engine.connect() as connection:
+ tables = set(
+ await connection.run_sync(lambda sync_connection: sa.inspect(sync_connection).get_table_names())
+ )
+ assert 'injected_partial_migration' not in tables
+
+ payloads = _manifest_payloads(tmp_path / 'migration-backups')
+ restored = [payload for payload in payloads if payload['target_revision'] == '0010_scope_resources']
+ assert len(restored) == 1
+ assert restored[0]['status'] == 'restored_after_failure'
+ assert restored[0]['source_revision'] == '0009_workspace_tenancy'
+ _assert_verified_backup(restored[0])
+
+ monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
+ await _manager(engine)._run_alembic_migrations()
+ assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
+ finally:
+ await engine.dispose()
diff --git a/tests/integration/persistence/test_workspace_migration.py b/tests/integration/persistence/test_workspace_migration.py
new file mode 100644
index 000000000..4fc56eba5
--- /dev/null
+++ b/tests/integration/persistence/test_workspace_migration.py
@@ -0,0 +1,380 @@
+from __future__ import annotations
+
+import logging
+import uuid
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.entity import persistence
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.persistence.mgr import PersistenceManager
+from langbot.pkg.persistence.alembic_runner import (
+ get_alembic_head,
+ get_alembic_current,
+ run_alembic_downgrade,
+ run_alembic_stamp,
+ run_alembic_upgrade,
+)
+from langbot.pkg.utils import constants
+from langbot.pkg.utils import importutil
+from langbot.pkg.workspace.collaboration import normalize_email
+
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+async def _create_legacy_schema(
+ engine,
+ *,
+ include_instance_uuid: bool = True,
+ include_users: bool = True,
+) -> None:
+ legacy_metadata = sa.MetaData()
+ metadata_table = sa.Table(
+ 'metadata',
+ legacy_metadata,
+ sa.Column('key', sa.String(255), primary_key=True),
+ sa.Column('value', sa.String(255)),
+ )
+ users = sa.Table(
+ 'users',
+ legacy_metadata,
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('user', sa.String(255), nullable=False),
+ sa.Column('password', sa.String(255), nullable=False),
+ sa.Column('account_type', sa.String(32), nullable=False, server_default='local'),
+ sa.Column('space_account_uuid', sa.String(255), nullable=True),
+ sa.Column('space_access_token', sa.Text, nullable=True),
+ sa.Column('space_refresh_token', sa.Text, nullable=True),
+ sa.Column('space_access_token_expires_at', sa.DateTime, nullable=True),
+ sa.Column('space_api_key', sa.String(255), nullable=True),
+ sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
+ sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
+ )
+ async with engine.begin() as conn:
+ await conn.run_sync(legacy_metadata.create_all)
+ await conn.execute(metadata_table.insert().values(key='database_version', value='25'))
+ if include_instance_uuid:
+ await conn.execute(metadata_table.insert().values(key='instance_uuid', value='instance_migration_test'))
+ if include_users:
+ await conn.execute(
+ users.insert(),
+ [
+ {'user': 'owner@example.com', 'password': 'owner-hash'},
+ {'user': 'member@example.com', 'password': 'member-hash'},
+ ],
+ )
+
+
+@pytest.fixture
+async def legacy_engine(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "legacy-workspace.db"}')
+ await _create_legacy_schema(engine)
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ yield engine
+ await engine.dispose()
+
+
+async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy_engine):
+ await run_alembic_upgrade(legacy_engine, 'head')
+
+ async with legacy_engine.connect() as conn:
+ tables = set(await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names()))
+ assert {
+ 'workspaces',
+ 'workspace_memberships',
+ 'workspace_invitations',
+ 'workspace_execution_states',
+ }.issubset(tables)
+
+ accounts = (
+ (await conn.execute(sa.text('SELECT id, uuid, status, source, projection_revision FROM users ORDER BY id')))
+ .mappings()
+ .all()
+ )
+ assert len(accounts) == 2
+ assert len({account['uuid'] for account in accounts}) == 2
+ for account in accounts:
+ uuid.UUID(account['uuid'])
+ assert account['status'] == 'active'
+ assert account['source'] == 'local'
+ assert account['projection_revision'] == 0
+
+ workspace = (
+ (await conn.execute(sa.text('SELECT * FROM workspaces WHERE source = :source'), {'source': 'local'}))
+ .mappings()
+ .one()
+ )
+ assert workspace['instance_uuid'] == 'instance_migration_test'
+ assert workspace['slug'] == 'default'
+ assert workspace['status'] == 'active'
+ assert workspace['created_by_account_uuid'] == accounts[0]['uuid']
+
+ membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
+ assert membership['workspace_uuid'] == workspace['uuid']
+ assert membership['account_uuid'] == accounts[0]['uuid']
+ assert membership['role'] == 'owner'
+ assert membership['status'] == 'active'
+
+ execution_state = (await conn.execute(sa.text('SELECT * FROM workspace_execution_states'))).mappings().one()
+ assert execution_state['workspace_uuid'] == workspace['uuid']
+ assert execution_state['instance_uuid'] == 'instance_migration_test'
+ assert execution_state['active_generation'] == 1
+ assert execution_state['state'] == 'active'
+ assert execution_state['write_fenced'] in (False, 0)
+
+ assert await get_alembic_current(legacy_engine) == get_alembic_head()
+
+
+async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_engine):
+ await run_alembic_upgrade(legacy_engine, 'head')
+ async with legacy_engine.connect() as conn:
+ account_uuids_before = (await conn.execute(sa.text('SELECT uuid FROM users ORDER BY id'))).scalars().all()
+ workspace_uuid_before = (
+ await conn.execute(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
+ ).scalar_one()
+
+ await run_alembic_upgrade(legacy_engine, 'head')
+
+ async with legacy_engine.connect() as conn:
+ account_uuids_after = (await conn.execute(sa.text('SELECT uuid FROM users ORDER BY id'))).scalars().all()
+ workspace_uuid_after = (
+ await conn.execute(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
+ ).scalar_one()
+ assert account_uuids_after == account_uuids_before
+ assert workspace_uuid_after == workspace_uuid_before
+
+
+async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
+ try:
+ await _create_legacy_schema(engine)
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ await run_alembic_upgrade(engine, '0009_workspace_tenancy')
+ assert await get_alembic_current(engine) == '0009_workspace_tenancy'
+
+ await run_alembic_downgrade(engine, '0008_mcp_resource_prefs')
+ assert await get_alembic_current(engine) == '0008_mcp_resource_prefs'
+ async with engine.connect() as conn:
+ tables = set(await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names()))
+ user_columns = {
+ column['name']
+ for column in await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_columns('users'))
+ }
+ accounts = (await conn.execute(sa.text('SELECT user, password FROM users ORDER BY id'))).all()
+ assert (
+ not {
+ 'workspaces',
+ 'workspace_memberships',
+ 'workspace_invitations',
+ 'workspace_execution_states',
+ }
+ & tables
+ )
+ assert not {'uuid', 'status', 'source', 'projection_revision'} & user_columns
+ assert accounts == [
+ ('owner@example.com', 'owner-hash'),
+ ('member@example.com', 'member-hash'),
+ ]
+
+ await run_alembic_upgrade(engine, '0009_workspace_tenancy')
+ assert await get_alembic_current(engine) == '0009_workspace_tenancy'
+ async with engine.connect() as conn:
+ assert await conn.scalar(sa.text('SELECT COUNT(*) FROM workspaces')) == 1
+ assert await conn.scalar(sa.text('SELECT COUNT(*) FROM workspace_memberships')) == 1
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.parametrize(
+ ('raw_email', 'expected_email'),
+ [
+ ('Straße@Example.COM', 'strasse@example.com'),
+ ('Ꭰ@Example.COM', 'Ꭰ@example.com'),
+ ],
+)
+async def test_workspace_upgrade_uses_runtime_unicode_email_normalization(
+ tmp_path,
+ raw_email,
+ expected_email,
+):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "unicode-email.db"}')
+ try:
+ await _create_legacy_schema(engine, include_users=False)
+ async with engine.begin() as conn:
+ await conn.execute(
+ sa.text('INSERT INTO users (user, password, account_type) VALUES (:email, :password, :type)'),
+ {'email': raw_email, 'password': 'owner-hash', 'type': 'local'},
+ )
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ await run_alembic_upgrade(engine, 'head')
+
+ async with engine.connect() as conn:
+ assert await conn.scalar(sa.text('SELECT normalized_email FROM users')) == expected_email
+ finally:
+ await engine.dispose()
+
+
+async def test_fresh_sqlite_schema_accepts_application_casefold_identity(tmp_path):
+ importutil.import_modules_in_pkg(persistence)
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "fresh-unicode-email.db"}')
+ canonical_email = normalize_email('Ꭰ@Example.COM')
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ await conn.execute(
+ sa.insert(User).values(
+ uuid='00000000-0000-0000-0000-000000000099',
+ user=canonical_email,
+ normalized_email=canonical_email,
+ password='hash',
+ )
+ )
+ async with engine.connect() as conn:
+ assert await conn.scalar(sa.select(User.normalized_email)) == 'Ꭰ@example.com'
+ finally:
+ await engine.dispose()
+
+
+async def test_workspace_upgrade_rejects_unicode_casefold_duplicate_accounts(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "unicode-email-duplicate.db"}')
+ try:
+ await _create_legacy_schema(engine, include_users=False)
+ async with engine.begin() as conn:
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO users (user, password, account_type) VALUES '
+ "('Straße@Example.COM', 'first-hash', 'local'), "
+ "('STRASSE@example.com', 'second-hash', 'local')"
+ )
+ )
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ with pytest.raises(RuntimeError, match='both normalize'):
+ await run_alembic_upgrade(engine, 'head')
+ finally:
+ await engine.dispose()
+
+
+async def test_uninitialized_instance_gets_ownerless_default_workspace(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "uninitialized-instance.db"}')
+ try:
+ await _create_legacy_schema(engine, include_users=False)
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ await run_alembic_upgrade(engine, 'head')
+
+ async with engine.connect() as conn:
+ workspace = (await conn.execute(sa.text('SELECT * FROM workspaces'))).mappings().one()
+ membership_count = await conn.scalar(sa.text('SELECT COUNT(*) FROM workspace_memberships'))
+ execution_state = (await conn.execute(sa.text('SELECT * FROM workspace_execution_states'))).mappings().one()
+
+ assert workspace['created_by_account_uuid'] is None
+ assert membership_count == 0
+ assert execution_state['workspace_uuid'] == workspace['uuid']
+ assert execution_state['active_generation'] == 1
+ finally:
+ await engine.dispose()
+
+
+async def test_local_workspace_unique_index_allows_cloud_projections(legacy_engine):
+ await run_alembic_upgrade(legacy_engine, 'head')
+
+ async with legacy_engine.begin() as conn:
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO workspaces '
+ '(uuid, instance_uuid, name, slug, type, status, source, projection_revision) '
+ 'VALUES (:uuid, :instance_uuid, :name, :slug, :type, :status, :source, 0)'
+ ),
+ {
+ 'uuid': str(uuid.uuid4()),
+ 'instance_uuid': 'instance_migration_test',
+ 'name': 'Cloud Projection',
+ 'slug': 'cloud-projection',
+ 'type': 'team',
+ 'status': 'active',
+ 'source': 'cloud_projection',
+ },
+ )
+
+ with pytest.raises(IntegrityError):
+ async with legacy_engine.begin() as conn:
+ await conn.execute(
+ sa.text(
+ 'INSERT INTO workspaces '
+ '(uuid, instance_uuid, name, slug, type, status, source, projection_revision) '
+ 'VALUES (:uuid, :instance_uuid, :name, :slug, :type, :status, :source, 0)'
+ ),
+ {
+ 'uuid': str(uuid.uuid4()),
+ 'instance_uuid': 'instance_migration_test',
+ 'name': 'Second Local',
+ 'slug': 'second-local',
+ 'type': 'team',
+ 'status': 'active',
+ 'source': 'local',
+ },
+ )
+
+
+async def test_legacy_instance_without_bound_instance_uuid_fails_closed(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "missing-instance.db"}')
+ try:
+ await _create_legacy_schema(engine, include_instance_uuid=False)
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ with pytest.raises(RuntimeError, match='instance_uuid'):
+ await run_alembic_upgrade(engine, 'head')
+ finally:
+ await engine.dispose()
+
+
+async def test_persistence_startup_defers_workspace_tables_until_account_upgrade(tmp_path, monkeypatch):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "startup-order.db"}')
+ try:
+ await _create_legacy_schema(engine)
+ await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
+ monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
+
+ database = type('Database', (), {'get_engine': lambda self: engine})()
+ application = type('Application', (), {})()
+ application.logger = logging.getLogger('workspace-startup-test')
+ manager = PersistenceManager(application)
+ manager.db = database
+
+ await manager.create_tables()
+ async with engine.connect() as conn:
+ tables_before_migration = set(
+ await conn.run_sync(lambda sync_conn: sa.inspect(sync_conn).get_table_names())
+ )
+ assert 'workspaces' not in tables_before_migration
+
+ await manager._run_alembic_migrations()
+
+ async with engine.connect() as conn:
+ workspace = (
+ (await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
+ )
+ assert workspace['instance_uuid'] == 'instance_migration_test'
+ finally:
+ await engine.dispose()
+
+
+async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
+ try:
+ await _create_legacy_schema(engine)
+ monkeypatch.setattr(constants, 'instance_id', 'different_instance')
+
+ database = type('Database', (), {'get_engine': lambda self: engine})()
+ application = type('Application', (), {})()
+ application.logger = logging.getLogger('workspace-instance-drift-test')
+ manager = PersistenceManager(application)
+ manager.db = database
+
+ with pytest.raises(RuntimeError, match='does not match'):
+ await manager.create_tables()
+ finally:
+ await engine.dispose()
diff --git a/tests/integration/pipeline/test_full_flow.py b/tests/integration/pipeline/test_full_flow.py
index 767594c33..712fdd3b6 100644
--- a/tests/integration/pipeline/test_full_flow.py
+++ b/tests/integration/pipeline/test_full_flow.py
@@ -210,7 +210,15 @@ def pipeline_app():
mock_conversation.update_time = None
mock_conversation.create_time = None
- app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
+ async def get_scoped_session(query):
+ context = query._execution_context
+ mock_session.instance_uuid = context.instance_uuid
+ mock_session.workspace_uuid = context.workspace_uuid
+ mock_session.placement_generation = context.placement_generation
+ mock_session.bot_uuid = query.bot_uuid
+ return mock_session
+
+ app.sess_mgr.get_session = AsyncMock(side_effect=get_scoped_session)
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
# Model mock for PreProcessor
diff --git a/tests/integration_tests/box/test_box_integration.py b/tests/integration_tests/box/test_box_integration.py
index c20a1d87f..cda85815a 100644
--- a/tests/integration_tests/box/test_box_integration.py
+++ b/tests/integration_tests/box/test_box_integration.py
@@ -18,6 +18,7 @@ import shutil
import socket
import subprocess
from types import SimpleNamespace
+from unittest.mock import AsyncMock
import pytest
@@ -27,7 +28,8 @@ from langbot_plugin.box.client import ActionRPCBoxClient
from langbot_plugin.box.errors import BoxBackendUnavailableError
from langbot_plugin.box.models import BoxExecutionStatus, BoxNetworkMode, BoxSpec
from langbot_plugin.box.runtime import BoxRuntime
-from langbot_plugin.box.server import BoxServerHandler
+from langbot_plugin.box.server import BoxGenerationFence, BoxServerHandler
+from langbot_plugin.entities.io.context import ActionContext
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -35,6 +37,11 @@ _logger = logging.getLogger('test.box.integration')
# Default image for integration tests — small and fast to pull.
_TEST_IMAGE = 'alpine:latest'
+_ACTION_CONTEXT = ActionContext(
+ instance_uuid='box-integration-instance',
+ workspace_uuid='box-integration-workspace',
+ placement_generation=1,
+)
# ── Skip helpers ──────────────────────────────────────────────────────
@@ -97,6 +104,22 @@ class _QueueConnection:
pass
+class _TenantBoxClient(ActionRPCBoxClient):
+ async def _call(
+ self,
+ action,
+ data,
+ timeout=15.0,
+ action_context=None,
+ ):
+ return await super()._call(
+ action,
+ data,
+ timeout=timeout,
+ action_context=action_context or _ACTION_CONTEXT,
+ )
+
+
async def _make_rpc_pair(runtime: BoxRuntime):
"""Create an in-process (ActionRPCBoxClient, server_task, client_task) connected via queues."""
from langbot_plugin.runtime.io.handler import Handler
@@ -106,14 +129,20 @@ async def _make_rpc_pair(runtime: BoxRuntime):
client_conn = _QueueConnection(rx=s2c, tx=c2s)
server_conn = _QueueConnection(rx=c2s, tx=s2c)
- server_handler = BoxServerHandler(server_conn, runtime)
+ server_handler = BoxServerHandler(
+ server_conn,
+ runtime,
+ host_control_authenticated=True,
+ trusted_instance_uuid=_ACTION_CONTEXT.instance_uuid,
+ generation_fence=BoxGenerationFence(),
+ )
server_task = asyncio.create_task(server_handler.run())
client_handler = Handler.__new__(Handler)
Handler.__init__(client_handler, client_conn)
client_task = asyncio.create_task(client_handler.run())
- client = ActionRPCBoxClient(logger=_logger)
+ client = _TenantBoxClient(logger=_logger)
client.set_handler(client_handler)
return client, server_task, client_task
@@ -294,6 +323,16 @@ async def test_full_service_to_remote_runtime(tmp_path):
mock_ap = SimpleNamespace(
logger=_logger,
+ workspace_service=SimpleNamespace(
+ instance_uuid=_ACTION_CONTEXT.instance_uuid,
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid=_ACTION_CONTEXT.instance_uuid,
+ workspace_uuid=_ACTION_CONTEXT.workspace_uuid,
+ placement_generation=_ACTION_CONTEXT.placement_generation,
+ )
+ ),
+ ),
instance_config=SimpleNamespace(
data={
'box': {
@@ -313,7 +352,12 @@ async def test_full_service_to_remote_runtime(tmp_path):
service = BoxService(mock_ap, client=client)
await service.initialize()
- query = pipeline_query.Query.model_construct(query_id=42)
+ query = pipeline_query.Query.model_construct(
+ query_id=42,
+ instance_uuid=_ACTION_CONTEXT.instance_uuid,
+ workspace_uuid=_ACTION_CONTEXT.workspace_uuid,
+ placement_generation=_ACTION_CONTEXT.placement_generation,
+ )
result = await service.execute_tool(
{'command': 'echo service-path'},
query,
diff --git a/tests/integration_tests/box/test_box_mcp_integration.py b/tests/integration_tests/box/test_box_mcp_integration.py
index 2fcfcb934..325910a1b 100644
--- a/tests/integration_tests/box/test_box_mcp_integration.py
+++ b/tests/integration_tests/box/test_box_mcp_integration.py
@@ -23,14 +23,41 @@ import pytest
from aiohttp.test_utils import TestServer
from langbot_plugin.box.client import ActionRPCBoxClient
-from langbot_plugin.box.errors import BoxManagedProcessNotFoundError, BoxSessionNotFoundError
+from langbot_plugin.box.errors import (
+ BoxError,
+ BoxManagedProcessNotFoundError,
+ BoxSessionNotFoundError,
+)
from langbot_plugin.box.models import BoxManagedProcessSpec, BoxManagedProcessStatus, BoxSpec
from langbot_plugin.box.runtime import BoxRuntime
-from langbot_plugin.box.server import BoxServerHandler, create_ws_relay_app
+from langbot_plugin.box.security import (
+ BOX_CONTROL_TOKEN_HEADER,
+ BOX_INSTANCE_HEADER,
+ BOX_PLACEMENT_GENERATION_HEADER,
+ BOX_WORKSPACE_HEADER,
+)
+from langbot_plugin.box.server import (
+ BoxGenerationFence,
+ BoxServerHandler,
+ create_ws_relay_app,
+)
+from langbot_plugin.entities.io.context import ActionContext
_logger = logging.getLogger('test.box.mcp_integration')
_TEST_IMAGE = 'alpine:latest'
+_ACTION_CONTEXT = ActionContext(
+ instance_uuid='box-integration-instance',
+ workspace_uuid='box-integration-workspace',
+ placement_generation=1,
+)
+_CONTROL_TOKEN = 'box-integration-control-token-longer-than-32-bytes'
+_RELAY_HEADERS = {
+ BOX_CONTROL_TOKEN_HEADER: _CONTROL_TOKEN,
+ BOX_INSTANCE_HEADER: _ACTION_CONTEXT.instance_uuid,
+ BOX_WORKSPACE_HEADER: _ACTION_CONTEXT.workspace_uuid,
+ BOX_PLACEMENT_GENERATION_HEADER: str(_ACTION_CONTEXT.placement_generation),
+}
# ── Skip helpers ──────────────────────────────────────────────────────
@@ -89,7 +116,26 @@ class _QueueConnection:
pass
-async def _make_rpc_pair(runtime: BoxRuntime):
+class _TenantBoxClient(ActionRPCBoxClient):
+ async def _call(
+ self,
+ action,
+ data,
+ timeout=15.0,
+ action_context=None,
+ ):
+ return await super()._call(
+ action,
+ data,
+ timeout=timeout,
+ action_context=action_context or _ACTION_CONTEXT,
+ )
+
+
+async def _make_rpc_pair(
+ runtime: BoxRuntime,
+ generation_fence: BoxGenerationFence,
+):
"""Create an in-process RPC pair connected via queues."""
from langbot_plugin.runtime.io.handler import Handler
@@ -98,14 +144,20 @@ async def _make_rpc_pair(runtime: BoxRuntime):
client_conn = _QueueConnection(rx=s2c, tx=c2s)
server_conn = _QueueConnection(rx=c2s, tx=s2c)
- server_handler = BoxServerHandler(server_conn, runtime)
+ server_handler = BoxServerHandler(
+ server_conn,
+ runtime,
+ host_control_authenticated=True,
+ trusted_instance_uuid=_ACTION_CONTEXT.instance_uuid,
+ generation_fence=generation_fence,
+ )
server_task = asyncio.create_task(server_handler.run())
client_handler = Handler.__new__(Handler)
Handler.__init__(client_handler, client_conn)
client_task = asyncio.create_task(client_handler.run())
- client = ActionRPCBoxClient(logger=_logger)
+ client = _TenantBoxClient(logger=_logger)
client.set_handler(client_handler)
return client, server_task, client_task
@@ -119,13 +171,22 @@ async def box_server():
"""Yield a (ws_relay_url, ActionRPCBoxClient) backed by a real BoxRuntime."""
runtime = BoxRuntime(logger=_logger)
await runtime.initialize()
+ generation_fence = BoxGenerationFence()
# Start ws relay for managed process attach
- ws_app = create_ws_relay_app(runtime)
+ ws_app = create_ws_relay_app(
+ runtime,
+ control_token=_CONTROL_TOKEN,
+ trusted_instance_uuid=_ACTION_CONTEXT.instance_uuid,
+ generation_fence=generation_fence,
+ )
ws_server = TestServer(ws_app)
await ws_server.start_server()
- client, server_task, client_task = await _make_rpc_pair(runtime)
+ client, server_task, client_task = await _make_rpc_pair(
+ runtime,
+ generation_fence,
+ )
ws_relay_url = str(ws_server.make_url(''))
yield ws_relay_url, client
@@ -207,10 +268,14 @@ async def test_ws_stdio_attach_echo(box_server):
await client.start_managed_process('mcp-int-ws', proc_spec)
# Connect via WebSocket (ws relay)
- ws_url = client.get_managed_process_websocket_url('mcp-int-ws', ws_relay_url)
+ ws_url = client.get_managed_process_websocket_url(
+ 'mcp-int-ws',
+ ws_relay_url,
+ action_context=_ACTION_CONTEXT,
+ )
session = aiohttp.ClientSession()
try:
- async with session.ws_connect(ws_url) as ws:
+ async with session.ws_connect(ws_url, headers=_RELAY_HEADERS) as ws:
# Send a line
await ws.send_str('hello from test')
@@ -224,6 +289,45 @@ async def test_ws_stdio_attach_echo(box_server):
await client.delete_session('mcp-int-ws')
+@requires_container
+@requires_socket
+@pytest.mark.asyncio
+async def test_ws_stdio_attach_closes_on_generation_advance(box_server):
+ """A real attached relay is revoked by the next placement RPC."""
+
+ ws_relay_url, client = box_server
+ spec = BoxSpec(
+ cmd='',
+ session_id='mcp-int-generation',
+ workdir='/tmp',
+ image=_TEST_IMAGE,
+ )
+ await client.create_session(spec)
+ await client.start_managed_process(
+ 'mcp-int-generation',
+ BoxManagedProcessSpec(command='cat', args=[], cwd='/tmp'),
+ )
+ ws_url = client.get_managed_process_websocket_url(
+ 'mcp-int-generation',
+ ws_relay_url,
+ action_context=_ACTION_CONTEXT,
+ )
+ second_context = _ACTION_CONTEXT.model_copy(update={'placement_generation': 2})
+
+ async with aiohttp.ClientSession() as session:
+ async with session.ws_connect(ws_url, headers=_RELAY_HEADERS) as ws:
+ assert await client.get_sessions(action_context=second_context) == []
+ close_message = await asyncio.wait_for(ws.receive(), timeout=5)
+ assert close_message.type in {
+ aiohttp.WSMsgType.CLOSE,
+ aiohttp.WSMsgType.CLOSING,
+ aiohttp.WSMsgType.CLOSED,
+ }
+
+ with pytest.raises(BoxError, match='Stale Box placement generation'):
+ await client.get_sessions(action_context=_ACTION_CONTEXT)
+
+
# ── 3. Session cleanup removes container ─────────────────────────────
diff --git a/tests/integration_tests/box/test_cloud_box_admission_integration.py b/tests/integration_tests/box/test_cloud_box_admission_integration.py
new file mode 100644
index 000000000..96610d511
--- /dev/null
+++ b/tests/integration_tests/box/test_cloud_box_admission_integration.py
@@ -0,0 +1,415 @@
+from __future__ import annotations
+
+import asyncio
+import datetime as dt
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from langbot_plugin.box.backend import BaseSandboxBackend
+from langbot_plugin.box.client import ActionRPCBoxClient
+from langbot_plugin.box.errors import BoxAdmissionError
+from langbot_plugin.box.models import (
+ BoxExecutionResult,
+ BoxExecutionStatus,
+ BoxNetworkMode,
+ BoxSessionInfo,
+ BoxSpec,
+)
+from langbot_plugin.box.runtime import BoxRuntime
+from langbot_plugin.box.server import BoxServerHandler
+from langbot_plugin.runtime.io.handler import Handler
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.box.service import BoxService
+from langbot.pkg.cloud.entitlements import (
+ EntitlementResolver,
+ EntitlementSnapshot,
+ EntitlementUnavailableError,
+)
+
+
+pytestmark = pytest.mark.integration
+_UTC = dt.timezone.utc
+
+
+class _AdmissionBackend(BaseSandboxBackend):
+ name = 'nsjail'
+
+ def __init__(self, logger):
+ super().__init__(logger)
+ self.started_specs: list[BoxSpec] = []
+ self.stopped_sessions: list[str] = []
+
+ async def is_available(self) -> bool:
+ return True
+
+ async def get_readiness(self, *, workspace_path=None, strict=False) -> dict:
+ return {
+ 'available': True,
+ 'cgroup_v2': True,
+ 'namespace_isolation': True,
+ 'mount_isolation': True,
+ 'network_isolation': True,
+ 'hard_workspace_quota': True,
+ 'hard_skill_storage_quota': True,
+ 'bounded_ephemeral_storage': True,
+ 'inode_quota': True,
+ }
+
+ async def start_session(self, spec: BoxSpec) -> BoxSessionInfo:
+ self.started_specs.append(spec)
+ now = dt.datetime.now(_UTC)
+ return BoxSessionInfo(
+ session_id=spec.session_id,
+ backend_name=self.name,
+ backend_session_id=f'jail-{len(self.started_specs)}',
+ image=spec.image,
+ network=spec.network,
+ host_path=spec.host_path,
+ host_path_mode=spec.host_path_mode,
+ mount_path=spec.mount_path,
+ persistent=spec.persistent,
+ cpus=spec.cpus,
+ memory_mb=spec.memory_mb,
+ pids_limit=spec.pids_limit,
+ read_only_rootfs=spec.read_only_rootfs,
+ workspace_quota_mb=spec.workspace_quota_mb,
+ created_at=now,
+ last_used_at=now,
+ )
+
+ async def exec(self, session: BoxSessionInfo, spec: BoxSpec) -> BoxExecutionResult:
+ await asyncio.sleep(0)
+ return BoxExecutionResult(
+ session_id=session.session_id,
+ backend_name=self.name,
+ status=BoxExecutionStatus.COMPLETED,
+ exit_code=0,
+ stdout=spec.cmd,
+ stderr='',
+ duration_ms=1,
+ )
+
+ async def stop_session(self, session: BoxSessionInfo):
+ self.stopped_sessions.append(session.session_id)
+
+
+class _QueueConnection:
+ def __init__(self, rx: asyncio.Queue[str], tx: asyncio.Queue[str]):
+ self._rx = rx
+ self._tx = tx
+
+ async def send(self, message: str) -> None:
+ await self._tx.put(message)
+
+ async def receive(self) -> str:
+ return await self._rx.get()
+
+ async def close(self) -> None:
+ return None
+
+
+async def _rpc_client(runtime: BoxRuntime):
+ client_to_server: asyncio.Queue[str] = asyncio.Queue()
+ server_to_client: asyncio.Queue[str] = asyncio.Queue()
+ client_connection = _QueueConnection(server_to_client, client_to_server)
+ server_connection = _QueueConnection(client_to_server, server_to_client)
+ server_handler = BoxServerHandler(
+ server_connection,
+ runtime,
+ host_control_authenticated=True,
+ trusted_instance_uuid='instance-a',
+ )
+ server_task = asyncio.create_task(server_handler.run())
+ client_handler = Handler(client_connection)
+ client_task = asyncio.create_task(client_handler.run())
+ client = ActionRPCBoxClient(logger=Mock())
+ client.set_handler(client_handler)
+ return client, server_task, client_task
+
+
+class _Entitlements:
+ def __init__(self):
+ self.snapshots: dict[str, EntitlementSnapshot] = {}
+
+ async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
+ return self.snapshots[workspace_uuid]
+
+
+def _snapshot(workspace_uuid: str, *, revision: int = 1, managed: bool = True) -> EntitlementSnapshot:
+ return EntitlementSnapshot(
+ instance_uuid='instance-a',
+ workspace_uuid=workspace_uuid,
+ entitlement_revision=revision,
+ status='active',
+ not_before=1,
+ expires_at=4_000_000_000,
+ features={'managed_sandbox': managed},
+ limits={'managed_sandbox_sessions': 1 if managed else 0},
+ )
+
+
+def _context(workspace_uuid: str, *, revision: int = 1) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid=workspace_uuid,
+ placement_generation=1,
+ entitlement_revision=revision,
+ )
+
+
+def _query(context: ExecutionContext, query_id: int):
+ query = pipeline_query.Query.model_construct(
+ query_id=query_id,
+ bot_uuid='bot-a',
+ pipeline_uuid='pipeline-a',
+ launcher_type='person',
+ launcher_id=f'user-{query_id}',
+ variables={},
+ )
+ object.__setattr__(query, 'instance_uuid', context.instance_uuid)
+ object.__setattr__(query, 'workspace_uuid', context.workspace_uuid)
+ object.__setattr__(query, 'placement_generation', context.placement_generation)
+ object.__setattr__(query, '_execution_context', context)
+ return query
+
+
+async def _stack(tmp_path):
+ shared_root = tmp_path / 'shared-box'
+ workspace_root = shared_root / 'workspaces'
+ workspace_root.mkdir(parents=True)
+ box_config = {
+ 'enabled': True,
+ 'backend': 'nsjail',
+ 'runtime': {'endpoint': 'ws://langbot-box:5410'},
+ 'local': {
+ 'profile': 'default',
+ 'host_root': str(shared_root),
+ 'default_workspace': str(workspace_root),
+ 'allowed_mount_roots': [str(shared_root)],
+ },
+ 'admission': {
+ 'required': True,
+ 'logical_session_id': 'global',
+ 'required_backend': 'nsjail',
+ 'max_sessions': 1,
+ 'max_managed_processes': 0,
+ 'max_grant_ttl_sec': 300,
+ 'max_timeout_sec': 60,
+ 'cpus': 0.5,
+ 'memory_mb': 256,
+ 'pids_limit': 64,
+ 'read_only_rootfs': True,
+ 'workspace_quota_mb': 32,
+ 'readiness_cache_sec': 0,
+ },
+ }
+ logger = Mock()
+ backend = _AdmissionBackend(logger)
+ runtime = BoxRuntime(logger, backends=[backend])
+ runtime.init(box_config)
+ await runtime.initialize()
+ client, server_task, client_task = await _rpc_client(runtime)
+
+ entitlements = _Entitlements()
+ workspace_service = SimpleNamespace(
+ instance_uuid='instance-a',
+ get_execution_binding=AsyncMock(
+ side_effect=lambda workspace_uuid, expected_generation: SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid=workspace_uuid,
+ placement_generation=expected_generation,
+ )
+ ),
+ )
+ app = SimpleNamespace(
+ logger=logger,
+ deployment=SimpleNamespace(multi_workspace_enabled=True),
+ entitlement_resolver=EntitlementResolver('instance-a', entitlements),
+ workspace_service=workspace_service,
+ instance_config=SimpleNamespace(data={'box': box_config, 'system': {'limitation': {}}}),
+ )
+ service = BoxService(app, client=client)
+ await service.initialize()
+ return service, runtime, backend, entitlements, server_task, client_task
+
+
+@pytest.mark.asyncio
+async def test_concurrent_first_use_creates_one_persistent_global_session(tmp_path):
+ service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
+ context = _context('workspace-a')
+ entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid)
+ try:
+ first, second = await asyncio.gather(
+ service.execute_tool({'command': 'echo first'}, _query(context, 1)),
+ service.execute_tool({'command': 'echo second'}, _query(context, 2)),
+ )
+
+ assert first['session_id'] == 'global'
+ assert second['session_id'] == 'global'
+ assert len(backend.started_specs) == 1
+ spec = backend.started_specs[0]
+ assert spec.persistent is True
+ assert spec.network == BoxNetworkMode.OFF
+ assert spec.cpus == 0.5
+ assert spec.memory_mb == 256
+ assert spec.pids_limit == 64
+ assert spec.workspace_quota_mb == 32
+ finally:
+ server_task.cancel()
+ client_task.cancel()
+ await runtime.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_entitlement_loss_revokes_and_closes_existing_global_session(tmp_path):
+ service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
+ context = _context('workspace-a')
+ entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid, revision=1)
+ try:
+ await service.execute_tool({'command': 'true'}, _query(context, 1))
+ assert len(runtime.get_sessions()) == 1
+
+ entitlements.snapshots[context.workspace_uuid] = _snapshot(
+ context.workspace_uuid,
+ revision=2,
+ managed=False,
+ )
+ with pytest.raises(EntitlementUnavailableError):
+ await service.execute_tool({'command': 'true'}, _query(context, 2))
+
+ assert runtime.get_sessions() == []
+ assert len(backend.stopped_sessions) == 1
+ finally:
+ server_task.cancel()
+ client_task.cancel()
+ await runtime.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path):
+ service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
+ first = _context('workspace-a')
+ second = _context('workspace-b')
+ entitlements.snapshots[first.workspace_uuid] = _snapshot(first.workspace_uuid)
+ entitlements.snapshots[second.workspace_uuid] = _snapshot(second.workspace_uuid)
+ try:
+ result_a = await service.execute_tool({'command': 'tenant-a'}, _query(first, 1))
+ result_b = await service.execute_tool({'command': 'tenant-b'}, _query(second, 2))
+
+ assert result_a['session_id'] == result_b['session_id'] == 'global'
+ assert len(backend.started_specs) == 2
+ assert backend.started_specs[0].session_id != backend.started_specs[1].session_id
+ assert backend.started_specs[0].host_path != backend.started_specs[1].host_path
+ assert len(runtime.get_sessions()) == 2
+ finally:
+ server_task.cancel()
+ client_task.cancel()
+ await runtime.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tmp_path):
+ service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
+ first = _context('workspace-a')
+ second = _context('workspace-b')
+ ineligible = _context('workspace-free')
+ entitlements.snapshots[first.workspace_uuid] = _snapshot(first.workspace_uuid)
+ entitlements.snapshots[second.workspace_uuid] = _snapshot(second.workspace_uuid)
+ entitlements.snapshots[ineligible.workspace_uuid] = _snapshot(
+ ineligible.workspace_uuid,
+ managed=False,
+ )
+ try:
+ private = await service.create_skill(
+ second,
+ {
+ 'name': 'private',
+ 'instructions': 'workspace-b secret',
+ },
+ )
+ own_skill = await service.create_skill(
+ first,
+ {
+ 'name': 'runner',
+ 'instructions': 'Run scripts/main.py',
+ },
+ )
+ await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
+ await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
+ refreshed_skill = await service.get_skill(first, 'runner')
+ assert refreshed_skill is not None
+ assert refreshed_skill['python_project'] is True
+ await service.execute_tool(
+ {
+ 'command': 'python /workspace/.skills/runner/scripts/main.py',
+ 'workdir': '/workspace/.skills/runner',
+ },
+ _query(first, 91),
+ skill_name='runner',
+ )
+
+ mounted_spec = backend.started_specs[-1]
+ assert len(mounted_spec.extra_mounts) == 1
+ assert mounted_spec.extra_mounts[0].host_path == own_skill['package_root']
+ assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
+ assert mounted_spec.extra_mounts[0].mode.value == 'ro'
+
+ with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'):
+ await service.scan_skill_directory(first, private['package_root'])
+ with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'):
+ await service.create_skill(
+ first,
+ {
+ 'name': 'stolen',
+ 'package_root': private['package_root'],
+ },
+ )
+
+ assert await service.get_skill(first, 'private') is None
+ with pytest.raises(EntitlementUnavailableError):
+ await service.list_skills(ineligible)
+ finally:
+ server_task.cancel()
+ client_task.cancel()
+ await runtime.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_forged_plan_network_session_and_managed_process_never_reach_runtime(tmp_path):
+ service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
+ context = _context('workspace-a')
+ entitlements.snapshots[context.workspace_uuid] = _snapshot(context.workspace_uuid)
+ query = _query(context, 1)
+ try:
+ with pytest.raises(BoxAdmissionError, match='host-controlled'):
+ await service.execute_spec_payload(
+ {'cmd': 'true', 'session_id': 'global', 'plan': 'pro'},
+ query,
+ )
+ with pytest.raises(BoxAdmissionError, match='network access is disabled'):
+ await service.execute_spec_payload(
+ {'cmd': 'true', 'session_id': 'global', 'network': 'on'},
+ query,
+ )
+ with pytest.raises(BoxAdmissionError, match='session_id is runtime-owned'):
+ await service.execute_spec_payload(
+ {'cmd': 'true', 'session_id': 'attacker'},
+ query,
+ )
+ with pytest.raises(BoxAdmissionError, match='Managed processes are disabled'):
+ await service.start_managed_process(
+ context,
+ 'global',
+ {'command': 'sleep', 'args': ['60']},
+ )
+
+ assert backend.started_specs == []
+ assert runtime.get_sessions() == []
+ finally:
+ server_task.cancel()
+ client_task.cancel()
+ await runtime.shutdown()
diff --git a/tests/manual/mcp_smoke.py b/tests/manual/mcp_smoke.py
index 197724fff..0f0081a7c 100644
--- a/tests/manual/mcp_smoke.py
+++ b/tests/manual/mcp_smoke.py
@@ -18,6 +18,8 @@ from hypercorn.config import Config
from quart import Quart
from langbot.pkg.api.mcp.mount import MCPMount
+from langbot.pkg.api.http.authz import Permission
+from langbot.pkg.api.http.service.apikey import ApiKeyIdentity
PORT = 5399
GLOBAL_KEY = 'test-global-key-123'
@@ -31,11 +33,26 @@ def build_ap() -> SimpleNamespace:
ap.ver_mgr = SimpleNamespace(get_current_version=lambda: '4.5.0-test')
ap.logger = SimpleNamespace(info=print, error=print, warning=print)
- # API key verification: reuse real logic shape (global key match)
- async def verify_api_key(key: str) -> bool:
- return bool(key) and key == GLOBAL_KEY
+ # API key authentication derives the trusted Workspace carried into tools.
+ async def authenticate_api_key(key: str) -> ApiKeyIdentity | None:
+ if key != GLOBAL_KEY:
+ return None
+ return ApiKeyIdentity(
+ instance_uuid='inst-1',
+ workspace_uuid='workspace-1',
+ placement_generation=1,
+ api_key_uuid='global-test-key',
+ permissions=frozenset(permission.value for permission in Permission),
+ )
- ap.apikey_service = SimpleNamespace(verify_api_key=verify_api_key)
+ ap.apikey_service = SimpleNamespace(authenticate_api_key=authenticate_api_key)
+
+ @contextlib.asynccontextmanager
+ async def tenant_scope(workspace_uuid: str):
+ assert workspace_uuid == 'workspace-1'
+ yield
+
+ ap.persistence_mgr = SimpleNamespace(tenant_scope=tenant_scope)
ap.bot_service = SimpleNamespace(
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}])
)
diff --git a/tests/unit_tests/api/http/service/test_bot_service.py b/tests/unit_tests/api/http/service/test_bot_service.py
index 6fdc2342f..5bfacec5a 100644
--- a/tests/unit_tests/api/http/service/test_bot_service.py
+++ b/tests/unit_tests/api/http/service/test_bot_service.py
@@ -6,6 +6,9 @@ from sqlalchemy.sql.dml import Update
from langbot.pkg.api.http.service.bot import BotService
+WORKSPACE_UUID = 'workspace-a'
+
+
class _FakeResult:
def __init__(self, value):
self.value = value
@@ -21,7 +24,9 @@ class _PersistenceManager:
async def execute_async(self, statement):
if isinstance(statement, Update):
self.update_values = {
- key: value for key, value in statement.compile().params.items() if not key.startswith('uuid_')
+ key: value
+ for key, value in statement.compile().params.items()
+ if not key.startswith(('uuid_', 'workspace_uuid_'))
}
return None
@@ -48,7 +53,7 @@ async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_nam
'use_pipeline_uuid': 'pipeline-1',
}
- await service.update_bot('bot-1', payload)
+ await service.update_bot(WORKSPACE_UUID, 'bot-1', payload)
assert payload == {
'uuid': 'caller-owned-uuid',
diff --git a/tests/unit_tests/api/http/service/test_tenant.py b/tests/unit_tests/api/http/service/test_tenant.py
new file mode 100644
index 000000000..b0ed29e1c
--- /dev/null
+++ b/tests/unit_tests/api/http/service/test_tenant.py
@@ -0,0 +1,34 @@
+import pytest
+import sqlalchemy
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
+from langbot.pkg.api.http.service.tenant import require_workspace_uuid, scope_statement
+
+
+class _TenantRow:
+ workspace_uuid = sqlalchemy.column('workspace_uuid')
+
+
+def test_require_workspace_uuid_accepts_execution_context():
+ context = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ )
+
+ assert require_workspace_uuid(context) == 'workspace-test'
+
+
+@pytest.mark.parametrize('context', [None, '', ' '])
+def test_require_workspace_uuid_rejects_missing_context(context):
+ with pytest.raises(WorkspaceRequiredError):
+ require_workspace_uuid(context)
+
+
+def test_scope_statement_adds_workspace_predicate():
+ statement = scope_statement(sqlalchemy.select(_TenantRow.workspace_uuid), _TenantRow, 'workspace-test')
+
+ assert 'workspace_uuid = :workspace_uuid_1' in str(statement)
+ assert statement.compile().params == {'workspace_uuid_1': 'workspace-test'}
diff --git a/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py b/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py
new file mode 100644
index 000000000..0dc7bb5e2
--- /dev/null
+++ b/tests/unit_tests/api/http/service/test_tenant_resource_isolation.py
@@ -0,0 +1,373 @@
+from __future__ import annotations
+
+import datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.service.bot import BotService
+from langbot.pkg.api.http.service.model import LLMModelsService
+from langbot.pkg.api.http.service.pipeline import PipelineService
+from langbot.pkg.api.http.service.provider import ModelProviderService
+from langbot.pkg.api.http.service.tenant import require_workspace_uuid
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.bot import Bot
+from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
+from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
+from langbot.pkg.entity.persistence.workspace import Workspace
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
+
+
+pytestmark = pytest.mark.asyncio
+
+WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
+
+
+class _PersistenceManager:
+ def __init__(self, engine):
+ self.engine = engine
+
+ async def execute_async(self, *args, **kwargs):
+ async with self.engine.connect() as connection:
+ result = await connection.execute(*args, **kwargs)
+ await connection.commit()
+ return result
+
+ @staticmethod
+ def serialize_model(model, data, masked_columns=None):
+ masked_columns = masked_columns or []
+ return {
+ column.name: (
+ getattr(data, column.name).isoformat()
+ if isinstance(getattr(data, column.name), datetime.datetime)
+ else getattr(data, column.name)
+ )
+ for column in model.__table__.columns
+ if column.name not in masked_columns
+ }
+
+
+@pytest.fixture
+async def tenant_services(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "tenant-resources.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ await connection.execute(
+ sqlalchemy.insert(Workspace),
+ [
+ {
+ 'uuid': WORKSPACE_A,
+ 'instance_uuid': 'instance-a',
+ 'name': 'Workspace A',
+ 'slug': 'workspace-a',
+ 'source': 'cloud_projection',
+ },
+ {
+ 'uuid': WORKSPACE_B,
+ 'instance_uuid': 'instance-b',
+ 'name': 'Workspace B',
+ 'slug': 'workspace-b',
+ 'source': 'cloud_projection',
+ },
+ ],
+ )
+ await connection.execute(
+ sqlalchemy.insert(ModelProvider),
+ [
+ {
+ 'uuid': 'provider-a',
+ 'workspace_uuid': WORKSPACE_A,
+ 'name': 'Same Provider',
+ 'requester': 'chatcmpl',
+ 'base_url': 'https://a.invalid',
+ 'api_keys': ['secret-a'],
+ },
+ {
+ 'uuid': 'provider-b',
+ 'workspace_uuid': WORKSPACE_B,
+ 'name': 'Same Provider',
+ 'requester': 'chatcmpl',
+ 'base_url': 'https://b.invalid',
+ 'api_keys': ['secret-b'],
+ },
+ ],
+ )
+ await connection.execute(
+ sqlalchemy.insert(LLMModel),
+ [
+ {
+ 'uuid': 'model-a',
+ 'workspace_uuid': WORKSPACE_A,
+ 'name': 'Same Model',
+ 'provider_uuid': 'provider-a',
+ 'abilities': [],
+ 'extra_args': {},
+ 'prefered_ranking': 0,
+ },
+ {
+ 'uuid': 'model-b',
+ 'workspace_uuid': WORKSPACE_B,
+ 'name': 'Same Model',
+ 'provider_uuid': 'provider-b',
+ 'abilities': [],
+ 'extra_args': {},
+ 'prefered_ranking': 0,
+ },
+ ],
+ )
+ await connection.execute(
+ sqlalchemy.insert(LegacyPipeline),
+ [
+ {
+ 'uuid': 'pipeline-a',
+ 'workspace_uuid': WORKSPACE_A,
+ 'name': 'Same Pipeline',
+ 'description': 'A',
+ 'for_version': 'test',
+ 'is_default': False,
+ 'stages': [],
+ 'config': {},
+ 'extensions_preferences': {},
+ },
+ {
+ 'uuid': 'pipeline-b',
+ 'workspace_uuid': WORKSPACE_B,
+ 'name': 'Same Pipeline',
+ 'description': 'B',
+ 'for_version': 'test',
+ 'is_default': False,
+ 'stages': [],
+ 'config': {},
+ 'extensions_preferences': {},
+ },
+ ],
+ )
+ await connection.execute(
+ sqlalchemy.insert(Bot),
+ [
+ {
+ 'uuid': 'bot-a',
+ 'workspace_uuid': WORKSPACE_A,
+ 'name': 'Same Bot',
+ 'description': 'A',
+ 'adapter': 'test',
+ 'adapter_config': {},
+ 'enable': False,
+ 'use_pipeline_uuid': 'pipeline-a',
+ 'use_pipeline_name': 'Same Pipeline',
+ 'pipeline_routing_rules': [],
+ },
+ {
+ 'uuid': 'bot-b',
+ 'workspace_uuid': WORKSPACE_B,
+ 'name': 'Same Bot',
+ 'description': 'B',
+ 'adapter': 'test',
+ 'adapter_config': {},
+ 'enable': False,
+ 'use_pipeline_uuid': 'pipeline-b',
+ 'use_pipeline_name': 'Same Pipeline',
+ 'pipeline_routing_rules': [],
+ },
+ ],
+ )
+
+ runtime_provider_a = SimpleNamespace(provider_entity=SimpleNamespace(uuid='provider-a'))
+ runtime_provider_b = SimpleNamespace(provider_entity=SimpleNamespace(uuid='provider-b'))
+ application = SimpleNamespace(
+ persistence_mgr=_PersistenceManager(engine),
+ instance_config=SimpleNamespace(data={'system': {'limitation': {}}, 'api': {}}),
+ ver_mgr=SimpleNamespace(get_current_version=lambda: 'test'),
+ platform_mgr=SimpleNamespace(
+ load_bot=AsyncMock(return_value=SimpleNamespace(enable=False)),
+ remove_bot=AsyncMock(),
+ get_bot_by_uuid=AsyncMock(return_value=None),
+ ),
+ pipeline_mgr=SimpleNamespace(
+ load_pipeline=AsyncMock(),
+ remove_pipeline=AsyncMock(),
+ ),
+ model_mgr=SimpleNamespace(
+ provider_dict={'provider-a': runtime_provider_a, 'provider-b': runtime_provider_b},
+ llm_models=[],
+ embedding_models=[],
+ rerank_models=[],
+ load_provider=AsyncMock(),
+ cache_provider=AsyncMock(),
+ get_provider_by_uuid=AsyncMock(return_value=runtime_provider_a),
+ reload_provider=AsyncMock(),
+ remove_provider=AsyncMock(),
+ load_llm_model_with_provider=AsyncMock(return_value=SimpleNamespace()),
+ cache_llm_model=AsyncMock(),
+ remove_llm_model=AsyncMock(),
+ ),
+ sess_mgr=SimpleNamespace(session_list=[]),
+ )
+ application.provider_service = ModelProviderService(application)
+ application.llm_model_service = LLMModelsService(application)
+ application.pipeline_service = PipelineService(application)
+ application.bot_service = BotService(application)
+
+ yield application, engine
+ await engine.dispose()
+
+
+async def test_context_is_mandatory_and_fails_closed(tenant_services):
+ application, _engine = tenant_services
+
+ with pytest.raises(WorkspaceRequiredError):
+ require_workspace_uuid(None)
+ with pytest.raises(WorkspaceRequiredError):
+ await application.bot_service.get_bots(None)
+ with pytest.raises(WorkspaceRequiredError):
+ await application.provider_service.get_providers(None)
+ with pytest.raises(WorkspaceRequiredError):
+ await application.pipeline_service.get_pipelines(None)
+ with pytest.raises(WorkspaceRequiredError):
+ await application.llm_model_service.get_llm_models(None)
+
+
+async def test_lists_and_same_names_are_isolated(tenant_services):
+ application, _engine = tenant_services
+
+ assert [item['uuid'] for item in await application.bot_service.get_bots(WORKSPACE_A)] == ['bot-a']
+ assert [item['uuid'] for item in await application.pipeline_service.get_pipelines(WORKSPACE_A)] == ['pipeline-a']
+ assert [item['uuid'] for item in await application.provider_service.get_providers(WORKSPACE_A)] == ['provider-a']
+ assert [item['uuid'] for item in await application.llm_model_service.get_llm_models(WORKSPACE_A)] == ['model-a']
+
+
+async def test_cross_workspace_uuid_guessing_cannot_read_update_or_delete(tenant_services):
+ application, engine = tenant_services
+
+ assert await application.bot_service.get_bot(WORKSPACE_A, 'bot-b') is None
+ assert await application.pipeline_service.get_pipeline(WORKSPACE_A, 'pipeline-b') is None
+ assert await application.provider_service.get_provider(WORKSPACE_A, 'provider-b') is None
+ assert await application.llm_model_service.get_llm_model(WORKSPACE_A, 'model-b') is None
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.bot_service.update_bot(WORKSPACE_A, 'bot-b', {'name': 'stolen'})
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.pipeline_service.update_pipeline(
+ WORKSPACE_A,
+ 'pipeline-b',
+ {'description': 'stolen'},
+ )
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.provider_service.update_provider(WORKSPACE_A, 'provider-b', {'name': 'stolen'})
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.llm_model_service.update_llm_model(
+ WORKSPACE_A,
+ 'model-b',
+ {'name': 'stolen'},
+ )
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.bot_service.delete_bot(WORKSPACE_A, 'bot-b')
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.pipeline_service.delete_pipeline(WORKSPACE_A, 'pipeline-b')
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.provider_service.delete_provider(WORKSPACE_A, 'provider-b')
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.llm_model_service.delete_llm_model(WORKSPACE_A, 'model-b')
+
+ async with engine.connect() as connection:
+ assert await connection.scalar(sqlalchemy.select(Bot.name).where(Bot.uuid == 'bot-b')) == 'Same Bot'
+ assert (
+ await connection.scalar(sqlalchemy.select(LegacyPipeline.uuid).where(LegacyPipeline.uuid == 'pipeline-b'))
+ == 'pipeline-b'
+ )
+ assert (
+ await connection.scalar(sqlalchemy.select(ModelProvider.name).where(ModelProvider.uuid == 'provider-b'))
+ == 'Same Provider'
+ )
+ assert await connection.scalar(sqlalchemy.select(LLMModel.uuid).where(LLMModel.uuid == 'model-b')) == 'model-b'
+
+
+async def test_cross_workspace_parent_references_are_rejected(tenant_services):
+ application, _engine = tenant_services
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.bot_service.update_bot(
+ WORKSPACE_A,
+ 'bot-a',
+ {'use_pipeline_uuid': 'pipeline-b'},
+ )
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await application.llm_model_service.create_llm_model(
+ WORKSPACE_A,
+ {
+ 'name': 'Cross reference',
+ 'provider_uuid': 'provider-b',
+ 'abilities': [],
+ 'extra_args': {},
+ 'prefered_ranking': 0,
+ },
+ auto_set_to_default_pipeline=False,
+ )
+
+
+async def test_created_resources_are_bound_to_callers_workspace(tenant_services):
+ application, engine = tenant_services
+
+ runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(uuid='provider-created'))
+ application.model_mgr.load_provider.return_value = runtime_provider
+ provider_uuid = await application.provider_service.create_provider(
+ WORKSPACE_A,
+ {
+ 'name': 'Created Provider',
+ 'requester': 'chatcmpl',
+ 'base_url': 'https://created.invalid',
+ 'api_keys': [],
+ },
+ )
+ pipeline_uuid = await application.pipeline_service.create_pipeline(
+ WORKSPACE_A,
+ {'name': 'Created Pipeline', 'description': 'created'},
+ )
+ bot_uuid = await application.bot_service.create_bot(
+ WORKSPACE_A,
+ {
+ 'name': 'Created Bot',
+ 'description': 'created',
+ 'adapter': 'test',
+ 'adapter_config': {},
+ 'enable': False,
+ 'pipeline_routing_rules': [],
+ },
+ )
+ model_uuid = await application.llm_model_service.create_llm_model(
+ WORKSPACE_A,
+ {
+ 'name': 'Created Model',
+ 'provider_uuid': 'provider-a',
+ 'abilities': [],
+ 'extra_args': {},
+ 'prefered_ranking': 0,
+ },
+ auto_set_to_default_pipeline=False,
+ )
+
+ async with engine.connect() as connection:
+ assert (
+ await connection.scalar(
+ sqlalchemy.select(ModelProvider.workspace_uuid).where(ModelProvider.uuid == provider_uuid)
+ )
+ == WORKSPACE_A
+ )
+ assert (
+ await connection.scalar(
+ sqlalchemy.select(LegacyPipeline.workspace_uuid).where(LegacyPipeline.uuid == pipeline_uuid)
+ )
+ == WORKSPACE_A
+ )
+ assert await connection.scalar(sqlalchemy.select(Bot.workspace_uuid).where(Bot.uuid == bot_uuid)) == WORKSPACE_A
+ assert (
+ await connection.scalar(sqlalchemy.select(LLMModel.workspace_uuid).where(LLMModel.uuid == model_uuid))
+ == WORKSPACE_A
+ )
diff --git a/tests/unit_tests/api/http/test_authz.py b/tests/unit_tests/api/http/test_authz.py
new file mode 100644
index 000000000..705891a16
--- /dev/null
+++ b/tests/unit_tests/api/http/test_authz.py
@@ -0,0 +1,74 @@
+from langbot.pkg.api.http import authz
+from langbot.pkg.api.http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
+
+
+def _context(role: authz.WorkspaceRole) -> RequestContext:
+ return RequestContext(
+ instance_uuid='instance-test',
+ placement_generation=1,
+ request_id='request-test',
+ auth_type='user-token',
+ principal=PrincipalContext(
+ principal_type=PrincipalType.ACCOUNT,
+ account_uuid='account-test',
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid='workspace-test',
+ membership_uuid='membership-test',
+ role=role.value,
+ permissions=authz.permissions_for_role(role),
+ ),
+ )
+
+
+def test_owner_has_every_fixed_permission():
+ ctx = _context(authz.WorkspaceRole.OWNER)
+
+ assert ctx.workspace.permissions == frozenset(permission.value for permission in authz.Permission)
+
+
+def test_admin_cannot_transfer_owner_delete_workspace_or_link_billing():
+ ctx = _context(authz.WorkspaceRole.ADMIN)
+
+ assert not authz.has_permission(ctx, authz.Permission.OWNER_TRANSFER)
+ assert not authz.has_permission(ctx, authz.Permission.WORKSPACE_DELETE)
+ assert not authz.has_permission(ctx, authz.Permission.BILLING_LINK_MANAGE)
+ assert authz.has_permission(ctx, authz.Permission.MEMBER_INVITE)
+
+
+def test_operator_can_run_but_cannot_manage_resources_or_secrets():
+ ctx = _context(authz.WorkspaceRole.OPERATOR)
+
+ assert authz.has_permission(ctx, authz.Permission.RUNTIME_OPERATE)
+ assert not authz.has_permission(ctx, authz.Permission.RESOURCE_MANAGE)
+ assert not authz.has_permission(ctx, authz.Permission.PROVIDER_SECRET_MANAGE)
+
+
+def test_unknown_role_has_no_permissions():
+ assert authz.permissions_for_role('unknown') == frozenset()
+
+
+def test_require_permission_reports_stable_permission():
+ ctx = _context(authz.WorkspaceRole.VIEWER)
+
+ try:
+ authz.require_permission(ctx, authz.Permission.RESOURCE_MANAGE)
+ except authz.PermissionDeniedError as exc:
+ assert exc.permission == authz.Permission.RESOURCE_MANAGE.value
+ assert exc.error_code == 'permission_denied'
+ else:
+ raise AssertionError('PermissionDeniedError was not raised')
+
+
+def test_execution_context_preserves_workspace_and_generation():
+ from langbot.pkg.api.http.context import ExecutionContext
+
+ ctx = _context(authz.WorkspaceRole.DEVELOPER)
+ execution = ExecutionContext.from_request(ctx, bot_uuid='bot-test', pipeline_uuid='pipeline-test')
+
+ assert execution.instance_uuid == 'instance-test'
+ assert execution.workspace_uuid == 'workspace-test'
+ assert execution.placement_generation == 1
+ assert execution.bot_uuid == 'bot-test'
+ assert execution.pipeline_uuid == 'pipeline-test'
+ assert execution.trigger_principal == ctx.principal
diff --git a/tests/unit_tests/api/http/test_bounded_json_request.py b/tests/unit_tests/api/http/test_bounded_json_request.py
new file mode 100644
index 000000000..6fbdb7f97
--- /dev/null
+++ b/tests/unit_tests/api/http/test_bounded_json_request.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import quart
+
+from langbot.pkg.api.http.controller import main as controller_main
+from langbot.pkg.utils import bounded_executor
+
+
+async def test_bounded_json_request_decodes_off_loop_in_workspace_scope(
+ monkeypatch,
+):
+ app = quart.Quart(__name__)
+ app.request_class = controller_main.BoundedJSONRequest
+ observed_scopes: list[str | None] = []
+
+ async def fake_to_thread(fn, *args, **kwargs):
+ observed_scopes.append(bounded_executor.current_blocking_work_scope())
+ return fn(*args, **kwargs)
+
+ monkeypatch.setattr(
+ controller_main.asyncio,
+ 'to_thread',
+ fake_to_thread,
+ )
+
+ @app.post('/json')
+ async def parse_json():
+ with bounded_executor.blocking_work_scope('workspace-a'):
+ payload = await quart.request.get_json()
+ return quart.jsonify(payload)
+
+ response = await app.test_client().post(
+ '/json',
+ json={'nested': {'value': 1}},
+ )
+
+ assert response.status_code == 200
+ assert await response.get_json() == {'nested': {'value': 1}}
+ assert observed_scopes == ['workspace-a']
diff --git a/tests/unit_tests/api/http/test_entitlement_context.py b/tests/unit_tests/api/http/test_entitlement_context.py
new file mode 100644
index 000000000..600eb1082
--- /dev/null
+++ b/tests/unit_tests/api/http/test_entitlement_context.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.context import (
+ ExecutionContext,
+ PrincipalContext,
+ PrincipalType,
+ RequestContext,
+ WorkspaceContext,
+)
+from langbot.pkg.api.http.controller.group import RouterGroup
+from langbot.pkg.cloud.entitlements import EntitlementSnapshot, EntitlementUnavailableError
+from langbot.pkg.cloud.entitlements import EntitlementResolver
+
+
+class _Group(RouterGroup):
+ async def initialize(self) -> None:
+ return None
+
+
+def _router(deployment) -> _Group:
+ provider = getattr(deployment, 'entitlement_provider', None)
+ resolver = EntitlementResolver('instance-a', provider) if provider is not None else None
+ ap = SimpleNamespace(deployment=deployment, entitlement_resolver=resolver)
+ return _Group(ap, quart.Quart(__name__))
+
+
+@pytest.mark.asyncio
+async def test_cloud_request_resolves_verified_entitlement_revision():
+ snapshot = EntitlementSnapshot(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ entitlement_revision=9,
+ status='active',
+ not_before=1,
+ expires_at=4_000_000_000,
+ features={},
+ limits={},
+ )
+ provider = SimpleNamespace(get_workspace_entitlement=AsyncMock(return_value=snapshot))
+ router = _router(SimpleNamespace(multi_workspace_enabled=True, entitlement_provider=provider))
+
+ revision = await router._resolve_entitlement_revision('instance-a', 'workspace-a')
+
+ assert revision == 9
+ provider.get_workspace_entitlement.assert_awaited_once_with('workspace-a')
+
+
+@pytest.mark.asyncio
+async def test_cloud_request_fails_closed_without_entitlement_provider():
+ router = _router(SimpleNamespace(multi_workspace_enabled=True, entitlement_provider=None))
+
+ with pytest.raises(EntitlementUnavailableError):
+ await router._resolve_entitlement_revision('instance-a', 'workspace-a')
+
+
+def test_execution_context_preserves_entitlement_revision():
+ request = RequestContext(
+ instance_uuid='instance-a',
+ placement_generation=1,
+ request_id='request-a',
+ auth_type='user-token',
+ principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid='account-a'),
+ workspace=WorkspaceContext(
+ workspace_uuid='workspace-a',
+ membership_uuid='membership-a',
+ role='owner',
+ permissions=frozenset(),
+ ),
+ entitlement_revision=11,
+ )
+
+ assert ExecutionContext.from_request(request).entitlement_revision == 11
diff --git a/tests/unit_tests/api/http/test_internal_error_responses.py b/tests/unit_tests/api/http/test_internal_error_responses.py
new file mode 100644
index 000000000..25c0e83ef
--- /dev/null
+++ b/tests/unit_tests/api/http/test_internal_error_responses.py
@@ -0,0 +1,257 @@
+from __future__ import annotations
+
+import contextlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller import group
+from langbot.pkg.api.http.controller.groups.webhooks import WebhookRouterGroup
+from langbot.pkg.utils.bounded_executor import (
+ BlockingWorkCapacityError,
+ current_blocking_work_scope,
+)
+
+
+pytestmark = pytest.mark.asyncio
+
+
+class _FailingRouterGroup(group.RouterGroup):
+ name = 'failing-test'
+ path = '/failing-test'
+
+ async def initialize(self) -> None:
+ @self.route('', methods=['GET'], auth_type=group.AuthType.NONE)
+ async def _():
+ raise RuntimeError('database password=do-not-return')
+
+
+class _AuthenticatedRouterGroup(group.RouterGroup):
+ name = 'authenticated-test'
+ path = '/authenticated-test'
+
+ async def initialize(self) -> None:
+ @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
+ async def _():
+ return self.success()
+
+
+class _BlockingCapacityRouterGroup(group.RouterGroup):
+ name = 'blocking-capacity-test'
+ path = '/blocking-capacity-test'
+
+ async def initialize(self) -> None:
+ @self.route('', methods=['GET'], auth_type=group.AuthType.NONE)
+ async def _():
+ raise BlockingWorkCapacityError('Workspace blocking executor capacity reached')
+
+
+class _InvalidAccountRouterGroup(group.RouterGroup):
+ name = 'invalid-account-test'
+ path = '/invalid-account-test'
+
+ async def initialize(self) -> None:
+ @self.route(
+ '',
+ methods=['GET'],
+ auth_type=group.AuthType.ACCOUNT_TOKEN,
+ permission='workspace.view',
+ )
+ async def _():
+ return self.success()
+
+
+async def test_unhandled_http_error_returns_generic_body_and_correlated_request_id():
+ logger = Mock()
+ application = SimpleNamespace(logger=logger)
+ quart_app = quart.Quart(__name__)
+ await _FailingRouterGroup(application, quart_app).initialize()
+
+ response = await quart_app.test_client().get(
+ '/failing-test',
+ headers={'X-Request-Id': 'request-http-test'},
+ )
+
+ assert response.status_code == 500
+ assert await response.get_json() == {
+ 'code': 'internal_error',
+ 'msg': 'Internal server error',
+ 'request_id': 'request-http-test',
+ }
+ assert response.headers['X-Request-Id'] == 'request-http-test'
+ log_message = logger.error.call_args.args[0]
+ assert 'request_id=request-http-test' in log_message
+ assert 'database password=do-not-return' in log_message
+ assert 'do-not-return' not in (await response.get_data(as_text=True))
+
+
+async def test_public_webhook_error_uses_same_generic_error_contract():
+ logger = Mock()
+ application = SimpleNamespace(
+ logger=logger,
+ platform_mgr=SimpleNamespace(
+ resolve_public_bot=AsyncMock(side_effect=RuntimeError('adapter credential=do-not-return'))
+ ),
+ )
+ quart_app = quart.Quart(__name__)
+ await WebhookRouterGroup(application, quart_app).initialize()
+
+ response = await quart_app.test_client().post(
+ '/bots/11111111-1111-4111-8111-111111111111',
+ headers={'X-Request-Id': 'request-webhook-test'},
+ )
+
+ assert response.status_code == 500
+ assert await response.get_json() == {
+ 'code': 'internal_error',
+ 'msg': 'Internal server error',
+ 'request_id': 'request-webhook-test',
+ }
+ assert response.headers['X-Request-Id'] == 'request-webhook-test'
+ log_message = logger.error.call_args.args[0]
+ assert 'request_id=request-webhook-test' in log_message
+ assert 'adapter credential=do-not-return' in log_message
+ assert 'do-not-return' not in (await response.get_data(as_text=True))
+
+
+async def test_blocking_work_capacity_maps_to_retryable_http_response():
+ application = SimpleNamespace(logger=Mock())
+ quart_app = quart.Quart(__name__)
+ await _BlockingCapacityRouterGroup(application, quart_app).initialize()
+
+ response = await quart_app.test_client().get('/blocking-capacity-test')
+
+ assert response.status_code == 429
+ assert await response.get_json() == {
+ 'code': 'blocking_work_capacity_exceeded',
+ 'msg': 'Workspace blocking executor capacity reached',
+ }
+
+
+async def test_public_webhook_carries_scope_without_holding_database_session():
+ class ScopeOnlyPersistenceManager:
+ mode = SimpleNamespace(value='cloud_runtime')
+
+ def __init__(self):
+ self.active_workspace = None
+
+ @contextlib.asynccontextmanager
+ async def tenant_scope(self, workspace_uuid):
+ self.active_workspace = workspace_uuid
+ try:
+ yield
+ finally:
+ self.active_workspace = None
+
+ def current_session(self):
+ return None
+
+ persistence_mgr = ScopeOnlyPersistenceManager()
+ workspace_uuid = '00000000-0000-0000-0000-00000000000a'
+ bot_uuid = '11111111-1111-4111-8111-111111111111'
+
+ class Adapter:
+ async def handle_unified_webhook(self, **_kwargs):
+ assert persistence_mgr.active_workspace == workspace_uuid
+ assert persistence_mgr.current_session() is None
+ assert current_blocking_work_scope() == workspace_uuid
+ return {'ok': True}
+
+ async def get_execution_binding(resolved_workspace_uuid, expected_generation=None):
+ assert resolved_workspace_uuid == workspace_uuid
+ assert expected_generation == 4
+
+ runtime_bot = SimpleNamespace(
+ workspace_uuid=workspace_uuid,
+ placement_generation=4,
+ enable=True,
+ adapter=Adapter(),
+ )
+ application = SimpleNamespace(
+ logger=Mock(),
+ persistence_mgr=persistence_mgr,
+ platform_mgr=SimpleNamespace(resolve_public_bot=AsyncMock(return_value=runtime_bot)),
+ workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding),
+ )
+ quart_app = quart.Quart(__name__)
+ await WebhookRouterGroup(application, quart_app).initialize()
+
+ response = await quart_app.test_client().post(f'/bots/{bot_uuid}')
+
+ assert response.status_code == 200
+ assert await response.get_json() == {'ok': True}
+ assert persistence_mgr.active_workspace is None
+
+
+async def test_public_webhook_blocking_capacity_is_retryable():
+ workspace_uuid = '00000000-0000-0000-0000-00000000000a'
+ bot_uuid = '11111111-1111-4111-8111-111111111111'
+
+ class Adapter:
+ async def handle_unified_webhook(self, **_kwargs):
+ raise BlockingWorkCapacityError(
+ 'Workspace blocking executor capacity reached',
+ scope=workspace_uuid,
+ )
+
+ runtime_bot = SimpleNamespace(
+ workspace_uuid=workspace_uuid,
+ placement_generation=4,
+ enable=True,
+ adapter=Adapter(),
+ )
+ application = SimpleNamespace(
+ logger=Mock(),
+ persistence_mgr=SimpleNamespace(mode=SimpleNamespace(value='oss')),
+ platform_mgr=SimpleNamespace(resolve_public_bot=AsyncMock(return_value=runtime_bot)),
+ workspace_service=SimpleNamespace(get_execution_binding=AsyncMock(return_value=None)),
+ )
+ quart_app = quart.Quart(__name__)
+ await WebhookRouterGroup(application, quart_app).initialize()
+
+ response = await quart_app.test_client().post(f'/bots/{bot_uuid}')
+
+ assert response.status_code == 429
+ assert await response.get_json() == {
+ 'code': 'blocking_work_capacity_exceeded',
+ 'msg': 'Workspace blocking executor capacity reached',
+ }
+
+
+async def test_authentication_failure_does_not_return_internal_exception_text():
+ logger = Mock()
+ application = SimpleNamespace(
+ logger=logger,
+ user_service=SimpleNamespace(
+ get_authenticated_account=AsyncMock(side_effect=RuntimeError('database password=do-not-return'))
+ ),
+ )
+ quart_app = quart.Quart(__name__)
+ await _AuthenticatedRouterGroup(application, quart_app).initialize()
+
+ response = await quart_app.test_client().get(
+ '/authenticated-test',
+ headers={
+ 'Authorization': 'Bearer invalid',
+ 'X-Request-Id': 'request-auth-test',
+ },
+ )
+
+ assert response.status_code == 401
+ assert await response.get_json() == {
+ 'code': 'invalid_authentication',
+ 'msg': 'Invalid authentication credentials',
+ }
+ assert 'do-not-return' not in (await response.get_data(as_text=True))
+ assert 'request_id=request-auth-test' in logger.warning.call_args.args[0]
+ assert 'database password=do-not-return' in logger.warning.call_args.args[0]
+
+
+async def test_account_token_route_cannot_declare_workspace_permission():
+ application = SimpleNamespace(logger=Mock())
+ quart_app = quart.Quart(__name__)
+
+ with pytest.raises(ValueError, match='cannot declare Workspace permissions'):
+ await _InvalidAccountRouterGroup(application, quart_app).initialize()
diff --git a/tests/unit_tests/api/http/test_route_tenant_scope.py b/tests/unit_tests/api/http/test_route_tenant_scope.py
new file mode 100644
index 000000000..4b4cb96e6
--- /dev/null
+++ b/tests/unit_tests/api/http/test_route_tenant_scope.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import quart
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.controller import group
+from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
+
+
+pytestmark = pytest.mark.asyncio
+
+
+async def test_authenticated_route_does_not_hold_database_session_during_external_wait():
+ entered = asyncio.Event()
+ release = asyncio.Event()
+ observations: list[bool] = []
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ persistence = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ persistence.db = SimpleNamespace(get_engine=lambda: engine)
+
+ class BlockingRouter(group.RouterGroup):
+ name = 'blocking-route-test'
+ path = '/blocking-route-test'
+
+ async def initialize(self) -> None:
+ @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
+ async def _():
+ observations.append(persistence.current_session() is None)
+ entered.set()
+ await release.wait()
+ observations.append(persistence.current_session() is None)
+ return self.success(data={})
+
+ account = SimpleNamespace(uuid='account-a', user='owner@example.com')
+ access = SimpleNamespace(
+ execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
+ workspace=SimpleNamespace(uuid='workspace-a'),
+ membership=SimpleNamespace(uuid='membership-a', role='owner', projection_revision=1),
+ )
+ application = SimpleNamespace(
+ persistence_mgr=persistence,
+ deployment=SimpleNamespace(multi_workspace_enabled=False),
+ user_service=SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account)),
+ workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)),
+ logger=Mock(),
+ )
+ quart_app = quart.Quart(__name__)
+ await BlockingRouter(application, quart_app).initialize()
+ client = quart_app.test_client()
+
+ request = asyncio.create_task(
+ client.get(
+ '/blocking-route-test',
+ headers={'Authorization': 'Bearer token', 'X-Workspace-Id': 'workspace-a'},
+ )
+ )
+ try:
+ await entered.wait()
+ assert observations == [True]
+ release.set()
+ response = await request
+ assert response.status_code == 200
+ assert observations == [True, True]
+ finally:
+ release.set()
+ if not request.done():
+ await request
+ await engine.dispose()
diff --git a/tests/unit_tests/api/service/test_apikey_service.py b/tests/unit_tests/api/service/test_apikey_service.py
index 9726888eb..160ffe430 100644
--- a/tests/unit_tests/api/service/test_apikey_service.py
+++ b/tests/unit_tests/api/service/test_apikey_service.py
@@ -1,482 +1,466 @@
-"""
-Unit tests for ApiKeyService.
-
-Tests API key CRUD operations with mocked persistence layer.
-
-Source: src/langbot/pkg/api/http/service/apikey.py
-"""
-
from __future__ import annotations
-import pytest
-from unittest.mock import AsyncMock, Mock, patch
+import datetime
+import hashlib
+import logging
+import uuid
from types import SimpleNamespace
+from unittest.mock import AsyncMock
+import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from langbot.pkg.api.http.authz import Permission, PermissionDeniedError
+from langbot.pkg.api.http.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.entity.persistence.apikey import ApiKey
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.entity.persistence.workspace import (
+ Workspace,
+ WorkspaceExecutionSource,
+ WorkspaceExecutionState,
+ WorkspaceSource,
+)
+from langbot.pkg.workspace.policy import SingleWorkspacePolicy
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
+from langbot.pkg.workspace.service import WorkspaceService
pytestmark = pytest.mark.asyncio
+class _PersistenceManager:
+ def __init__(self, engine):
+ self.engine = engine
+
+ def get_db_engine(self):
+ return self.engine
+
+ async def execute_async(self, *args, **kwargs):
+ async with self.engine.connect() as connection:
+ result = await connection.execute(*args, **kwargs)
+ await connection.commit()
+ return result
+
+ @staticmethod
+ def serialize_model(model, row, masked_columns=()):
+ return {
+ column.name: (
+ getattr(row, column.name).isoformat()
+ if isinstance(getattr(row, column.name), datetime.datetime)
+ else getattr(row, column.name)
+ )
+ for column in model.__table__.columns
+ if column.name not in masked_columns
+ }
+
+
+def _context(workspace_uuid: str, account_uuid: str, permissions: set[Permission]) -> RequestContext:
+ return RequestContext(
+ instance_uuid='api-key-instance',
+ placement_generation=1,
+ request_id=str(uuid.uuid4()),
+ auth_type='user-token',
+ principal=PrincipalContext(PrincipalType.ACCOUNT, account_uuid=account_uuid),
+ workspace=WorkspaceContext(
+ workspace_uuid=workspace_uuid,
+ membership_uuid=str(uuid.uuid4()),
+ role='owner',
+ permissions=frozenset(permission.value for permission in permissions),
+ ),
+ )
+
+
+@pytest.fixture
+async def api_key_context(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "api-keys.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+
+ application = SimpleNamespace(
+ persistence_mgr=_PersistenceManager(engine),
+ instance_config=SimpleNamespace(data={'api': {'global_api_key': ''}}),
+ logger=logging.getLogger('api-key-test'),
+ )
+ application.workspace_service = WorkspaceService(application, instance_uuid='api-key-instance')
+ workspace = await application.workspace_service.ensure_singleton_workspace()
+ account_uuid = str(uuid.uuid4())
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
+ async with session_factory.begin() as session:
+ session.add(
+ User(
+ uuid=account_uuid,
+ user='owner@example.com',
+ normalized_email='owner@example.com',
+ password='hash',
+ account_type='local',
+ )
+ )
+ service = ApiKeyService(application)
+ context = _context(workspace.uuid, account_uuid, set(Permission))
+ yield application, service, context, engine
+ await engine.dispose()
+
+
+async def test_secret_is_returned_once_and_only_hash_is_persisted(api_key_context):
+ _application, service, context, engine = api_key_context
+
+ created = await service.create_api_key(context, 'Automation', 'CI key')
+ secret = created['key']
+ assert secret.startswith('lbk_')
+ assert created['secret_available'] is True
+ assert 'key_hash' not in created
+
+ listed = await service.get_api_keys(context)
+ assert len(listed) == 1
+ assert 'key' not in listed[0]
+ assert 'key_hash' not in listed[0]
+ assert listed[0]['secret_available'] is False
+
+ async with engine.connect() as connection:
+ stored = await connection.scalar(sqlalchemy.select(ApiKey.key_hash))
+ assert stored == hashlib.sha256(secret.encode()).hexdigest()
+ assert secret not in stored
+
+
+async def test_authentication_derives_workspace_scopes_and_updates_usage(api_key_context):
+ _application, service, context, engine = api_key_context
+ created = await service.create_api_key(
+ context,
+ 'Read only',
+ scopes=[Permission.RESOURCE_VIEW.value],
+ )
+
+ identity = await service.authenticate_api_key(created['key'])
+ assert identity is not None
+ assert identity.workspace_uuid == context.workspace_uuid
+ assert identity.permissions == frozenset({Permission.RESOURCE_VIEW.value})
+
+ async with engine.connect() as connection:
+ last_used_at = await connection.scalar(sqlalchemy.select(ApiKey.last_used_at))
+ assert last_used_at is not None
+
+
+async def test_revoked_expired_and_unknown_keys_fail_closed(api_key_context):
+ _application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Revocable')
+ await service.delete_api_key(context, created['id'])
+ assert await service.authenticate_api_key(created['key']) is None
+ assert await service.verify_api_key('') is False
+ assert await service.verify_api_key('plain-secret') is False
+ assert await service.verify_api_key('lbk_unknown') is False
+
+ expired_secret = 'lbk_expired'
+ await service.ap.persistence_mgr.execute_async(
+ sqlalchemy.insert(ApiKey).values(
+ workspace_uuid=context.workspace_uuid,
+ name='Expired',
+ key_hash=hashlib.sha256(expired_secret.encode()).hexdigest(),
+ scopes=[Permission.RESOURCE_VIEW.value],
+ status='active',
+ expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(seconds=1),
+ )
+ )
+ assert await service.authenticate_api_key(expired_secret) is None
+
+
+async def test_revoke_winning_last_used_update_race_fails_authentication(api_key_context):
+ application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Racing revoke')
+ original_execute = application.persistence_mgr.execute_async
+ injected_revoke = False
+
+ async def execute_with_revoke(statement, *args, **kwargs):
+ nonlocal injected_revoke
+ if (
+ not injected_revoke
+ and isinstance(statement, sqlalchemy.sql.dml.Update)
+ and statement.table.name == ApiKey.__tablename__
+ ):
+ injected_revoke = True
+ await original_execute(sqlalchemy.update(ApiKey).where(ApiKey.id == created['id']).values(status='revoked'))
+ return await original_execute(statement, *args, **kwargs)
+
+ application.persistence_mgr.execute_async = execute_with_revoke
+
+ assert await service.authenticate_api_key(created['key']) is None
+ assert injected_revoke is True
+
+
+async def test_cross_workspace_crud_and_secret_guessing_are_isolated(api_key_context):
+ application, service, first_context, engine = api_key_context
+ second_workspace_uuid = str(uuid.uuid4())
+ async with async_sessionmaker(engine, expire_on_commit=False).begin() as session:
+ session.add(
+ Workspace(
+ uuid=second_workspace_uuid,
+ instance_uuid='api-key-instance',
+ name='Second',
+ slug='second',
+ source=WorkspaceSource.CLOUD_PROJECTION.value,
+ )
+ )
+ session.add(
+ WorkspaceExecutionState(
+ workspace_uuid=second_workspace_uuid,
+ instance_uuid='api-key-instance',
+ active_generation=3,
+ state='active',
+ write_fenced=False,
+ source=WorkspaceExecutionSource.CLOUD.value,
+ )
+ )
+ second_context = _context(second_workspace_uuid, first_context.account_uuid or '', set(Permission))
+ created = await service.create_api_key(first_context, 'First only')
+
+ assert await service.get_api_key(second_context, created['id']) is None
+ assert await service.get_api_keys(second_context) == []
+ identity = await service.authenticate_api_key(created['key'])
+ assert identity is not None
+ assert identity.workspace_uuid == first_context.workspace_uuid
+ assert identity.workspace_uuid != second_workspace_uuid
+
+ # Prove the explicit multi-Workspace policy does not change key-derived routing.
+ application.workspace_service.policy = SingleWorkspacePolicy(workspace_limit=10, multi_workspace_enabled=True)
+ identity = await service.authenticate_api_key(created['key'])
+ assert identity is not None
+ assert identity.workspace_uuid == first_context.workspace_uuid
+
+
+async def test_global_config_key_is_oss_singleton_only(api_key_context):
+ application, service, _context_value, _engine = api_key_context
+ application.instance_config.data['api']['global_api_key'] = 'configured-secret'
+
+ identity = await service.authenticate_api_key('configured-secret')
+ assert identity is not None
+ assert identity.api_key_uuid == 'global-oss-api-key'
+
+ application.workspace_service.policy = SingleWorkspacePolicy(workspace_limit=10, multi_workspace_enabled=True)
+ assert await service.authenticate_api_key('configured-secret') is None
+
+
+async def test_explicit_scopes_cannot_exceed_callers_workspace_permissions(api_key_context):
+ _application, service, context, _engine = api_key_context
+ limited_context = _context(
+ context.workspace_uuid,
+ context.account_uuid or '',
+ {Permission.API_KEY_MANAGE, Permission.RESOURCE_VIEW},
+ )
+
+ created = await service.create_api_key(
+ limited_context,
+ 'Read only',
+ scopes=[Permission.RESOURCE_VIEW.value],
+ )
+ identity = await service.authenticate_api_key(created['key'])
+ assert identity is not None
+ assert identity.permissions == frozenset({Permission.RESOURCE_VIEW.value})
+
+ with pytest.raises(PermissionDeniedError) as exc_info:
+ await service.create_api_key(
+ limited_context,
+ 'Escalated',
+ scopes=[Permission.WORKSPACE_DELETE.value],
+ )
+ assert exc_info.value.permission == Permission.WORKSPACE_DELETE.value
+
+
+# Preserve the pre-tenancy CRUD and verification regression matrix while
+# exercising it through the new Workspace-bound API. The assertions reflect
+# intentional security changes: secrets are returned once, deletion revokes,
+# and missing Workspace resources are reported as not found.
class TestApiKeyServiceGetApiKeys:
- """Tests for get_api_keys method."""
+ async def test_get_api_keys_empty_list(self, api_key_context):
+ _application, service, context, _engine = api_key_context
- async def test_get_api_keys_empty_list(self):
- """Returns empty list when no API keys exist."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- mock_result = Mock()
- mock_result.all = Mock(return_value=[])
- ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
- ap.persistence_mgr.serialize_model = Mock(
- side_effect=lambda model_cls, entity: {
- 'id': entity.id,
- 'name': entity.name,
- 'key': entity.key,
- 'description': entity.description,
- }
- if entity
- else {}
- )
+ assert await service.get_api_keys(context) == []
- service = ApiKeyService(ap)
+ async def test_get_api_keys_returns_serialized_list(self, api_key_context):
+ _application, service, context, _engine = api_key_context
+ await service.create_api_key(context, 'Test Key 1', 'First test key')
+ await service.create_api_key(context, 'Test Key 2', 'Second test key')
- # Execute
- result = await service.get_api_keys()
+ result = await service.get_api_keys(context)
- # Verify
- assert result == []
- ap.persistence_mgr.execute_async.assert_called_once()
-
- async def test_get_api_keys_returns_serialized_list(self):
- """Returns serialized list of API keys."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
-
- # Create mock API key entities
- key1 = Mock(spec=ApiKey)
- key1.id = 1
- key1.name = 'Test Key 1'
- key1.key = 'lbk_test_key_1'
- key1.description = 'First test key'
-
- key2 = Mock(spec=ApiKey)
- key2.id = 2
- key2.name = 'Test Key 2'
- key2.key = 'lbk_test_key_2'
- key2.description = 'Second test key'
-
- mock_result = Mock()
- mock_result.all = Mock(return_value=[key1, key2])
- ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
- ap.persistence_mgr.serialize_model = Mock(
- side_effect=lambda model_cls, entity: {
- 'id': entity.id,
- 'name': entity.name,
- 'key': entity.key,
- 'description': entity.description,
- }
- )
-
- service = ApiKeyService(ap)
-
- # Execute
- result = await service.get_api_keys()
-
- # Verify
- assert len(result) == 2
- assert result[0]['name'] == 'Test Key 1'
- assert result[1]['name'] == 'Test Key 2'
+ assert [item['name'] for item in result] == ['Test Key 1', 'Test Key 2']
+ assert [item['description'] for item in result] == ['First test key', 'Second test key']
+ assert all('key' not in item and 'key_hash' not in item for item in result)
class TestApiKeyServiceCreateApiKey:
- """Tests for create_api_key method."""
+ async def test_create_api_key_generates_key_with_prefix(self, api_key_context):
+ _application, service, context, _engine = api_key_context
- async def test_create_api_key_generates_key_with_prefix(self):
- """Creates API key with 'lbk_' prefix."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
+ with pytest.MonkeyPatch.context() as monkeypatch:
+ monkeypatch.setattr(
+ 'langbot.pkg.api.http.service.apikey.secrets.token_urlsafe', lambda _size: 'fixed-token'
+ )
+ result = await service.create_api_key(context, 'New Key', 'Test description')
- created_key = Mock(spec=ApiKey)
- created_key.id = 1
- created_key.name = 'New Key'
- created_key.key = 'lbk_fixed-token'
- created_key.description = 'Test description'
- select_result = Mock()
- select_result.first = Mock(return_value=created_key)
- insert_params = []
-
- async def mock_execute(query):
- params = query.compile().params
- if {'name', 'key', 'description'}.issubset(params):
- insert_params.append(params)
- return Mock()
- return select_result
-
- ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
- ap.persistence_mgr.serialize_model = Mock(
- side_effect=lambda model_cls, entity: {
- 'id': 1,
- 'name': entity.name,
- 'key': entity.key,
- 'description': entity.description,
- }
- )
-
- service = ApiKeyService(ap)
-
- with patch('langbot.pkg.api.http.service.apikey.secrets.token_urlsafe', return_value='fixed-token'):
- result = await service.create_api_key('New Key', 'Test description')
-
- assert insert_params == [{'name': 'New Key', 'key': 'lbk_fixed-token', 'description': 'Test description'}]
- assert result['key'].startswith('lbk_')
assert result['key'] == 'lbk_fixed-token'
assert result['name'] == 'New Key'
assert result['description'] == 'Test description'
+ assert result['secret_available'] is True
- async def test_create_api_key_without_description(self):
- """Creates API key with empty description when not provided."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
+ async def test_create_api_key_without_description(self, api_key_context):
+ _application, service, context, _engine = api_key_context
- created_key = Mock(spec=ApiKey)
- created_key.id = 1
- created_key.name = 'No Desc Key'
- created_key.key = 'lbk_no_desc_key'
- created_key.description = ''
+ result = await service.create_api_key(context, 'No Desc Key')
- select_result = Mock()
- select_result.first = Mock(return_value=created_key)
- insert_result = Mock()
-
- async def mock_execute(query):
- if hasattr(query, 'values'):
- return insert_result
- return select_result
-
- ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
- ap.persistence_mgr.serialize_model = Mock(
- return_value={
- 'id': 1,
- 'name': 'No Desc Key',
- 'key': 'lbk_no_desc_key',
- 'description': '',
- }
- )
-
- service = ApiKeyService(ap)
-
- # Execute
- result = await service.create_api_key('No Desc Key')
-
- # Verify
assert result['description'] == ''
class TestApiKeyServiceGetApiKey:
- """Tests for get_api_key method."""
+ async def test_get_api_key_by_id_found(self, api_key_context):
+ _application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Found Key', 'Found')
- async def test_get_api_key_by_id_found(self):
- """Returns API key when found by ID."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
+ result = await service.get_api_key(context, created['id'])
- key = Mock(spec=ApiKey)
- key.id = 1
- key.name = 'Found Key'
- key.key = 'lbk_found_key'
- key.description = 'Found'
-
- mock_result = Mock()
- mock_result.first = Mock(return_value=key)
- ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
- ap.persistence_mgr.serialize_model = Mock(
- return_value={
- 'id': 1,
- 'name': 'Found Key',
- 'key': 'lbk_found_key',
- 'description': 'Found',
- }
- )
-
- service = ApiKeyService(ap)
-
- # Execute
- result = await service.get_api_key(1)
-
- # Verify
assert result is not None
- assert result['id'] == 1
+ assert result['id'] == created['id']
assert result['name'] == 'Found Key'
+ assert 'key' not in result and 'key_hash' not in result
- async def test_get_api_key_by_id_not_found(self):
- """Returns None when API key not found."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
+ async def test_get_api_key_by_id_not_found(self, api_key_context):
+ _application, service, context, _engine = api_key_context
- mock_result = Mock()
- mock_result.first = Mock(return_value=None)
- ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
+ assert await service.get_api_key(context, 999) is None
- service = ApiKeyService(ap)
+ async def test_get_api_key_by_id_zero(self, api_key_context):
+ _application, service, context, _engine = api_key_context
- # Execute
- result = await service.get_api_key(999)
-
- # Verify
- assert result is None
-
- async def test_get_api_key_by_id_zero(self):
- """Handles ID=0 (edge case) correctly."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
-
- mock_result = Mock()
- mock_result.first = Mock(return_value=None)
- ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
-
- service = ApiKeyService(ap)
-
- # Execute
- result = await service.get_api_key(0)
-
- # Verify - should return None (no key with ID 0)
- assert result is None
+ assert await service.get_api_key(context, 0) is None
class TestApiKeyServiceVerifyApiKey:
- """Tests for verify_api_key method."""
+ async def test_verify_api_key_valid(self, api_key_context):
+ _application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Valid')
- @staticmethod
- def _make_ap(db_key=None, global_api_key=''):
- """Build a mock Application with persistence + instance_config."""
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- mock_result = Mock()
- mock_result.first = Mock(return_value=db_key)
- ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
- ap.instance_config = SimpleNamespace(data={'api': {'global_api_key': global_api_key}})
- return ap
+ assert await service.verify_api_key(created['key']) is True
- async def test_verify_api_key_valid(self):
- """Returns True for valid API key."""
- # Setup
- key = Mock(spec=ApiKey)
- ap = self._make_ap(db_key=key)
+ async def test_verify_api_key_invalid(self, api_key_context):
+ _application, service, _context, _engine = api_key_context
- service = ApiKeyService(ap)
+ assert await service.verify_api_key('lbk_invalid_key') is False
- # Execute
- result = await service.verify_api_key('lbk_valid_key')
+ async def test_verify_api_key_empty_string(self, api_key_context):
+ _application, service, _context, _engine = api_key_context
- # Verify
- assert result is True
+ assert await service.verify_api_key('') is False
- async def test_verify_api_key_invalid(self):
- """Returns False for invalid API key."""
- # Setup
- ap = self._make_ap(db_key=None)
+ async def test_verify_api_key_unknown_key(self, api_key_context):
+ _application, service, _context, _engine = api_key_context
- service = ApiKeyService(ap)
+ assert await service.verify_api_key('unknown_key') is False
- # Execute
- result = await service.verify_api_key('lbk_invalid_key')
+ async def test_verify_global_api_key_match(self, api_key_context):
+ application, service, context, _engine = api_key_context
+ application.instance_config.data['api']['global_api_key'] = 'my-global-secret'
- # Verify
- assert result is False
+ identity = await service.authenticate_api_key('my-global-secret')
- async def test_verify_api_key_empty_string(self):
- """Returns False for empty key string."""
- # Setup
- ap = self._make_ap(db_key=None)
+ assert identity is not None
+ assert identity.workspace_uuid == context.workspace_uuid
+ assert identity.api_key_uuid == 'global-oss-api-key'
- service = ApiKeyService(ap)
+ async def test_verify_global_api_key_no_prefix_required(self, api_key_context):
+ application, service, _context, _engine = api_key_context
+ application.instance_config.data['api']['global_api_key'] = 'plainsecret123'
- # Execute
- result = await service.verify_api_key('')
+ assert await service.verify_api_key('plainsecret123') is True
- # Verify
- assert result is False
+ async def test_verify_global_api_key_mismatch_falls_back_to_db(self, api_key_context):
+ application, service, context, _engine = api_key_context
+ application.instance_config.data['api']['global_api_key'] = 'my-global-secret'
+ created = await service.create_api_key(context, 'DB key')
- async def test_verify_api_key_unknown_key(self):
- """Returns False when the key is not present in persistence."""
- # Setup
- ap = self._make_ap(db_key=None)
+ identity = await service.authenticate_api_key(created['key'])
- service = ApiKeyService(ap)
+ assert identity is not None
+ assert identity.api_key_uuid == created['uuid']
- # Execute
- result = await service.verify_api_key('unknown_key')
+ async def test_verify_empty_global_api_key_disabled(self, api_key_context):
+ application, service, _context, _engine = api_key_context
+ application.instance_config.data['api']['global_api_key'] = ''
- # Verify
- assert result is False
-
- async def test_verify_global_api_key_match(self):
- """Returns True when key matches the config.yaml global API key (no DB lookup)."""
- # Setup: no DB record, but a global key is configured
- ap = self._make_ap(db_key=None, global_api_key='my-global-secret')
-
- service = ApiKeyService(ap)
-
- # Execute
- result = await service.verify_api_key('my-global-secret')
-
- # Verify: accepted purely on config match
- assert result is True
- # DB should not have been consulted for the global-key path
- ap.persistence_mgr.execute_async.assert_not_called()
-
- async def test_verify_global_api_key_no_prefix_required(self):
- """Global API key is accepted even without the lbk_ prefix."""
- ap = self._make_ap(db_key=None, global_api_key='plainsecret123')
-
- service = ApiKeyService(ap)
-
- result = await service.verify_api_key('plainsecret123')
-
- assert result is True
-
- async def test_verify_global_api_key_mismatch_falls_back_to_db(self):
- """A non-matching key still falls through to the DB lookup."""
- # Global key set, but request uses a different lbk_ key that IS in DB
- key = Mock(spec=ApiKey)
- ap = self._make_ap(db_key=key, global_api_key='my-global-secret')
-
- service = ApiKeyService(ap)
-
- result = await service.verify_api_key('lbk_db_key')
-
- assert result is True
- ap.persistence_mgr.execute_async.assert_called_once()
-
- async def test_verify_empty_global_api_key_disabled(self):
- """An empty global_api_key must never authenticate an empty/blank request."""
- ap = self._make_ap(db_key=None, global_api_key='')
-
- service = ApiKeyService(ap)
-
- # Empty request key is rejected, and a blank global key never matches
assert await service.verify_api_key('') is False
assert await service.verify_api_key(' ') is False
- async def test_verify_api_key_missing_global_config_key(self):
- """Works even when api.global_api_key is absent (existing installs)."""
- # instance_config without the global_api_key field at all
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- mock_result = Mock()
- mock_result.first = Mock(return_value=None)
- ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
- ap.instance_config = SimpleNamespace(data={'api': {}})
+ async def test_verify_api_key_missing_global_config_key(self, api_key_context):
+ application, service, _context, _engine = api_key_context
+ application.instance_config.data = {'api': {}}
- service = ApiKeyService(ap)
-
- result = await service.verify_api_key('lbk_some_key')
-
- assert result is False
+ assert await service.verify_api_key('lbk_some_key') is False
class TestApiKeyServiceDeleteApiKey:
- """Tests for delete_api_key method."""
+ async def test_delete_api_key_by_id(self, api_key_context):
+ _application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Delete me')
- async def test_delete_api_key_by_id(self):
- """Deletes API key by ID."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ await service.delete_api_key(context, created['id'])
- service = ApiKeyService(ap)
+ stored = await service.get_api_key(context, created['id'])
+ assert stored is not None
+ assert stored['status'] == 'revoked'
+ assert await service.verify_api_key(created['key']) is False
- # Execute
- await service.delete_api_key(1)
+ async def test_delete_api_key_nonexistent_id(self, api_key_context):
+ _application, service, context, _engine = api_key_context
- # Verify - execute_async was called (delete operation)
- ap.persistence_mgr.execute_async.assert_called_once()
-
- async def test_delete_api_key_nonexistent_id(self):
- """Delete operation completes even for nonexistent ID (no error raised)."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
-
- service = ApiKeyService(ap)
-
- # Execute - should not raise error
- await service.delete_api_key(999)
-
- # Verify - execute_async was called regardless
- ap.persistence_mgr.execute_async.assert_called_once()
+ with pytest.raises(WorkspaceNotFoundError, match='API key not found'):
+ await service.delete_api_key(context, 999)
class TestApiKeyServiceUpdateApiKey:
- """Tests for update_api_key method."""
+ async def test_update_api_key_name_only(self, api_key_context):
+ _application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Original', 'Description')
- async def test_update_api_key_name_only(self):
- """Updates only the name field."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ await service.update_api_key(context, created['id'], name='Updated Name')
- service = ApiKeyService(ap)
+ stored = await service.get_api_key(context, created['id'])
+ assert stored is not None
+ assert stored['name'] == 'Updated Name'
+ assert stored['description'] == 'Description'
- # Execute
- await service.update_api_key(1, name='Updated Name')
+ async def test_update_api_key_description_only(self, api_key_context):
+ _application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Original', 'Description')
- # Verify - execute_async was called with update
- ap.persistence_mgr.execute_async.assert_called_once()
+ await service.update_api_key(context, created['id'], description='Updated description')
- async def test_update_api_key_description_only(self):
- """Updates only the description field."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ stored = await service.get_api_key(context, created['id'])
+ assert stored is not None
+ assert stored['name'] == 'Original'
+ assert stored['description'] == 'Updated description'
- service = ApiKeyService(ap)
+ async def test_update_api_key_both_fields(self, api_key_context):
+ _application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Original', 'Description')
- # Execute
- await service.update_api_key(1, description='Updated description')
+ await service.update_api_key(
+ context,
+ created['id'],
+ name='New Name',
+ description='New description',
+ )
- # Verify
- ap.persistence_mgr.execute_async.assert_called_once()
+ stored = await service.get_api_key(context, created['id'])
+ assert stored is not None
+ assert stored['name'] == 'New Name'
+ assert stored['description'] == 'New description'
- async def test_update_api_key_both_fields(self):
- """Updates both name and description."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ async def test_update_api_key_no_fields(self, api_key_context):
+ application, service, context, _engine = api_key_context
+ created = await service.create_api_key(context, 'Original')
+ original_execute = application.persistence_mgr.execute_async
+ application.persistence_mgr.execute_async = AsyncMock(wraps=original_execute)
- service = ApiKeyService(ap)
+ await service.update_api_key(context, created['id'])
- # Execute
- await service.update_api_key(1, name='New Name', description='New description')
-
- # Verify
- ap.persistence_mgr.execute_async.assert_called_once()
-
- async def test_update_api_key_no_fields(self):
- """Does nothing when no fields provided."""
- # Setup
- ap = SimpleNamespace()
- ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
-
- service = ApiKeyService(ap)
-
- # Execute
- await service.update_api_key(1)
-
- # Verify - no execute call since no update_data
- ap.persistence_mgr.execute_async.assert_not_called()
+ application.persistence_mgr.execute_async.assert_not_awaited()
diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py
index 8a6d0ad2a..dea5763f2 100644
--- a/tests/unit_tests/api/service/test_bot_service.py
+++ b/tests/unit_tests/api/service/test_bot_service.py
@@ -19,6 +19,8 @@ from langbot.pkg.entity.persistence.bot import Bot
pytestmark = pytest.mark.asyncio
+WORKSPACE_UUID = 'workspace-a'
+
def _create_mock_bot(
bot_uuid: str = None,
@@ -73,7 +75,9 @@ class TestBotServiceGetBots:
service = BotService(ap)
# Execute
- result = await service.get_bots()
+ result = await service.get_bots(
+ WORKSPACE_UUID,
+ )
# Verify
assert result == []
@@ -101,7 +105,7 @@ class TestBotServiceGetBots:
service = BotService(ap)
# Execute
- result = await service.get_bots(include_secret=True)
+ result = await service.get_bots(WORKSPACE_UUID, include_secret=True)
# Verify
assert len(result) == 2
@@ -130,7 +134,7 @@ class TestBotServiceGetBots:
service = BotService(ap)
# Execute
- result = await service.get_bots(include_secret=False)
+ result = await service.get_bots(WORKSPACE_UUID, include_secret=False)
# Verify - adapter_config should be masked
assert result[0]['adapter_config'] is None
@@ -159,7 +163,7 @@ class TestBotServiceGetBot:
service = BotService(ap)
# Execute
- result = await service.get_bot('test-uuid')
+ result = await service.get_bot(WORKSPACE_UUID, 'test-uuid')
# Verify
assert result is not None
@@ -178,7 +182,7 @@ class TestBotServiceGetBot:
service = BotService(ap)
# Execute
- result = await service.get_bot('nonexistent-uuid')
+ result = await service.get_bot(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify
assert result is None
@@ -203,7 +207,7 @@ class TestBotServiceGetRuntimeBotInfo:
# Execute & Verify
with pytest.raises(Exception, match='Bot not found'):
- await service.get_runtime_bot_info('nonexistent-uuid')
+ await service.get_runtime_bot_info(WORKSPACE_UUID, 'nonexistent-uuid')
async def test_get_runtime_bot_info_returns_webhook_for_wecom(self):
"""Returns webhook URL for wecom adapter."""
@@ -231,7 +235,7 @@ class TestBotServiceGetRuntimeBotInfo:
service.get_bot = AsyncMock(return_value=bot_data)
# Execute
- result = await service.get_runtime_bot_info('wecom-uuid')
+ result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'wecom-uuid')
# Verify
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
@@ -257,7 +261,7 @@ class TestBotServiceGetRuntimeBotInfo:
service.get_bot = AsyncMock(return_value=bot_data)
# Execute
- result = await service.get_runtime_bot_info('telegram-uuid')
+ result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'telegram-uuid')
# Verify - no webhook for telegram
assert result['adapter_runtime_values']['webhook_url'] is None
@@ -288,7 +292,7 @@ class TestBotServiceGetRuntimeBotInfo:
service.get_bot = AsyncMock(return_value=bot_data)
# Execute
- result = await service.get_runtime_bot_info('runtime-uuid')
+ result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'runtime-uuid')
# Verify
assert result['adapter_runtime_values']['bot_account_id'] == 'runtime-account-123'
@@ -318,7 +322,7 @@ class TestBotServiceCreateBot:
# Execute & Verify
with pytest.raises(ValueError, match='Maximum number of bots'):
- await service.create_bot({'name': 'New Bot'})
+ await service.create_bot(WORKSPACE_UUID, {'name': 'New Bot'})
async def test_create_bot_no_limit(self):
"""Creates bot without limit check when max_bots=-1."""
@@ -360,7 +364,9 @@ class TestBotServiceCreateBot:
service = BotService(ap)
# Execute
- bot_uuid = await service.create_bot({'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}})
+ bot_uuid = await service.create_bot(
+ WORKSPACE_UUID, {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
+ )
# Verify
assert bot_uuid is not None
@@ -412,11 +418,15 @@ class TestBotServiceCreateBot:
# Execute
bot_data = {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
- bot_uuid = await service.create_bot(bot_data)
+ bot_uuid = await service.create_bot(WORKSPACE_UUID, bot_data)
- # Verify - pipeline uuid and name were set
- assert 'use_pipeline_uuid' in bot_data
- assert 'use_pipeline_name' in bot_data
+ # The service owns a copy and cannot mutate caller input while adding tenant data.
+ assert bot_data == {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
+ insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
+ insert_values = insert_statement.compile().params
+ assert insert_values['workspace_uuid'] == WORKSPACE_UUID
+ assert insert_values['use_pipeline_uuid'] == 'default-pipeline-uuid'
+ assert insert_values['use_pipeline_name'] == 'Default Pipeline'
assert bot_uuid is not None # Verify UUID was returned
@@ -446,7 +456,7 @@ class TestBotServiceUpdateBot:
# Execute
update_data = {'uuid': 'should-be-removed', 'name': 'Updated Name'}
- await service.update_bot('test-uuid', update_data)
+ await service.update_bot(WORKSPACE_UUID, 'test-uuid', update_data)
update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
assert update_params['name'] == 'Updated Name'
@@ -467,7 +477,7 @@ class TestBotServiceUpdateBot:
# Execute & Verify
with pytest.raises(Exception, match='Pipeline not found'):
- await service.update_bot('test-uuid', {'use_pipeline_uuid': 'nonexistent-pipeline'})
+ await service.update_bot(WORKSPACE_UUID, 'test-uuid', {'use_pipeline_uuid': 'nonexistent-pipeline'})
async def test_update_bot_sets_pipeline_name(self):
"""Sets use_pipeline_name when updating use_pipeline_uuid."""
@@ -504,7 +514,7 @@ class TestBotServiceUpdateBot:
ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot)
# Execute
- await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'})
+ await service.update_bot(WORKSPACE_UUID, 'test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'})
update_params = ap.persistence_mgr.execute_async.await_args_list[1].args[0].compile().params
assert update_params['use_pipeline_uuid'] == 'pipeline-uuid'
@@ -524,12 +534,13 @@ class TestBotServiceDeleteBot:
ap.platform_mgr.remove_bot = AsyncMock()
service = BotService(ap)
+ service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
# Execute
- await service.delete_bot('test-uuid')
+ await service.delete_bot(WORKSPACE_UUID, 'test-uuid')
# Verify
- ap.platform_mgr.remove_bot.assert_called_once_with('test-uuid')
+ ap.platform_mgr.remove_bot.assert_called_once_with(WORKSPACE_UUID, 'test-uuid')
ap.persistence_mgr.execute_async.assert_called_once()
async def test_delete_bot_nonexistent_uuid(self):
@@ -542,9 +553,10 @@ class TestBotServiceDeleteBot:
ap.platform_mgr.remove_bot = AsyncMock()
service = BotService(ap)
+ service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
# Execute - should not raise
- await service.delete_bot('nonexistent-uuid')
+ await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify - both called regardless
ap.platform_mgr.remove_bot.assert_called_once()
@@ -561,10 +573,11 @@ class TestBotServiceListEventLogs:
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
+ service.get_bot = AsyncMock(return_value={'uuid': 'nonexistent-uuid'})
# Execute & Verify
with pytest.raises(Exception, match='Bot not found'):
- await service.list_event_logs('nonexistent-uuid', 0, 10)
+ await service.list_event_logs(WORKSPACE_UUID, 'nonexistent-uuid', 0, 10)
async def test_list_event_logs_returns_logs(self):
"""Returns logs from runtime bot logger."""
@@ -581,9 +594,10 @@ class TestBotServiceListEventLogs:
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
service = BotService(ap)
+ service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
# Execute
- logs, total = await service.list_event_logs('bot-uuid', 0, 10)
+ logs, total = await service.list_event_logs(WORKSPACE_UUID, 'bot-uuid', 0, 10)
# Verify
assert len(logs) == 1
@@ -602,10 +616,11 @@ class TestBotServiceSendMessage:
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=None)
service = BotService(ap)
+ service.get_bot = AsyncMock(return_value={'uuid': 'nonexistent-uuid'})
# Execute & Verify
with pytest.raises(Exception, match='Bot not found'):
- await service.send_message('nonexistent-uuid', 'group', '123', {'test': 'data'})
+ await service.send_message(WORKSPACE_UUID, 'nonexistent-uuid', 'group', '123', {'test': 'data'})
async def test_send_message_invalid_message_chain_raises(self):
"""Raises Exception when message_chain_data is invalid."""
@@ -619,10 +634,11 @@ class TestBotServiceSendMessage:
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
service = BotService(ap)
+ service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
# Execute & Verify - invalid format should raise
with pytest.raises(Exception, match='Invalid message_chain format'):
- await service.send_message('bot-uuid', 'group', '123', {'invalid': 'format'})
+ await service.send_message(WORKSPACE_UUID, 'bot-uuid', 'group', '123', {'invalid': 'format'})
async def test_send_message_valid_call(self):
"""Sends message through adapter when all valid."""
@@ -636,6 +652,7 @@ class TestBotServiceSendMessage:
ap.platform_mgr.get_bot_by_uuid = AsyncMock(return_value=runtime_bot)
service = BotService(ap)
+ service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
# Execute with valid message chain format
message_chain_data = {'messages': [{'type': 'text', 'data': {'text': 'Hello'}}]}
@@ -644,7 +661,7 @@ class TestBotServiceSendMessage:
with patch('langbot_plugin.api.entities.builtin.platform.message.MessageChain') as MockMessageChain:
mock_chain = Mock()
MockMessageChain.model_validate = Mock(return_value=mock_chain)
- await service.send_message('bot-uuid', 'group', '123', message_chain_data)
+ await service.send_message(WORKSPACE_UUID, 'bot-uuid', 'group', '123', message_chain_data)
# Verify adapter.send_message was called
runtime_bot.adapter.send_message.assert_called_once_with('group', '123', mock_chain)
diff --git a/tests/unit_tests/api/service/test_knowledge_service.py b/tests/unit_tests/api/service/test_knowledge_service.py
index 1e0592b01..20d955f69 100644
--- a/tests/unit_tests/api/service/test_knowledge_service.py
+++ b/tests/unit_tests/api/service/test_knowledge_service.py
@@ -1,389 +1,581 @@
-"""Unit tests for API knowledge service.
-
-Tests cover:
-- Knowledge base CRUD operations
-- Capability checking
-- Knowledge engine discovery
-- File operations
-"""
+"""Tests for the tenant-aware knowledge service facade."""
from __future__ import annotations
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
import pytest
-from unittest.mock import Mock, AsyncMock
-from importlib import import_module
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.api.http.service.knowledge import KnowledgeService
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
-def get_knowledge_service_module():
- """Lazy import to avoid circular import issues."""
- return import_module('langbot.pkg.api.http.service.knowledge')
+CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=2,
+)
-def create_mock_app():
- """Create mock Application for testing."""
- mock_app = Mock()
- mock_app.logger = Mock()
- mock_app.rag_mgr = AsyncMock()
- mock_app.persistence_mgr = AsyncMock()
- mock_app.persistence_mgr.execute_async = AsyncMock()
- mock_app.persistence_mgr.serialize_model = Mock(return_value={})
- mock_app.plugin_connector = AsyncMock()
- mock_app.plugin_connector.is_enable_plugin = True
- return mock_app
+class _Rows:
+ def __init__(self, rows=()):
+ self.rows = list(rows)
+
+ def all(self):
+ return self.rows
+
+ def __iter__(self):
+ return iter(self.rows)
+def _app():
+ return SimpleNamespace(
+ logger=Mock(),
+ instance_config=SimpleNamespace(data={}),
+ rag_mgr=SimpleNamespace(
+ get_all_knowledge_base_details=AsyncMock(return_value=[]),
+ get_knowledge_base_details=AsyncMock(return_value=None),
+ create_knowledge_base=AsyncMock(),
+ remove_knowledge_base_from_runtime=AsyncMock(),
+ load_knowledge_base=AsyncMock(),
+ get_knowledge_base_by_uuid=AsyncMock(return_value=None),
+ delete_knowledge_base=AsyncMock(),
+ ),
+ persistence_mgr=SimpleNamespace(
+ execute_async=AsyncMock(return_value=_Rows()),
+ serialize_model=Mock(return_value={}),
+ ),
+ plugin_connector=SimpleNamespace(
+ is_enable_plugin=True,
+ require_workspace_context=AsyncMock(side_effect=lambda context: context),
+ get_rag_creation_schema=AsyncMock(return_value={}),
+ get_rag_retrieval_schema=AsyncMock(return_value={}),
+ list_knowledge_engines=AsyncMock(return_value=[]),
+ list_parsers=AsyncMock(return_value=[]),
+ ),
+ )
+
+
+@pytest.mark.asyncio
+async def test_list_and_get_forward_explicit_context():
+ app = _app()
+ app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb-a'}]
+ app.rag_mgr.get_knowledge_base_details.return_value = {'uuid': 'kb-a'}
+ service = KnowledgeService(app)
+
+ assert await service.get_knowledge_bases(CONTEXT) == [{'uuid': 'kb-a'}]
+ assert await service.get_knowledge_base(CONTEXT, 'kb-a') == {'uuid': 'kb-a'}
+ app.rag_mgr.get_all_knowledge_base_details.assert_awaited_once_with(CONTEXT)
+ app.rag_mgr.get_knowledge_base_details.assert_awaited_once_with(CONTEXT, 'kb-a')
+
+
+@pytest.mark.asyncio
+async def test_none_context_fails_closed_before_plugin_or_manager_access():
+ app = _app()
+ service = KnowledgeService(app)
+
+ with pytest.raises(WorkspaceRequiredError):
+ await service.get_knowledge_bases(None)
+ with pytest.raises(WorkspaceRequiredError):
+ await service.create_knowledge_base(None, {'knowledge_engine_plugin_id': 'author/engine'})
+ app.plugin_connector.get_rag_creation_schema.assert_not_awaited()
+ app.rag_mgr.create_knowledge_base.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_create_validates_schema_and_binds_context():
+ app = _app()
+ app.plugin_connector.get_rag_creation_schema.return_value = {
+ 'schema': [{'name': 'endpoint', 'label': {'en_US': 'Endpoint'}, 'required': True}]
+ }
+ app.rag_mgr.create_knowledge_base.return_value = SimpleNamespace(uuid='kb-created')
+ service = KnowledgeService(app)
+
+ with pytest.raises(ValueError, match='Endpoint is required'):
+ await service.create_knowledge_base(
+ CONTEXT,
+ {'knowledge_engine_plugin_id': 'author/engine'},
+ )
+
+ result = await service.create_knowledge_base(
+ CONTEXT,
+ {
+ 'name': 'KB',
+ 'description': 'desc',
+ 'knowledge_engine_plugin_id': 'author/engine',
+ 'creation_settings': {'endpoint': 'https://example.invalid'},
+ },
+ )
+ assert result == 'kb-created'
+ app.rag_mgr.create_knowledge_base.assert_awaited_once_with(
+ CONTEXT,
+ name='KB',
+ knowledge_engine_plugin_id='author/engine',
+ creation_settings={'endpoint': 'https://example.invalid'},
+ retrieval_settings={},
+ description='desc',
+ )
+
+
+@pytest.mark.asyncio
+async def test_create_enforces_workspace_knowledge_base_limit():
+ app = _app()
+ app.instance_config.data = {'system': {'limitation': {'max_knowledge_bases': 2}}}
+ app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb-a'}, {'uuid': 'kb-b'}]
+ service = KnowledgeService(app)
+
+ with pytest.raises(ValueError, match=r'Maximum number of knowledge bases \(2\) reached'):
+ await service.create_knowledge_base(
+ CONTEXT,
+ {'knowledge_engine_plugin_id': 'author/engine'},
+ )
+ app.rag_mgr.create_knowledge_base.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_update_rejects_guessed_uuid_and_scopes_reload():
+ app = _app()
+ service = KnowledgeService(app)
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await service.update_knowledge_base(CONTEXT, 'kb-other', {'name': 'stolen'})
+ app.persistence_mgr.execute_async.assert_not_awaited()
+
+ app.rag_mgr.get_knowledge_base_details.return_value = {'uuid': 'kb-a', 'workspace_uuid': 'workspace-a'}
+ await service.update_knowledge_base(CONTEXT, 'kb-a', {'name': 'updated', 'uuid': 'ignored'})
+ app.rag_mgr.remove_knowledge_base_from_runtime.assert_awaited_once_with(CONTEXT, 'kb-a')
+ app.rag_mgr.load_knowledge_base.assert_awaited_once_with(
+ CONTEXT,
+ {'uuid': 'kb-a', 'workspace_uuid': 'workspace-a'},
+ )
+
+
+@pytest.mark.asyncio
+async def test_runtime_retrieve_uses_execution_context():
+ app = _app()
+ entry = SimpleNamespace(model_dump=Mock(return_value={'id': 'entry-a'}))
+ runtime_kb = SimpleNamespace(retrieve=AsyncMock(return_value=[entry]))
+ app.rag_mgr.get_knowledge_base_by_uuid.return_value = runtime_kb
+ service = KnowledgeService(app)
+
+ assert await service.retrieve_knowledge_base(CONTEXT, 'kb-a', 'query', {'top_k': 3}) == [{'id': 'entry-a'}]
+ runtime_kb.retrieve.assert_awaited_once_with(CONTEXT, 'query', settings={'top_k': 3})
+
+
+@pytest.mark.asyncio
+async def test_runtime_retrieve_cross_workspace_uuid_is_not_found():
+ app = _app()
+ service = KnowledgeService(app)
+ with pytest.raises(WorkspaceNotFoundError):
+ await service.retrieve_knowledge_base(CONTEXT, 'kb-other', 'query')
+
+
+@pytest.mark.asyncio
+async def test_file_listing_checks_parent_knowledge_base_first():
+ app = _app()
+ service = KnowledgeService(app)
+ with pytest.raises(WorkspaceNotFoundError):
+ await service.get_files_by_knowledge_base(CONTEXT, 'kb-other')
+ app.persistence_mgr.execute_async.assert_not_awaited()
+
+ app.rag_mgr.get_knowledge_base_details.return_value = {'uuid': 'kb-a'}
+ row = SimpleNamespace(uuid='file-a')
+ app.persistence_mgr.execute_async.return_value = _Rows([row])
+ app.persistence_mgr.serialize_model.return_value = {'uuid': 'file-a'}
+ assert await service.get_files_by_knowledge_base(CONTEXT, 'kb-a') == [{'uuid': 'file-a'}]
+
+
+@pytest.mark.asyncio
+async def test_store_and_delete_file_require_runtime_parent_and_capability():
+ app = _app()
+ runtime_kb = SimpleNamespace(
+ store_file=AsyncMock(return_value='task-a'),
+ delete_file=AsyncMock(),
+ )
+ app.rag_mgr.get_knowledge_base_by_uuid.return_value = runtime_kb
+ app.rag_mgr.get_knowledge_base_details.return_value = {'knowledge_engine': {'capabilities': ['doc_ingestion']}}
+ service = KnowledgeService(app)
+
+ assert await service.store_file(CONTEXT, 'kb-a', 'upload.pdf', 'author/parser') == 'task-a'
+ runtime_kb.store_file.assert_awaited_once_with(CONTEXT, 'upload.pdf', parser_plugin_id='author/parser')
+ await service.delete_file(CONTEXT, 'kb-a', 'file-a')
+ runtime_kb.delete_file.assert_awaited_once_with(CONTEXT, 'file-a')
+
+
+@pytest.mark.asyncio
+async def test_delete_knowledge_base_rejects_cross_workspace_uuid():
+ app = _app()
+ service = KnowledgeService(app)
+ with pytest.raises(WorkspaceNotFoundError):
+ await service.delete_knowledge_base(CONTEXT, 'kb-other')
+ app.rag_mgr.delete_knowledge_base.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_engine_and_parser_discovery_require_context_and_filter_results():
+ app = _app()
+ app.plugin_connector.list_knowledge_engines.return_value = [{'plugin_id': 'author/engine'}]
+ app.plugin_connector.list_parsers.return_value = [
+ {'id': 'text', 'supported_mime_types': ['text/plain']},
+ {'id': 'pdf', 'supported_mime_types': ['application/pdf']},
+ ]
+ service = KnowledgeService(app)
+
+ assert await service.list_knowledge_engines(CONTEXT) == [{'plugin_id': 'author/engine'}]
+ assert await service.list_parsers(CONTEXT, 'application/pdf') == [
+ {'id': 'pdf', 'supported_mime_types': ['application/pdf']}
+ ]
+ with pytest.raises(WorkspaceRequiredError):
+ await service.list_parsers(None)
+
+
+@pytest.mark.asyncio
+async def test_engine_discovery_rejects_connector_workspace_or_generation_mismatch():
+ app = _app()
+ app.plugin_connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
+ service = KnowledgeService(app)
+
+ with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
+ await service.list_knowledge_engines(CONTEXT)
+
+ app.plugin_connector.list_knowledge_engines.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_schema_validation_refences_before_second_runtime_call():
+ app = _app()
+ app.plugin_connector.require_workspace_context.side_effect = [
+ CONTEXT,
+ WorkspaceNotFoundError('Plugin resource not found'),
+ ]
+ service = KnowledgeService(app)
+
+ with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
+ await service.create_knowledge_base(
+ CONTEXT,
+ {'knowledge_engine_plugin_id': 'author/engine'},
+ )
+
+ app.plugin_connector.get_rag_creation_schema.assert_awaited_once()
+ app.plugin_connector.get_rag_retrieval_schema.assert_not_awaited()
+ app.rag_mgr.create_knowledge_base.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_engine_schemas_are_context_gated_and_fail_soft_on_connector_error():
+ app = _app()
+ app.plugin_connector.get_rag_creation_schema.return_value = {'schema': ['creation']}
+ app.plugin_connector.get_rag_retrieval_schema.side_effect = RuntimeError('offline')
+ service = KnowledgeService(app)
+
+ assert await service.get_engine_creation_schema(CONTEXT, 'author/engine') == {'schema': ['creation']}
+ assert await service.get_engine_retrieval_schema(CONTEXT, 'author/engine') == {}
+ with pytest.raises(WorkspaceRequiredError):
+ await service.get_engine_creation_schema(None, 'author/engine')
+
+
+# Preserve the original service regression matrix with the new explicit
+# Workspace context. These intentionally overlap a few isolation-focused
+# tests above so legacy business behavior cannot disappear behind new guards.
class TestKnowledgeServiceInit:
- """Tests for KnowledgeService initialization."""
-
def test_init_stores_app_reference(self):
- """Test that __init__ stores Application reference."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
+ app = _app()
- service = knowledge_module.KnowledgeService(mock_app)
+ service = KnowledgeService(app)
- assert service.ap is mock_app
+ assert service.ap is app
class TestGetKnowledgeBases:
- """Tests for get_knowledge_bases method."""
-
@pytest.mark.asyncio
async def test_returns_all_kb_details(self):
- """Test that it returns all knowledge base details."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.rag_mgr.get_all_knowledge_base_details = AsyncMock(return_value=[{'uuid': 'kb1', 'name': 'KB1'}])
+ app = _app()
+ app.rag_mgr.get_all_knowledge_base_details.return_value = [{'uuid': 'kb1', 'name': 'KB1'}]
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.get_knowledge_bases()
+ result = await KnowledgeService(app).get_knowledge_bases(CONTEXT)
- assert len(result) == 1
- assert result[0]['uuid'] == 'kb1'
+ assert result == [{'uuid': 'kb1', 'name': 'KB1'}]
+ app.rag_mgr.get_all_knowledge_base_details.assert_awaited_once_with(CONTEXT)
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_kbs(self):
- """Test that it returns empty list when no knowledge bases."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.rag_mgr.get_all_knowledge_base_details = AsyncMock(return_value=[])
+ app = _app()
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.get_knowledge_bases()
-
- assert result == []
+ assert await KnowledgeService(app).get_knowledge_bases(CONTEXT) == []
class TestGetKnowledgeBase:
- """Tests for get_knowledge_base method."""
-
@pytest.mark.asyncio
async def test_returns_kb_details_by_uuid(self):
- """Test that it returns specific KB details."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value={'uuid': 'kb1', 'name': 'KB1'})
+ app = _app()
+ app.rag_mgr.get_knowledge_base_details.return_value = {'uuid': 'kb1', 'name': 'KB1'}
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.get_knowledge_base('kb1')
+ result = await KnowledgeService(app).get_knowledge_base(CONTEXT, 'kb1')
- assert result['uuid'] == 'kb1'
+ assert result == {'uuid': 'kb1', 'name': 'KB1'}
@pytest.mark.asyncio
async def test_returns_none_when_not_found(self):
- """Test that it returns None when KB not found."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value=None)
+ app = _app()
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.get_knowledge_base('nonexistent')
-
- assert result is None
+ assert await KnowledgeService(app).get_knowledge_base(CONTEXT, 'nonexistent') is None
class TestCreateKnowledgeBase:
- """Tests for create_knowledge_base method."""
-
@pytest.mark.asyncio
async def test_creates_kb_with_required_fields(self):
- """Test creating KB with required plugin ID."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_kb = Mock()
- mock_kb.uuid = 'new_kb_uuid'
- mock_app.rag_mgr.create_knowledge_base = AsyncMock(return_value=mock_kb)
-
- service = knowledge_module.KnowledgeService(mock_app)
+ app = _app()
+ app.rag_mgr.create_knowledge_base.return_value = SimpleNamespace(uuid='new_kb_uuid')
+ service = KnowledgeService(app)
kb_data = {
'name': 'Test KB',
'knowledge_engine_plugin_id': 'author/engine',
'description': 'Test description',
}
- result = await service.create_knowledge_base(kb_data)
+ result = await service.create_knowledge_base(CONTEXT, kb_data)
assert result == 'new_kb_uuid'
- mock_app.rag_mgr.create_knowledge_base.assert_called_once()
+ app.rag_mgr.create_knowledge_base.assert_awaited_once_with(
+ CONTEXT,
+ name='Test KB',
+ knowledge_engine_plugin_id='author/engine',
+ creation_settings={},
+ retrieval_settings={},
+ description='Test description',
+ )
@pytest.mark.asyncio
async def test_raises_when_missing_plugin_id(self):
- """Test that ValueError is raised when plugin ID missing."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
+ app = _app()
- service = knowledge_module.KnowledgeService(mock_app)
+ with pytest.raises(ValueError, match='knowledge_engine_plugin_id is required'):
+ await KnowledgeService(app).create_knowledge_base(CONTEXT, {'name': 'Test'})
- with pytest.raises(ValueError) as exc_info:
- await service.create_knowledge_base({'name': 'Test'})
-
- assert 'knowledge_engine_plugin_id is required' in str(exc_info.value)
+ app.rag_mgr.create_knowledge_base.assert_not_awaited()
@pytest.mark.asyncio
async def test_creates_with_default_name(self):
- """Test that KB is created with default name if not provided."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_kb = Mock()
- mock_kb.uuid = 'new_kb_uuid'
- mock_app.rag_mgr.create_knowledge_base = AsyncMock(return_value=mock_kb)
+ app = _app()
+ app.rag_mgr.create_knowledge_base.return_value = SimpleNamespace(uuid='new_kb_uuid')
- service = knowledge_module.KnowledgeService(mock_app)
+ await KnowledgeService(app).create_knowledge_base(
+ CONTEXT,
+ {'knowledge_engine_plugin_id': 'author/engine'},
+ )
- await service.create_knowledge_base({'knowledge_engine_plugin_id': 'author/engine'})
-
- # Check that default name 'Untitled' was used
- call_args = mock_app.rag_mgr.create_knowledge_base.call_args
- assert call_args.kwargs['name'] == 'Untitled'
+ assert app.rag_mgr.create_knowledge_base.await_args.kwargs['name'] == 'Untitled'
class TestUpdateKnowledgeBase:
- """Tests for update_knowledge_base method."""
-
@pytest.mark.asyncio
async def test_updates_mutable_fields_only(self):
- """Test that only mutable fields are updated."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value={'uuid': 'kb1', 'name': 'Updated'})
- mock_app.rag_mgr.remove_knowledge_base_from_runtime = AsyncMock()
- mock_app.rag_mgr.load_knowledge_base = AsyncMock()
+ app = _app()
+ app.rag_mgr.get_knowledge_base_details.return_value = {'uuid': 'kb1', 'name': 'Updated'}
+ service = KnowledgeService(app)
- service = knowledge_module.KnowledgeService(mock_app)
-
- # Pass both mutable and immutable fields
await service.update_knowledge_base(
+ CONTEXT,
'kb1',
{
'name': 'New Name',
'description': 'New desc',
- 'uuid': 'should_be_filtered', # immutable
+ 'uuid': 'should_be_filtered',
},
)
- # Check that only mutable fields were passed to update
- call_args = mock_app.persistence_mgr.execute_async.call_args
- assert call_args is not None
+ update_statement = app.persistence_mgr.execute_async.await_args_list[0].args[0]
+ params = update_statement.compile().params
+ assert params['name'] == 'New Name'
+ assert params['description'] == 'New desc'
+ assert 'uuid' not in params
+ app.rag_mgr.remove_knowledge_base_from_runtime.assert_awaited_once_with(CONTEXT, 'kb1')
@pytest.mark.asyncio
async def test_returns_early_when_no_mutable_fields(self):
- """Test that update returns early when no mutable fields provided."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
+ app = _app()
+ app.rag_mgr.get_knowledge_base_details.return_value = {'uuid': 'kb1'}
- service = knowledge_module.KnowledgeService(mock_app)
+ await KnowledgeService(app).update_knowledge_base(
+ CONTEXT,
+ 'kb1',
+ {'uuid': 'should_be_filtered'},
+ )
- # Pass only immutable fields
- await service.update_knowledge_base('kb1', {'uuid': 'should_be_filtered'})
-
- # No DB update should be called
- mock_app.persistence_mgr.execute_async.assert_not_called()
+ app.persistence_mgr.execute_async.assert_not_awaited()
+ app.rag_mgr.remove_knowledge_base_from_runtime.assert_not_awaited()
class TestCheckDocCapability:
- """Tests for _check_doc_capability method."""
-
@pytest.mark.asyncio
async def test_passes_when_capability_supported(self):
- """Test that check passes when doc_ingestion capability exists."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(
- return_value={'knowledge_engine': {'capabilities': ['doc_ingestion']}}
- )
+ app = _app()
+ app.rag_mgr.get_knowledge_base_details.return_value = {'knowledge_engine': {'capabilities': ['doc_ingestion']}}
- service = knowledge_module.KnowledgeService(mock_app)
-
- await service._check_doc_capability('kb1', 'document upload')
-
- # No exception raised means success
+ await KnowledgeService(app)._check_doc_capability(CONTEXT, 'kb1', 'document upload')
@pytest.mark.asyncio
async def test_raises_when_kb_not_found(self):
- """Test that Exception is raised when KB not found."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(return_value=None)
+ app = _app()
- service = knowledge_module.KnowledgeService(mock_app)
-
- with pytest.raises(Exception) as exc_info:
- await service._check_doc_capability('nonexistent', 'test operation')
-
- assert 'Knowledge base not found' in str(exc_info.value)
+ with pytest.raises(WorkspaceNotFoundError, match='Knowledge base not found'):
+ await KnowledgeService(app)._check_doc_capability(
+ CONTEXT,
+ 'nonexistent',
+ 'test operation',
+ )
@pytest.mark.asyncio
async def test_raises_when_capability_not_supported(self):
- """Test that Exception is raised when doc_ingestion not in capabilities."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.rag_mgr.get_knowledge_base_details = AsyncMock(
- return_value={'knowledge_engine': {'capabilities': ['other_capability']}}
- )
+ app = _app()
+ app.rag_mgr.get_knowledge_base_details.return_value = {
+ 'knowledge_engine': {'capabilities': ['other_capability']}
+ }
- service = knowledge_module.KnowledgeService(mock_app)
-
- with pytest.raises(Exception) as exc_info:
- await service._check_doc_capability('kb1', 'document upload')
-
- assert 'does not support document upload' in str(exc_info.value)
+ with pytest.raises(Exception, match='does not support document upload'):
+ await KnowledgeService(app)._check_doc_capability(
+ CONTEXT,
+ 'kb1',
+ 'document upload',
+ )
class TestListKnowledgeEngines:
- """Tests for list_knowledge_engines method."""
-
@pytest.mark.asyncio
async def test_returns_engines_from_plugin_connector(self):
- """Test that it returns knowledge engines from plugin connector."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
- return_value=[{'id': 'engine1', 'name': 'Engine 1'}]
- )
+ app = _app()
+ app.plugin_connector.list_knowledge_engines.return_value = [{'id': 'engine1', 'name': 'Engine 1'}]
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.list_knowledge_engines()
+ result = await KnowledgeService(app).list_knowledge_engines(CONTEXT)
- assert len(result) == 1
- assert result[0]['id'] == 'engine1'
+ assert result == [{'id': 'engine1', 'name': 'Engine 1'}]
@pytest.mark.asyncio
async def test_returns_empty_when_plugin_disabled(self):
- """Test that it returns empty list when plugin disabled."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.is_enable_plugin = False
+ app = _app()
+ app.plugin_connector.is_enable_plugin = False
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.list_knowledge_engines()
-
- assert result == []
+ assert await KnowledgeService(app).list_knowledge_engines(CONTEXT) == []
+ app.plugin_connector.list_knowledge_engines.assert_not_awaited()
@pytest.mark.asyncio
async def test_returns_empty_on_exception(self):
- """Test that it returns empty list and logs warning on exception."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.list_knowledge_engines = AsyncMock(side_effect=Exception('Connection error'))
+ app = _app()
+ app.plugin_connector.list_knowledge_engines.side_effect = RuntimeError('Connection error')
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.list_knowledge_engines()
-
- assert result == []
- mock_app.logger.warning.assert_called_once()
+ assert await KnowledgeService(app).list_knowledge_engines(CONTEXT) == []
+ app.logger.warning.assert_called_once()
class TestListParsers:
- """Tests for list_parsers method."""
-
@pytest.mark.asyncio
async def test_returns_all_parsers(self):
- """Test that it returns all parsers when no MIME type filter."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.list_parsers = AsyncMock(
- return_value=[
- {'id': 'parser1', 'supported_mime_types': ['text/plain']},
- {'id': 'parser2', 'supported_mime_types': ['application/pdf']},
- ]
- )
+ app = _app()
+ app.plugin_connector.list_parsers.return_value = [
+ {'id': 'parser1', 'supported_mime_types': ['text/plain']},
+ {'id': 'parser2', 'supported_mime_types': ['application/pdf']},
+ ]
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.list_parsers()
+ result = await KnowledgeService(app).list_parsers(CONTEXT)
assert len(result) == 2
@pytest.mark.asyncio
async def test_filters_by_mime_type(self):
- """Test that it filters parsers by MIME type."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.list_parsers = AsyncMock(
- return_value=[
- {'id': 'parser1', 'supported_mime_types': ['text/plain']},
- {'id': 'parser2', 'supported_mime_types': ['application/pdf']},
- ]
- )
+ app = _app()
+ app.plugin_connector.list_parsers.return_value = [
+ {'id': 'parser1', 'supported_mime_types': ['text/plain']},
+ {'id': 'parser2', 'supported_mime_types': ['application/pdf']},
+ ]
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.list_parsers(mime_type='application/pdf')
+ result = await KnowledgeService(app).list_parsers(CONTEXT, 'application/pdf')
- assert len(result) == 1
- assert result[0]['id'] == 'parser2'
+ assert result == [{'id': 'parser2', 'supported_mime_types': ['application/pdf']}]
@pytest.mark.asyncio
async def test_returns_empty_when_plugin_disabled(self):
- """Test that it returns empty list when plugin disabled."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.is_enable_plugin = False
+ app = _app()
+ app.plugin_connector.is_enable_plugin = False
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.list_parsers()
-
- assert result == []
+ assert await KnowledgeService(app).list_parsers(CONTEXT) == []
+ app.plugin_connector.list_parsers.assert_not_awaited()
class TestGetEngineSchemas:
- """Tests for get_engine_creation_schema and get_engine_retrieval_schema."""
-
@pytest.mark.asyncio
async def test_returns_creation_schema(self):
- """Test that it returns creation schema for engine."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.get_rag_creation_schema = AsyncMock(
- return_value={'properties': {'name': {'type': 'string'}}}
- )
+ app = _app()
+ app.plugin_connector.get_rag_creation_schema.return_value = {'properties': {'name': {'type': 'string'}}}
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.get_engine_creation_schema('author/engine')
+ result = await KnowledgeService(app).get_engine_creation_schema(
+ CONTEXT,
+ 'author/engine',
+ )
assert 'properties' in result
@pytest.mark.asyncio
async def test_returns_retrieval_schema(self):
- """Test that it returns retrieval schema for engine."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.get_rag_retrieval_schema = AsyncMock(
- return_value={'properties': {'top_k': {'type': 'integer'}}}
- )
+ app = _app()
+ app.plugin_connector.get_rag_retrieval_schema.return_value = {'properties': {'top_k': {'type': 'integer'}}}
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.get_engine_retrieval_schema('author/engine')
+ result = await KnowledgeService(app).get_engine_retrieval_schema(
+ CONTEXT,
+ 'author/engine',
+ )
assert 'properties' in result
@pytest.mark.asyncio
async def test_returns_empty_dict_on_exception(self):
- """Test that it returns empty dict and logs warning on exception."""
- knowledge_module = get_knowledge_service_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.get_rag_creation_schema = AsyncMock(side_effect=Exception('Plugin error'))
+ app = _app()
+ app.plugin_connector.get_rag_creation_schema.side_effect = RuntimeError('Plugin error')
- service = knowledge_module.KnowledgeService(mock_app)
- result = await service.get_engine_creation_schema('author/engine')
+ result = await KnowledgeService(app).get_engine_creation_schema(
+ CONTEXT,
+ 'author/engine',
+ )
assert result == {}
- mock_app.logger.warning.assert_called_once()
+ app.logger.warning.assert_called_once()
+
+
+class TestKnowledgeBaseSecretViews:
+ @pytest.mark.asyncio
+ async def test_creation_settings_are_redacted_for_resource_view_only(self):
+ app = _app()
+ raw = {
+ 'uuid': 'kb-secret',
+ 'creation_settings': {
+ 'dify_apikey': 'dify-secret',
+ 'headers': {'Authorization': 'Bearer secret'},
+ },
+ }
+ app.rag_mgr.get_all_knowledge_base_details.return_value = [raw]
+ service = KnowledgeService(app)
+
+ redacted = await service.get_knowledge_bases(CONTEXT)
+ manager_view = await service.get_knowledge_bases(CONTEXT, include_secret=True)
+
+ assert redacted[0]['creation_settings']['dify_apikey'] == '***'
+ assert redacted[0]['creation_settings']['headers']['Authorization'] == '***'
+ assert manager_view[0]['creation_settings']['dify_apikey'] == 'dify-secret'
+ assert raw['creation_settings']['dify_apikey'] == 'dify-secret'
+
+ @pytest.mark.asyncio
+ async def test_new_masked_creation_secret_is_rejected(self):
+ app = _app()
+
+ with pytest.raises(ValueError, match='no existing value'):
+ await KnowledgeService(app).create_knowledge_base(
+ CONTEXT,
+ {
+ 'knowledge_engine_plugin_id': 'author/engine',
+ 'creation_settings': {'dify_apikey': '***'},
+ },
+ )
+
+ app.rag_mgr.create_knowledge_base.assert_not_awaited()
diff --git a/tests/unit_tests/api/service/test_maintenance_service.py b/tests/unit_tests/api/service/test_maintenance_service.py
index 8d5b5b0df..67aa3e66e 100644
--- a/tests/unit_tests/api/service/test_maintenance_service.py
+++ b/tests/unit_tests/api/service/test_maintenance_service.py
@@ -13,16 +13,39 @@ Source: src/langbot/pkg/api/http/service/maintenance.py
from __future__ import annotations
+import contextlib
import pytest
from unittest.mock import AsyncMock, Mock, patch, MagicMock
from types import SimpleNamespace
import datetime
from pathlib import Path
+import sqlalchemy
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.service.maintenance import MaintenanceService
+from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.bstorage import BinaryStorage
+from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
+from langbot.pkg.entity.persistence.workspace import Workspace
pytestmark = pytest.mark.asyncio
+TEST_CONTEXT = ExecutionContext(
+ instance_uuid='test-instance',
+ workspace_uuid='test-workspace',
+ placement_generation=1,
+)
+
+
+@pytest.fixture(autouse=True)
+def assume_oss_singleton(monkeypatch):
+ async def is_oss_singleton(_self, _context):
+ return True
+
+ monkeypatch.setattr(MaintenanceService, '_is_oss_singleton', is_oss_singleton)
def _create_mock_result(scalar_value=None):
@@ -32,6 +55,14 @@ def _create_mock_result(scalar_value=None):
return result
+def _scoped_storage_manager():
+ prefix = 'instances/i/workspaces/w/generations/1/owners/upload/o/'
+ return SimpleNamespace(
+ scoped_prefix=Mock(return_value=prefix),
+ is_scoped_object_key=Mock(side_effect=lambda key, **_: key == f'{prefix}uploaded_file.txt'),
+ )
+
+
class TestMaintenanceServiceCleanupExpiredFiles:
"""Tests for cleanup_expired_files method."""
@@ -39,6 +70,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
"""Uses default retention days when config not set."""
# Setup
ap = SimpleNamespace()
+ ap.storage_mgr = _scoped_storage_manager()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {}
ap.storage_mgr = SimpleNamespace()
@@ -58,7 +90,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async!
# Execute
- result = await service.cleanup_expired_files()
+ result = await service.cleanup_expired_files(TEST_CONTEXT)
# Verify - returns counts
assert 'uploaded_files' in result
@@ -95,7 +127,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
service._cleanup_expired_log_files = Mock(return_value=3) # NOT async
# Execute
- result = await service.cleanup_expired_files()
+ result = await service.cleanup_expired_files(TEST_CONTEXT)
# Verify
assert result['uploaded_files'] == 2
@@ -124,7 +156,7 @@ class TestMaintenanceServiceCleanupExpiredFiles:
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async
# Execute
- result = await service.cleanup_expired_files()
+ result = await service.cleanup_expired_files(TEST_CONTEXT)
# Verify
assert result['uploaded_files'] == 1
@@ -159,12 +191,57 @@ class TestMaintenanceServiceCleanupExpiredFiles:
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async
# Execute
- result = await service.cleanup_expired_files()
+ result = await service.cleanup_expired_files(TEST_CONTEXT)
# Verify - warning logged, defaults used
assert ap.logger.warning.called
assert 'uploaded_files' in result
+ async def test_cloud_cleanup_carries_scope_without_holding_database_session(self):
+ class ScopeOnlyPersistenceManager:
+ mode = SimpleNamespace(value='cloud_runtime')
+
+ def __init__(self):
+ self.active_workspace = None
+
+ @contextlib.asynccontextmanager
+ async def tenant_scope(self, workspace_uuid):
+ self.active_workspace = workspace_uuid
+ try:
+ yield
+ finally:
+ self.active_workspace = None
+
+ def current_session(self):
+ return None
+
+ persistence_mgr = ScopeOnlyPersistenceManager()
+ application = SimpleNamespace(
+ persistence_mgr=persistence_mgr,
+ instance_config=SimpleNamespace(data={}),
+ logger=SimpleNamespace(warning=Mock()),
+ )
+ service = MaintenanceService(application)
+
+ async def cleanup_uploads(_context, _retention_days):
+ assert persistence_mgr.active_workspace == TEST_CONTEXT.workspace_uuid
+ assert persistence_mgr.current_session() is None
+ return 2
+
+ def cleanup_logs(_retention_days):
+ assert persistence_mgr.active_workspace == TEST_CONTEXT.workspace_uuid
+ assert persistence_mgr.current_session() is None
+ return 1
+
+ service._cleanup_expired_uploaded_files = cleanup_uploads
+ service._cleanup_expired_log_files = cleanup_logs
+
+ assert await service.cleanup_expired_files(TEST_CONTEXT) == {
+ 'uploaded_files': 2,
+ 'log_files': 1,
+ }
+ assert persistence_mgr.active_workspace is None
+
class TestMaintenanceServiceGetStorageAnalysis:
"""Tests for get_storage_analysis method."""
@@ -196,7 +273,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
service._expired_log_candidates = Mock(return_value=[])
# Execute
- result = await service.get_storage_analysis()
+ result = await service.get_storage_analysis(TEST_CONTEXT)
# Verify
assert 'generated_at' in result
@@ -229,7 +306,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
service._expired_log_candidates = Mock(return_value=[])
# Execute
- result = await service.get_storage_analysis()
+ result = await service.get_storage_analysis(TEST_CONTEXT)
# Verify - all sections present
sections = {s['key'] for s in result['sections']}
@@ -265,7 +342,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
service._expired_log_candidates = Mock(return_value=[])
# Execute
- result = await service.get_storage_analysis()
+ result = await service.get_storage_analysis(TEST_CONTEXT)
# Verify
assert result['database']['type'] == 'postgresql'
@@ -294,7 +371,7 @@ class TestMaintenanceServiceGetStorageAnalysis:
service._expired_log_candidates = Mock(return_value=[{'name': 'old_log', 'size_bytes': 50}])
# Execute
- result = await service.get_storage_analysis()
+ result = await service.get_storage_analysis(TEST_CONTEXT)
# Verify
assert len(result['cleanup_candidates']['uploaded_files']) == 1
@@ -316,7 +393,7 @@ class TestMaintenanceServiceMonitoringCounts:
service = MaintenanceService(ap)
# Execute
- result = await service._monitoring_counts()
+ result = await service._monitoring_counts(TEST_CONTEXT)
# Verify - all table keys present
assert 'messages' in result
@@ -338,7 +415,7 @@ class TestMaintenanceServiceMonitoringCounts:
service = MaintenanceService(ap)
# Execute
- result = await service._monitoring_counts()
+ result = await service._monitoring_counts(TEST_CONTEXT)
# Verify - all zero
assert all(v == 0 for v in result.values())
@@ -374,7 +451,7 @@ class TestMaintenanceServiceBinaryStorageStats:
service = MaintenanceService(ap)
# Execute
- result = await service._binary_storage_stats()
+ result = await service._binary_storage_stats(TEST_CONTEXT)
# Verify
assert result['count'] == 10
@@ -404,7 +481,7 @@ class TestMaintenanceServiceBinaryStorageStats:
service = MaintenanceService(ap)
# Execute
- result = await service._binary_storage_stats()
+ result = await service._binary_storage_stats(TEST_CONTEXT)
# Verify - warning logged, size_bytes None or 0
assert ap.logger.warning.called
@@ -618,11 +695,13 @@ class TestMaintenanceServiceIsUploadedFileKey:
"""Returns True for valid upload file key."""
# Setup
ap = SimpleNamespace()
+ ap.storage_mgr = _scoped_storage_manager()
service = MaintenanceService(ap)
# Execute - simple filename without path
- result = service._is_uploaded_file_key('uploaded_file.txt')
+ key = f'{ap.storage_mgr.scoped_prefix(TEST_CONTEXT, owner_type="upload")}uploaded_file.txt'
+ result = service._is_uploaded_file_key(TEST_CONTEXT, key)
# Verify
assert result is True
@@ -631,11 +710,12 @@ class TestMaintenanceServiceIsUploadedFileKey:
"""Returns False for key with path separator."""
# Setup
ap = SimpleNamespace()
+ ap.storage_mgr = _scoped_storage_manager()
service = MaintenanceService(ap)
# Execute - key with path
- result = service._is_uploaded_file_key('path/to/file.txt')
+ result = service._is_uploaded_file_key(TEST_CONTEXT, 'path/to/file.txt')
# Verify
assert result is False
@@ -644,11 +724,12 @@ class TestMaintenanceServiceIsUploadedFileKey:
"""Returns False for plugin config prefix."""
# Setup
ap = SimpleNamespace()
+ ap.storage_mgr = _scoped_storage_manager()
service = MaintenanceService(ap)
# Execute - plugin config file
- result = service._is_uploaded_file_key('plugin_config_some_plugin.json')
+ result = service._is_uploaded_file_key(TEST_CONTEXT, 'plugin_config_some_plugin.json')
# Verify
assert result is False
@@ -662,6 +743,7 @@ class TestMaintenanceServiceExpiredLogCandidates:
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
+ ap.storage_mgr = _scoped_storage_manager()
service = MaintenanceService(ap)
@@ -748,11 +830,12 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
+ ap.storage_mgr = _scoped_storage_manager()
service = MaintenanceService(ap)
with patch.object(Path, 'exists', return_value=False):
- result = service._expired_local_upload_candidates(7)
+ result = service._expired_local_upload_candidates(TEST_CONTEXT, 7)
# Verify
assert result == []
@@ -762,12 +845,10 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
+ ap.storage_mgr = _scoped_storage_manager()
service = MaintenanceService(ap)
- # Mock _is_uploaded_file_key
- service._is_uploaded_file_key = Mock(side_effect=lambda key: 'plugin_config_' not in key and '/' not in key)
-
- # Create mock files - one valid, one plugin config
+ # Create one file and one non-file entry under the scoped upload root.
mock_entry_valid = Mock(spec=Path)
mock_entry_valid.is_file = Mock(return_value=True)
mock_entry_valid.name = 'valid_upload.txt'
@@ -775,9 +856,10 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
mock_stat.st_size = 100
mock_stat.st_mtime = 0 # Very old
mock_entry_valid.stat = Mock(return_value=mock_stat)
+ mock_entry_valid.relative_to = Mock(return_value=Path('scoped/valid_upload.txt'))
mock_entry_plugin = Mock(spec=Path)
- mock_entry_plugin.is_file = Mock(return_value=True)
+ mock_entry_plugin.is_file = Mock(return_value=False)
mock_entry_plugin.name = 'plugin_config_test.json'
mock_stat2 = Mock()
mock_stat2.st_size = 200
@@ -785,23 +867,22 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
mock_entry_plugin.stat = Mock(return_value=mock_stat2)
with patch.object(Path, 'exists', return_value=True):
- with patch.object(Path, 'iterdir') as mock_iterdir:
- mock_iterdir.return_value = [mock_entry_valid, mock_entry_plugin]
- result = service._expired_local_upload_candidates(7)
+ with patch.object(Path, 'rglob') as mock_rglob:
+ mock_rglob.return_value = [mock_entry_valid, mock_entry_plugin]
+ result = service._expired_local_upload_candidates(TEST_CONTEXT, 7)
# Verify - only valid upload included
assert len(result) == 1
- assert result[0]['key'] == 'valid_upload.txt'
+ assert result[0]['key'] == 'scoped/valid_upload.txt'
def test_expired_local_upload_candidates_includes_path(self):
"""Includes path when include_paths=True."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
+ ap.storage_mgr = _scoped_storage_manager()
service = MaintenanceService(ap)
- service._is_uploaded_file_key = Mock(return_value=True)
-
mock_entry = Mock(spec=Path)
mock_entry.is_file = Mock(return_value=True)
mock_entry.name = 'old_file.txt'
@@ -810,11 +891,178 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
mock_stat.st_size = 100
mock_stat.st_mtime = 0
mock_entry.stat = Mock(return_value=mock_stat)
+ mock_entry.relative_to = Mock(return_value=Path('scoped/old_file.txt'))
with patch.object(Path, 'exists', return_value=True):
- with patch.object(Path, 'iterdir') as mock_iterdir:
- mock_iterdir.return_value = [mock_entry]
- result = service._expired_local_upload_candidates(7, include_paths=True)
+ with patch.object(Path, 'rglob') as mock_rglob:
+ mock_rglob.return_value = [mock_entry]
+ result = service._expired_local_upload_candidates(
+ TEST_CONTEXT,
+ 7,
+ include_paths=True,
+ )
# Verify - path included
assert 'path' in result[0]
+
+ def test_expired_local_upload_candidates_respects_run_limit(self):
+ ap = SimpleNamespace(
+ logger=SimpleNamespace(warning=Mock()),
+ storage_mgr=_scoped_storage_manager(),
+ instance_config=SimpleNamespace(data={'storage': {'cleanup': {'max_files_per_run': 2}}}),
+ )
+ service = MaintenanceService(ap)
+ entries = []
+ for index in range(3):
+ entry = Mock(spec=Path)
+ entry.is_file = Mock(return_value=True)
+ entry.stat = Mock(return_value=SimpleNamespace(st_size=100, st_mtime=0))
+ entry.relative_to = Mock(return_value=Path(f'scoped/old-{index}.txt'))
+ entries.append(entry)
+
+ with patch.object(Path, 'exists', return_value=True):
+ with patch.object(Path, 'rglob', return_value=entries):
+ result = service._expired_local_upload_candidates(TEST_CONTEXT, 7)
+
+ assert [item['key'] for item in result] == [
+ 'scoped/old-0.txt',
+ 'scoped/old-1.txt',
+ ]
+ ap.instance_config.data['storage']['cleanup']['max_files_per_run'] = 999999
+ assert service._max_files_per_run() == 10000
+
+
+ISOLATION_WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+ISOLATION_WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
+
+
+def _tenant_context(workspace_uuid: str) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid='instance',
+ workspace_uuid=workspace_uuid,
+ placement_generation=1,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ )
+
+
+class _RealPersistenceManager:
+ def __init__(self, engine):
+ self.engine = engine
+
+ async def execute_async(self, *args, **kwargs):
+ async with self.engine.connect() as connection:
+ result = await connection.execute(*args, **kwargs)
+ await connection.commit()
+ return result
+
+
+@pytest.fixture
+async def tenant_maintenance_service(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "maintenance.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ await connection.execute(
+ sqlalchemy.insert(Workspace),
+ [
+ {
+ 'uuid': ISOLATION_WORKSPACE_A,
+ 'instance_uuid': 'instance',
+ 'name': 'A',
+ 'slug': 'a',
+ 'source': 'cloud_projection',
+ },
+ {
+ 'uuid': ISOLATION_WORKSPACE_B,
+ 'instance_uuid': 'instance',
+ 'name': 'B',
+ 'slug': 'b',
+ 'source': 'cloud_projection',
+ },
+ ],
+ )
+ now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+ await connection.execute(
+ sqlalchemy.insert(MonitoringMessage),
+ [
+ {
+ 'id': 'message-a',
+ 'workspace_uuid': ISOLATION_WORKSPACE_A,
+ 'timestamp': now,
+ 'bot_id': 'bot',
+ 'bot_name': 'Bot',
+ 'pipeline_id': 'pipeline',
+ 'pipeline_name': 'Pipeline',
+ 'message_content': 'A',
+ 'session_id': 'same-session',
+ 'status': 'success',
+ 'level': 'info',
+ },
+ {
+ 'id': 'message-b',
+ 'workspace_uuid': ISOLATION_WORKSPACE_B,
+ 'timestamp': now,
+ 'bot_id': 'bot',
+ 'bot_name': 'Bot',
+ 'pipeline_id': 'pipeline',
+ 'pipeline_name': 'Pipeline',
+ 'message_content': 'B',
+ 'session_id': 'same-session',
+ 'status': 'success',
+ 'level': 'info',
+ },
+ ],
+ )
+ await connection.execute(
+ sqlalchemy.insert(BinaryStorage),
+ [
+ {
+ 'workspace_uuid': ISOLATION_WORKSPACE_A,
+ 'unique_key': 'a',
+ 'key': 'same',
+ 'owner_type': 'plugin',
+ 'owner': 'same',
+ 'value': b'aaa',
+ },
+ {
+ 'workspace_uuid': ISOLATION_WORKSPACE_B,
+ 'unique_key': 'b',
+ 'key': 'same',
+ 'owner_type': 'plugin',
+ 'owner': 'same',
+ 'value': b'bbbbb',
+ },
+ ],
+ )
+
+ application = SimpleNamespace(
+ persistence_mgr=_RealPersistenceManager(engine),
+ instance_config=SimpleNamespace(data={}),
+ logger=SimpleNamespace(warning=lambda *_: None),
+ )
+ yield MaintenanceService(application)
+ await engine.dispose()
+
+
+async def test_cleanup_requires_execution_context(tenant_maintenance_service):
+ with pytest.raises(WorkspaceRequiredError):
+ await tenant_maintenance_service.cleanup_expired_files(None)
+
+
+async def test_monitoring_counts_are_workspace_scoped(tenant_maintenance_service):
+ counts_a = await tenant_maintenance_service._monitoring_counts(_tenant_context(ISOLATION_WORKSPACE_A))
+ counts_b = await tenant_maintenance_service._monitoring_counts(_tenant_context(ISOLATION_WORKSPACE_B))
+ assert counts_a['messages'] == 1
+ assert counts_b['messages'] == 1
+
+
+async def test_binary_storage_stats_are_workspace_scoped(tenant_maintenance_service):
+ stats_a = await tenant_maintenance_service._binary_storage_stats(_tenant_context(ISOLATION_WORKSPACE_A))
+ stats_b = await tenant_maintenance_service._binary_storage_stats(_tenant_context(ISOLATION_WORKSPACE_B))
+ assert stats_a == {'count': 1, 'size_bytes': 3}
+ assert stats_b == {'count': 1, 'size_bytes': 5}
+
+
+async def test_path_helpers_handle_missing_paths(tenant_maintenance_service, tmp_path):
+ missing = tmp_path / 'missing'
+ assert tenant_maintenance_service._path_size(missing) == 0
+ assert tenant_maintenance_service._file_count(missing) == 0
diff --git a/tests/unit_tests/api/service/test_mcp_service.py b/tests/unit_tests/api/service/test_mcp_service.py
index ea08897a1..31c531e98 100644
--- a/tests/unit_tests/api/service/test_mcp_service.py
+++ b/tests/unit_tests/api/service/test_mcp_service.py
@@ -13,17 +13,62 @@ Source: src/langbot/pkg/api/http/service/mcp.py
from __future__ import annotations
+import asyncio
+import copy
import pytest
from unittest.mock import AsyncMock, Mock, MagicMock
from types import SimpleNamespace
import uuid
-from langbot.pkg.api.http.service.mcp import MCPService
+from langbot.pkg.api.http.authz import Permission
+from langbot.pkg.api.http.context import (
+ ExecutionContext,
+ PrincipalContext,
+ PrincipalType,
+ RequestContext,
+ WorkspaceContext,
+)
+from langbot.pkg.api.http.service.mcp import MCPService, redact_mcp_secrets, restore_mcp_secret_placeholders
+from langbot.pkg.core.taskmgr import TaskCapacityError
from langbot.pkg.entity.persistence.mcp import MCPServer
+from langbot.pkg.provider.tools.loaders.mcp_policy import MCPStdioDisabledError
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+
+_VIEWER_CONTEXT = RequestContext(
+ instance_uuid='instance-a',
+ placement_generation=1,
+ request_id='request-a',
+ auth_type='user_token',
+ principal=PrincipalContext(
+ principal_type=PrincipalType.ACCOUNT,
+ account_uuid='account-a',
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid='workspace-a',
+ membership_uuid='membership-a',
+ role='viewer',
+ permissions=frozenset({Permission.RESOURCE_VIEW.value}),
+ ),
+)
+
+
+def _service(ap: SimpleNamespace) -> MCPService:
+ ap.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid=_CONTEXT.instance_uuid))
+ )
+ if not hasattr(ap, 'logger'):
+ ap.logger = Mock()
+ return MCPService(ap)
+
def _create_mock_mcp_server(
server_uuid: str = None,
@@ -42,11 +87,13 @@ def _create_mock_mcp_server(
return server
-def _create_mock_result(items: list = None, first_item=None):
+def _create_mock_result(items: list = None, first_item=None, *, scalar_value=0, rowcount=1):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
+ result.scalar = Mock(return_value=scalar_value)
+ result.rowcount = rowcount
return result
@@ -64,10 +111,10 @@ class TestMCPServiceGetRuntimeInfo:
mock_session.get_runtime_info_dict = Mock(return_value={'status': 'running', 'tools': 5})
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session)
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- result = await service.get_runtime_info('test-server')
+ result = await service.get_runtime_info(_CONTEXT, 'test-server')
# Verify
assert result is not None
@@ -81,10 +128,10 @@ class TestMCPServiceGetRuntimeInfo:
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=None)
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- result = await service.get_runtime_info('nonexistent-server')
+ result = await service.get_runtime_info(_CONTEXT, 'nonexistent-server')
# Verify
assert result is None
@@ -101,12 +148,13 @@ class TestMCPServiceResources:
return_value=[{'uri_template': 'file:///{path}', 'name': 'files'}]
)
- service = MCPService(ap)
+ service = _service(ap)
+ service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'docs'}))
- result = await service.get_mcp_server_resource_templates('docs')
+ result = await service.get_mcp_server_resource_templates(_CONTEXT, 'docs')
assert result == [{'uri_template': 'file:///{path}', 'name': 'files'}]
- ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with('docs')
+ ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with(_CONTEXT, 'docs')
async def test_read_resource_envelope_uses_ui_preview_source(self):
ap = SimpleNamespace()
@@ -121,9 +169,11 @@ class TestMCPServiceResources:
}
)
- service = MCPService(ap)
+ service = _service(ap)
+ service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'docs'}))
result = await service.read_mcp_server_resource_envelope(
+ _CONTEXT,
'docs',
'file:///README.md',
max_bytes=4096,
@@ -132,6 +182,7 @@ class TestMCPServiceResources:
assert result['source'] == 'ui_preview'
ap.tool_mgr.mcp_tool_loader.read_resource_envelope.assert_awaited_once_with(
+ _CONTEXT,
'docs',
'file:///README.md',
include_blob=True,
@@ -156,12 +207,12 @@ class TestMCPServiceGetMCPServers:
'name': entity.name,
}
)
- ap.tool_mgr = None
+ ap.tool_mgr = SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=None)))
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- result = await service.get_mcp_servers()
+ result = await service.get_mcp_servers(_CONTEXT)
# Verify
assert result == []
@@ -185,12 +236,12 @@ class TestMCPServiceGetMCPServers:
'mode': entity.mode,
}
)
- ap.tool_mgr = None
+ ap.tool_mgr = SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=None)))
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- result = await service.get_mcp_servers()
+ result = await service.get_mcp_servers(_CONTEXT)
# Verify
assert len(result) == 2
@@ -215,21 +266,115 @@ class TestMCPServiceGetMCPServers:
)
ap.tool_mgr = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
- ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=None)
+ runtime_session = SimpleNamespace(get_runtime_info_dict=Mock(return_value={'status': 'connected'}))
+ ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=runtime_session)
- service = MCPService(ap)
- service.get_runtime_info = AsyncMock(return_value={'status': 'connected'})
+ service = _service(ap)
# Execute
- result = await service.get_mcp_servers(contain_runtime_info=True)
+ result = await service.get_mcp_servers(_CONTEXT, contain_runtime_info=True)
# Verify - runtime info included
assert result[0]['runtime_info'] == {'status': 'connected'}
+ async def test_resource_view_list_and_detail_redact_secrets_without_mutating_raw_data(self):
+ ap = SimpleNamespace()
+ ap.persistence_mgr = SimpleNamespace()
+ server = _create_mock_mcp_server(name='Secret Server')
+ serialized = {
+ 'uuid': 'secret-uuid',
+ 'name': 'Secret Server',
+ 'enable': True,
+ 'extra_args': {
+ 'url': (
+ 'https://mcp-user:mcp-password@mcp.invalid/connect'
+ '?token=url-secret&transport=streamable&sig=signed-secret'
+ ),
+ 'headers': {
+ 'Authorization': 'Bearer top-secret',
+ 'X-API-Key': 'api-secret',
+ 'Accept': 'application/json',
+ },
+ 'env': {
+ 'ACCESS_TOKEN': 'access-secret',
+ 'TOKENIZER': 'public-model-name',
+ },
+ 'credentials': {
+ 'username': 'service-user',
+ 'password': 'password-secret',
+ },
+ 'public_key': 'public-value',
+ },
+ }
+ original = copy.deepcopy(serialized)
+ ap.persistence_mgr.execute_async = AsyncMock(
+ side_effect=[
+ _create_mock_result([server]),
+ _create_mock_result(first_item=server),
+ ]
+ )
+ ap.persistence_mgr.serialize_model = Mock(return_value=serialized)
+ ap.tool_mgr = SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=None)))
+ service = _service(ap)
+
+ listed = await service.get_mcp_servers(_VIEWER_CONTEXT)
+ detail = await service.get_mcp_server_by_name(_VIEWER_CONTEXT, 'Secret Server')
+
+ for response in (listed[0], detail):
+ assert response['extra_args']['url'] == (
+ 'https://***@mcp.invalid/connect?token=***&transport=streamable&sig=***'
+ )
+ assert response['extra_args']['headers'] == {
+ 'Authorization': '***',
+ 'X-API-Key': '***',
+ 'Accept': 'application/json',
+ }
+ assert response['extra_args']['env'] == {
+ 'ACCESS_TOKEN': '***',
+ 'TOKENIZER': 'public-model-name',
+ }
+ assert response['extra_args']['credentials'] == {
+ 'username': '***',
+ 'password': '***',
+ }
+ assert response['extra_args']['public_key'] == 'public-value'
+ assert serialized == original
+
+ async def test_redacted_url_roundtrip_restores_persisted_credentials(self):
+ persisted = {
+ 'extra_args': {'url': 'https://mcp-user:mcp-password@mcp.invalid/connect?token=url-secret&transport=http'}
+ }
+
+ submitted = redact_mcp_secrets(persisted)
+
+ assert submitted['extra_args']['url'] == 'https://***@mcp.invalid/connect?token=***&transport=http'
+ assert restore_mcp_secret_placeholders(submitted, persisted) == persisted
+
class TestMCPServiceCreateMCPServer:
"""Tests for create_mcp_server method."""
+ async def test_create_stdio_rejected_by_independent_instance_gate(self):
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'mcp': {'stdio': {'enabled': False}},
+ 'system': {'limitation': {'max_extensions': -1}},
+ }
+ ),
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock()),
+ tool_mgr=None,
+ )
+ service = _service(ap)
+
+ with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'):
+ await service.create_mcp_server(
+ _CONTEXT,
+ {'name': 'local', 'mode': 'stdio', 'enable': True, 'extra_args': {}},
+ )
+
+ ap.persistence_mgr.execute_async.assert_not_awaited()
+
async def test_create_mcp_server_max_extensions_reached_raises(self):
"""Raises ValueError when max_extensions limit reached."""
# Setup
@@ -241,16 +386,20 @@ class TestMCPServiceCreateMCPServer:
ap.plugin_connector.list_plugins = AsyncMock(return_value=[Mock(), Mock()]) # 2 plugins
# Mock get_mcp_servers to return 0 servers (2 plugins already)
- mock_result = _create_mock_result([])
- ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
+ ap.persistence_mgr.execute_async = AsyncMock(
+ side_effect=[
+ _create_mock_result(scalar_value=0),
+ _create_mock_result(scalar_value=2),
+ ]
+ )
ap.persistence_mgr.serialize_model = Mock(return_value={})
- ap.tool_mgr = None
+ ap.tool_mgr = SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=None)))
- service = MCPService(ap)
+ service = _service(ap)
# Execute & Verify - 2 plugins + new server would exceed limit
with pytest.raises(ValueError, match='Maximum number of extensions'):
- await service.create_mcp_server({'name': 'New Server'})
+ await service.create_mcp_server(_CONTEXT, {'name': 'New Server'})
async def test_create_mcp_server_no_limit(self):
"""Creates MCP server without limit when max_extensions=-1."""
@@ -271,10 +420,10 @@ class TestMCPServiceCreateMCPServer:
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid'})
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- server_uuid = await service.create_mcp_server({'name': 'New Server'})
+ server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server'})
# Verify
assert server_uuid is not None
@@ -293,11 +442,11 @@ class TestMCPServiceCreateMCPServer:
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server))
ap.persistence_mgr.serialize_model = Mock(return_value={})
- service = MCPService(ap)
+ service = _service(ap)
# Execute & Verify
with pytest.raises(ValueError, match='MCP server already exists: Existing Server'):
- await service.create_mcp_server({'name': 'Existing Server'})
+ await service.create_mcp_server(_CONTEXT, {'name': 'Existing Server'})
async def test_create_mcp_server_loads_server(self):
"""Loads server into tool_mgr when enabled."""
@@ -330,14 +479,62 @@ class TestMCPServiceCreateMCPServer:
return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': True}
)
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- await service.create_mcp_server({'name': 'New Server', 'enable': True})
+ await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True})
# Verify - host_mcp_server was called
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
+ async def test_create_mcp_server_does_not_start_host_until_transaction_commits(self):
+ """The Runtime must not observe a server row that can still roll back."""
+
+ gate = asyncio.get_running_loop().create_future()
+
+ class PersistenceManagerStub:
+ def create_after_commit_gate(self):
+ return gate
+
+ ap = SimpleNamespace()
+ ap.persistence_mgr = PersistenceManagerStub()
+ ap.instance_config = SimpleNamespace(data={'system': {'limitation': {'max_extensions': -1}}})
+ observed = []
+
+ async def host_mcp_server(context, config):
+ observed.append((context, config))
+
+ ap.tool_mgr = SimpleNamespace(
+ mcp_tool_loader=SimpleNamespace(
+ host_mcp_server=host_mcp_server,
+ _hosted_mcp_tasks=[],
+ )
+ )
+ server_entity = _create_mock_mcp_server(server_uuid='new-uuid', enable=True)
+ results = [
+ _create_mock_result([]),
+ Mock(),
+ _create_mock_result(first_item=server_entity),
+ ]
+ ap.persistence_mgr.execute_async = AsyncMock(side_effect=results)
+ ap.persistence_mgr.serialize_model = Mock(
+ return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': True}
+ )
+ service = _service(ap)
+
+ await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True})
+ await asyncio.sleep(0)
+ assert observed == []
+
+ gate.set_result(None)
+ await ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks[0]
+ assert observed == [
+ (
+ _CONTEXT,
+ {'uuid': 'new-uuid', 'name': 'New Server', 'enable': True},
+ )
+ ]
+
async def test_create_mcp_server_disabled_no_load(self):
"""Does not load server when disabled."""
# Setup
@@ -351,10 +548,10 @@ class TestMCPServiceCreateMCPServer:
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid'})
- service = MCPService(ap)
+ service = _service(ap)
# Execute with enable=False
- server_uuid = await service.create_mcp_server({'name': 'New Server', 'enable': False})
+ server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': False})
# Verify - no tool_mgr load attempt
assert server_uuid is not None
@@ -379,13 +576,11 @@ class TestMCPServiceGetMCPServerByName:
'runtime_info': None,
}
)
- ap.tool_mgr = None
-
- service = MCPService(ap)
- service.get_runtime_info = AsyncMock(return_value=None)
+ ap.tool_mgr = SimpleNamespace(mcp_tool_loader=SimpleNamespace(get_session=Mock(return_value=None)))
+ service = _service(ap)
# Execute
- result = await service.get_mcp_server_by_name('Found Server')
+ result = await service.get_mcp_server_by_name(_CONTEXT, 'Found Server')
# Verify
assert result is not None
@@ -400,10 +595,10 @@ class TestMCPServiceGetMCPServerByName:
mock_result = _create_mock_result(first_item=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- result = await service.get_mcp_server_by_name('Nonexistent Server')
+ result = await service.get_mcp_server_by_name(_CONTEXT, 'Nonexistent Server')
# Verify
assert result is None
@@ -421,8 +616,10 @@ class TestMCPServiceUpdateMCPServer:
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {'Old Server': Mock()}
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
+ ap.tool_mgr.mcp_tool_loader.has_session = Mock(return_value=True)
old_server = _create_mock_mcp_server(name='Old Server', enable=True)
+ updated_server = _create_mock_mcp_server(name='Old Server', enable=False)
call_count = 0
@@ -431,14 +628,23 @@ class TestMCPServiceUpdateMCPServer:
call_count += 1
if call_count == 1:
return _create_mock_result(first_item=old_server)
- return Mock() # Update
+ if call_count == 2:
+ return _create_mock_result()
+ return _create_mock_result(first_item=updated_server)
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
+ ap.persistence_mgr.serialize_model = Mock(
+ side_effect=lambda _model, entity: {
+ 'uuid': 'test-uuid',
+ 'name': entity.name,
+ 'enable': entity.enable,
+ }
+ )
- service = MCPService(ap)
+ service = _service(ap)
# Execute - disable server
- await service.update_mcp_server('test-uuid', {'enable': False})
+ await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False})
# Verify - server was removed
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once()
@@ -453,6 +659,7 @@ class TestMCPServiceUpdateMCPServer:
ap.tool_mgr.mcp_tool_loader.sessions = {}
ap.tool_mgr.mcp_tool_loader.host_mcp_server = AsyncMock()
ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks = []
+ ap.tool_mgr.mcp_tool_loader.has_session = Mock(return_value=False)
old_server = _create_mock_mcp_server(name='Old Server', enable=False)
@@ -474,10 +681,10 @@ class TestMCPServiceUpdateMCPServer:
return_value={'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True}
)
- service = MCPService(ap)
+ service = _service(ap)
# Execute - enable server
- await service.update_mcp_server('test-uuid', {'enable': True})
+ await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': True})
# Verify - server was loaded
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
@@ -493,6 +700,7 @@ class TestMCPServiceUpdateMCPServer:
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
ap.tool_mgr.mcp_tool_loader.host_mcp_server = AsyncMock()
ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks = []
+ ap.tool_mgr.mcp_tool_loader.has_session = Mock(return_value=True)
old_server = _create_mock_mcp_server(name='Old Server', enable=True)
@@ -510,13 +718,13 @@ class TestMCPServiceUpdateMCPServer:
return_value={'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True}
)
- service = MCPService(ap)
+ service = _service(ap)
# Execute - update enabled server (keep enabled, update extra_args)
- await service.update_mcp_server('test-uuid', {'enable': True, 'extra_args': {'new': 'args'}})
+ await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': True, 'extra_args': {'new': 'args'}})
# Verify - remove and reload
- ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with('Old Server')
+ ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Old Server')
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
async def test_update_mcp_server_no_tool_mgr(self):
@@ -541,15 +749,99 @@ class TestMCPServiceUpdateMCPServer:
return Mock() # Update
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
+ ap.persistence_mgr.serialize_model = Mock(
+ return_value={
+ 'uuid': 'test-uuid',
+ 'name': 'Server',
+ 'enable': True,
+ }
+ )
- service = MCPService(ap)
+ service = _service(ap)
# Execute - should not raise
- await service.update_mcp_server('test-uuid', {'name': 'New Name'})
+ await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False})
# Verify - persistence was called
assert ap.persistence_mgr.execute_async.call_count >= 2
+ async def test_update_restores_existing_masked_secrets_and_preserves_explicit_changes(self):
+ ap = SimpleNamespace()
+ ap.persistence_mgr = SimpleNamespace()
+ ap.tool_mgr = SimpleNamespace(mcp_tool_loader=None)
+ old_server = _create_mock_mcp_server(name='Server', enable=True)
+ old_data = {
+ 'uuid': 'test-uuid',
+ 'name': 'Server',
+ 'enable': True,
+ 'mode': 'streamable_http',
+ 'extra_args': {
+ 'headers': {
+ 'Authorization': 'Bearer original-secret',
+ 'X-API-Key': 'original-api-key',
+ 'Cookie': 'original-cookie',
+ }
+ },
+ }
+ captured_updates = []
+
+ async def mock_execute(statement):
+ if not captured_updates:
+ captured_updates.append(None)
+ return _create_mock_result(first_item=old_server)
+ captured_updates[0] = statement
+ return _create_mock_result()
+
+ ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
+ ap.persistence_mgr.serialize_model = Mock(return_value=old_data)
+ service = _service(ap)
+
+ await service.update_mcp_server(
+ _CONTEXT,
+ 'test-uuid',
+ {
+ 'extra_args': {
+ 'headers': {
+ 'Authorization': '***',
+ 'X-API-Key': 'replacement-api-key',
+ 'Cookie': '',
+ }
+ }
+ },
+ )
+
+ persisted = captured_updates[0].compile().params['extra_args']
+ assert persisted['headers'] == {
+ 'Authorization': 'Bearer original-secret',
+ 'X-API-Key': 'replacement-api-key',
+ 'Cookie': '',
+ }
+
+ async def test_update_rejects_masked_secret_without_existing_value(self):
+ ap = SimpleNamespace()
+ ap.persistence_mgr = SimpleNamespace()
+ ap.tool_mgr = SimpleNamespace(mcp_tool_loader=None)
+ old_server = _create_mock_mcp_server(name='Server', enable=True)
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=old_server))
+ ap.persistence_mgr.serialize_model = Mock(
+ return_value={
+ 'uuid': 'test-uuid',
+ 'name': 'Server',
+ 'enable': True,
+ 'extra_args': {'headers': {'Accept': 'application/json'}},
+ }
+ )
+ service = _service(ap)
+
+ with pytest.raises(ValueError, match='Masked MCP secret has no existing value'):
+ await service.update_mcp_server(
+ _CONTEXT,
+ 'test-uuid',
+ {'extra_args': {'headers': {'Authorization': '***'}}},
+ )
+
+ assert ap.persistence_mgr.execute_async.await_count == 1
+
class TestMCPServiceDeleteMCPServer:
"""Tests for delete_mcp_server method."""
@@ -563,6 +855,7 @@ class TestMCPServiceDeleteMCPServer:
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {'Server to Delete': Mock()}
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
+ ap.tool_mgr.mcp_tool_loader.has_session = Mock(return_value=True)
server = _create_mock_mcp_server(name='Server to Delete')
@@ -576,14 +869,21 @@ class TestMCPServiceDeleteMCPServer:
return Mock() # Delete
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
+ ap.persistence_mgr.serialize_model = Mock(
+ return_value={
+ 'uuid': 'test-uuid',
+ 'name': 'Server to Delete',
+ 'enable': True,
+ }
+ )
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- await service.delete_mcp_server('test-uuid')
+ await service.delete_mcp_server(_CONTEXT, 'test-uuid')
# Verify
- ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with('Server to Delete')
+ ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Server to Delete')
ap.persistence_mgr.execute_async.assert_called()
async def test_delete_mcp_server_not_in_sessions(self):
@@ -595,6 +895,7 @@ class TestMCPServiceDeleteMCPServer:
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {} # Server not in sessions
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
+ ap.tool_mgr.mcp_tool_loader.has_session = Mock(return_value=False)
server = _create_mock_mcp_server(name='Not in Sessions')
@@ -608,11 +909,18 @@ class TestMCPServiceDeleteMCPServer:
return Mock()
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
+ ap.persistence_mgr.serialize_model = Mock(
+ return_value={
+ 'uuid': 'test-uuid',
+ 'name': 'Not in Sessions',
+ 'enable': True,
+ }
+ )
- service = MCPService(ap)
+ service = _service(ap)
# Execute
- await service.delete_mcp_server('test-uuid')
+ await service.delete_mcp_server(_CONTEXT, 'test-uuid')
# Verify - remove not called (server not in sessions)
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_called()
@@ -626,6 +934,7 @@ class TestMCPServiceDeleteMCPServer:
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.sessions = {}
ap.tool_mgr.mcp_tool_loader.remove_mcp_server = AsyncMock()
+ ap.tool_mgr.mcp_tool_loader.has_session = Mock(return_value=False)
# No server found
call_count = 0
@@ -639,18 +948,35 @@ class TestMCPServiceDeleteMCPServer:
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
- service = MCPService(ap)
+ service = _service(ap)
- # Execute - should not raise
- await service.delete_mcp_server('nonexistent-uuid')
+ with pytest.raises(WorkspaceNotFoundError, match='MCP server not found'):
+ await service.delete_mcp_server(_CONTEXT, 'nonexistent-uuid')
- # Verify - delete was called regardless
- ap.persistence_mgr.execute_async.assert_called()
+ assert ap.persistence_mgr.execute_async.await_count == 1
class TestMCPServiceTestMCPServer:
"""Tests for test_mcp_server method."""
+ async def test_transient_stdio_test_rejected_by_instance_gate(self):
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'mcp': {'stdio': {'enabled': False}}}),
+ tool_mgr=SimpleNamespace(mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock())),
+ task_mgr=SimpleNamespace(create_user_task=Mock()),
+ )
+ service = _service(ap)
+
+ with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'):
+ await service.test_mcp_server(
+ _CONTEXT,
+ '_',
+ {'name': 'local', 'mode': 'stdio', 'enable': True, 'extra_args': {}},
+ )
+
+ ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_not_awaited()
+ ap.task_mgr.create_user_task.assert_not_called()
+
async def test_test_mcp_server_existing_server(self):
"""Tests existing MCP server connection."""
# Setup
@@ -667,12 +993,18 @@ class TestMCPServiceTestMCPServer:
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session)
ap.task_mgr = SimpleNamespace()
- ap.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id=123))
- service = MCPService(ap)
+ service = _service(ap)
+ service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'}))
+
+ def create_user_task(coroutine, **_kwargs):
+ coroutine.close()
+ return SimpleNamespace(id=123)
+
+ ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
# Execute
- task_id = await service.test_mcp_server('existing-server', {})
+ task_id = await service.test_mcp_server(_CONTEXT, 'existing-server', {})
# Verify - returns task ID
assert task_id == 123
@@ -685,11 +1017,12 @@ class TestMCPServiceTestMCPServer:
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=None)
- service = MCPService(ap)
+ service = _service(ap)
+ service._require_server = AsyncMock(side_effect=WorkspaceNotFoundError('MCP server not found'))
# Execute & Verify
- with pytest.raises(ValueError, match='Server not found'):
- await service.test_mcp_server('nonexistent-server', {})
+ with pytest.raises(WorkspaceNotFoundError, match='MCP server not found'):
+ await service.test_mcp_server(_CONTEXT, 'nonexistent-server', {})
async def test_test_mcp_server_new_server(self):
"""Tests new MCP server with underscore name."""
@@ -703,13 +1036,38 @@ class TestMCPServiceTestMCPServer:
ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session)
ap.task_mgr = SimpleNamespace()
- ap.task_mgr.create_user_task = Mock(return_value=SimpleNamespace(id=456))
- service = MCPService(ap)
+ service = _service(ap)
+
+ def create_user_task(coroutine, **_kwargs):
+ coroutine.close()
+ return SimpleNamespace(id=456)
+
+ ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
# Execute with '_' name (new server)
- task_id = await service.test_mcp_server('_', {'name': 'New Server'})
+ task_id = await service.test_mcp_server(_CONTEXT, '_', {'name': 'New Server'})
# Verify - load_mcp_server called
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
assert task_id == 456
+
+ async def test_rejected_transient_test_session_is_shut_down(self):
+ ap = SimpleNamespace()
+ mock_session = MagicMock()
+ mock_session.shutdown = AsyncMock()
+ ap.tool_mgr = SimpleNamespace(
+ mcp_tool_loader=SimpleNamespace(load_mcp_server=AsyncMock(return_value=mock_session))
+ )
+
+ def reject(coroutine, **_kwargs):
+ coroutine.close()
+ raise TaskCapacityError('capacity')
+
+ ap.task_mgr = SimpleNamespace(create_user_task=Mock(side_effect=reject))
+ service = _service(ap)
+
+ with pytest.raises(TaskCapacityError, match='capacity'):
+ await service.test_mcp_server(_CONTEXT, '_', {'name': 'New Server'})
+
+ mock_session.shutdown.assert_awaited_once_with()
diff --git a/tests/unit_tests/api/service/test_model_service.py b/tests/unit_tests/api/service/test_model_service.py
index 42129ed3b..7e6af718a 100644
--- a/tests/unit_tests/api/service/test_model_service.py
+++ b/tests/unit_tests/api/service/test_model_service.py
@@ -25,11 +25,37 @@ from langbot.pkg.api.http.service.model import (
_runtime_model_data,
_validate_provider_supports,
)
+from langbot.pkg.api.http.service import model as model_service_module
from langbot.pkg.entity.persistence.model import LLMModel, EmbeddingModel, RerankModel, ModelProvider
pytestmark = pytest.mark.asyncio
+WORKSPACE_UUID = 'workspace-a'
+
+
+@pytest.fixture(autouse=True)
+def assume_test_provider_belongs_to_workspace(monkeypatch):
+ """Keep legacy runtime-focused tests isolated from the new ownership lookup."""
+
+ async def _allow_provider(_ap, _context, provider_uuid):
+ return {'uuid': provider_uuid}
+
+ monkeypatch.setattr(model_service_module, '_require_workspace_provider', _allow_provider)
+
+
+def _existing_llm_data(provider_uuid: str = 'provider-uuid') -> dict:
+ return {
+ 'uuid': 'existing-uuid',
+ 'workspace_uuid': WORKSPACE_UUID,
+ 'name': 'Existing Model',
+ 'provider_uuid': provider_uuid,
+ 'abilities': [],
+ 'context_length': None,
+ 'extra_args': {},
+ 'prefered_ranking': 0,
+ }
+
def _create_mock_llm_model(
model_uuid: str = 'llm-uuid',
@@ -101,6 +127,35 @@ def _create_mock_result(items: list = None, first_item=None):
return result
+def _create_runtime_model_mgr() -> SimpleNamespace:
+ """Build a context-aware runtime-manager double for service tests."""
+
+ manager = SimpleNamespace(
+ provider_dict={},
+ llm_models=[],
+ embedding_models=[],
+ rerank_models=[],
+ load_llm_model_with_provider=AsyncMock(return_value=Mock()),
+ load_embedding_model_with_provider=AsyncMock(return_value=Mock()),
+ load_rerank_model_with_provider=AsyncMock(return_value=Mock()),
+ cache_llm_model=AsyncMock(),
+ cache_embedding_model=AsyncMock(),
+ cache_rerank_model=AsyncMock(),
+ remove_llm_model=AsyncMock(),
+ remove_embedding_model=AsyncMock(),
+ remove_rerank_model=AsyncMock(),
+ )
+
+ async def get_provider(_context, provider_uuid):
+ provider = manager.provider_dict.get(provider_uuid)
+ if provider is None:
+ raise ValueError(f'Model provider {provider_uuid} not found')
+ return provider
+
+ manager.get_provider_by_uuid = AsyncMock(side_effect=get_provider)
+ return manager
+
+
class TestParseProviderApiKeys:
"""Tests for _parse_provider_api_keys helper function."""
@@ -183,7 +238,9 @@ class TestLLMModelsServiceGetLLMModels:
service = LLMModelsService(ap)
# Execute
- result = await service.get_llm_models()
+ result = await service.get_llm_models(
+ WORKSPACE_UUID,
+ )
# Verify
assert result == []
@@ -221,7 +278,9 @@ class TestLLMModelsServiceGetLLMModels:
service = LLMModelsService(ap)
# Execute
- result = await service.get_llm_models()
+ result = await service.get_llm_models(
+ WORKSPACE_UUID,
+ )
# Verify
assert len(result) == 1
@@ -260,7 +319,7 @@ class TestLLMModelsServiceGetLLMModels:
service = LLMModelsService(ap)
# Execute
- result = await service.get_llm_models(include_secret=False)
+ result = await service.get_llm_models(WORKSPACE_UUID, include_secret=False)
# Verify - keys should be masked
assert result[0]['provider']['api_keys'] == ['***', '***']
@@ -302,7 +361,7 @@ class TestLLMModelsServiceGetLLMModel:
service = LLMModelsService(ap)
# Execute
- result = await service.get_llm_model('found-uuid')
+ result = await service.get_llm_model(WORKSPACE_UUID, 'found-uuid')
# Verify
assert result is not None
@@ -321,7 +380,7 @@ class TestLLMModelsServiceGetLLMModel:
service = LLMModelsService(ap)
# Execute
- result = await service.get_llm_model('nonexistent-uuid')
+ result = await service.get_llm_model(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify
assert result is None
@@ -346,7 +405,7 @@ class TestLLMModelsServiceGetLLMModelsByProvider:
service = LLMModelsService(ap)
# Execute
- result = await service.get_llm_models_by_provider('target-provider')
+ result = await service.get_llm_models_by_provider(WORKSPACE_UUID, 'target-provider')
# Verify
assert len(result) == 2
@@ -360,7 +419,7 @@ class TestLLMModelsServiceCreateLLMModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
@@ -374,12 +433,13 @@ class TestLLMModelsServiceCreateLLMModel:
# Execute
model_uuid = await service.create_llm_model(
+ WORKSPACE_UUID,
{
'name': 'New LLM',
'provider_uuid': 'provider-uuid',
'abilities': [],
'extra_args': {},
- }
+ },
)
# Verify
@@ -391,7 +451,7 @@ class TestLLMModelsServiceCreateLLMModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
@@ -405,6 +465,7 @@ class TestLLMModelsServiceCreateLLMModel:
# Execute
model_uuid = await service.create_llm_model(
+ WORKSPACE_UUID,
{
'uuid': 'preserved-uuid',
'name': 'Preserved UUID Model',
@@ -422,7 +483,7 @@ class TestLLMModelsServiceCreateLLMModel:
"""Creates LLM model with context_length outside extra_args."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
@@ -434,6 +495,7 @@ class TestLLMModelsServiceCreateLLMModel:
service = LLMModelsService(ap)
await service.create_llm_model(
+ WORKSPACE_UUID,
{
'uuid': 'model-with-context',
'name': 'Context Model',
@@ -446,7 +508,7 @@ class TestLLMModelsServiceCreateLLMModel:
auto_set_to_default_pipeline=False,
)
- runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[0]
+ runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[1]
assert runtime_entity.context_length == 128000
assert runtime_entity.extra_args == {'temperature': 0.2}
assert 'context_length' not in runtime_entity.extra_args
@@ -456,7 +518,7 @@ class TestLLMModelsServiceCreateLLMModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {} # Empty - no provider
mock_result = _create_mock_result([])
@@ -467,12 +529,13 @@ class TestLLMModelsServiceCreateLLMModel:
# Execute & Verify
with pytest.raises(Exception, match='provider not found'):
await service.create_llm_model(
+ WORKSPACE_UUID,
{
'name': 'No Provider Model',
'provider_uuid': 'nonexistent-provider',
'abilities': [],
'extra_args': {},
- }
+ },
)
async def test_create_llm_model_with_provider_data(self):
@@ -480,7 +543,7 @@ class TestLLMModelsServiceCreateLLMModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {}
ap.model_mgr.llm_models = []
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
@@ -500,6 +563,7 @@ class TestLLMModelsServiceCreateLLMModel:
# Execute - with provider data (no UUID)
result_uuid = await service.create_llm_model(
+ WORKSPACE_UUID,
{
'name': 'Model with New Provider',
'provider': {
@@ -509,7 +573,7 @@ class TestLLMModelsServiceCreateLLMModel:
},
'abilities': [],
'extra_args': {},
- }
+ },
)
# Verify - provider_service was called and UUID generated
@@ -525,7 +589,7 @@ class TestLLMModelsServiceUpdateLLMModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.remove_llm_model = AsyncMock()
@@ -534,9 +598,11 @@ class TestLLMModelsServiceUpdateLLMModel:
ap.persistence_mgr.execute_async = AsyncMock()
service = LLMModelsService(ap)
+ service.get_llm_model = AsyncMock(return_value=_existing_llm_data())
# Execute
await service.update_llm_model(
+ WORKSPACE_UUID,
'existing-uuid',
{
'uuid': 'should-be-removed',
@@ -546,24 +612,26 @@ class TestLLMModelsServiceUpdateLLMModel:
)
# Verify - remove and load called
- ap.model_mgr.remove_llm_model.assert_called_once_with('existing-uuid')
+ ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid')
async def test_update_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found after update."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {} # Empty
ap.model_mgr.remove_llm_model = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
service = LLMModelsService(ap)
+ service.get_llm_model = AsyncMock(return_value=_existing_llm_data('nonexistent-provider'))
# Execute & Verify
with pytest.raises(Exception, match='provider not found'):
await service.update_llm_model(
+ WORKSPACE_UUID,
'model-uuid',
{
'name': 'Update',
@@ -575,15 +643,17 @@ class TestLLMModelsServiceUpdateLLMModel:
"""Updates runtime model with context_length outside extra_args."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.llm_models = []
ap.model_mgr.remove_llm_model = AsyncMock()
ap.model_mgr.load_llm_model_with_provider = AsyncMock(return_value=Mock())
service = LLMModelsService(ap)
+ service.get_llm_model = AsyncMock(return_value=_existing_llm_data())
await service.update_llm_model(
+ WORKSPACE_UUID,
'existing-uuid',
{
'name': 'Updated Name',
@@ -594,7 +664,7 @@ class TestLLMModelsServiceUpdateLLMModel:
},
)
- runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[0]
+ runtime_entity = ap.model_mgr.load_llm_model_with_provider.await_args.args[1]
assert runtime_entity.uuid == 'existing-uuid'
assert runtime_entity.context_length == 64000
assert runtime_entity.extra_args == {'temperature': 0.4}
@@ -609,7 +679,7 @@ class TestLLMModelsServiceDeleteLLMModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.remove_llm_model = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
@@ -617,11 +687,11 @@ class TestLLMModelsServiceDeleteLLMModel:
service = LLMModelsService(ap)
# Execute
- await service.delete_llm_model('delete-uuid')
+ await service.delete_llm_model(WORKSPACE_UUID, 'delete-uuid')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
- ap.model_mgr.remove_llm_model.assert_called_once_with('delete-uuid')
+ ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'delete-uuid')
class TestEmbeddingModelsServiceGetEmbeddingModels:
@@ -640,7 +710,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModels:
service = EmbeddingModelsService(ap)
# Execute
- result = await service.get_embedding_models()
+ result = await service.get_embedding_models(
+ WORKSPACE_UUID,
+ )
# Verify
assert result == []
@@ -677,7 +749,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModels:
service = EmbeddingModelsService(ap)
# Execute
- result = await service.get_embedding_models()
+ result = await service.get_embedding_models(
+ WORKSPACE_UUID,
+ )
# Verify
assert len(result) == 1
@@ -717,7 +791,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModel:
service = EmbeddingModelsService(ap)
# Execute
- result = await service.get_embedding_model('found-embedding')
+ result = await service.get_embedding_model(WORKSPACE_UUID, 'found-embedding')
# Verify
assert result is not None
@@ -734,7 +808,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModel:
service = EmbeddingModelsService(ap)
# Execute
- result = await service.get_embedding_model('nonexistent-embedding')
+ result = await service.get_embedding_model(WORKSPACE_UUID, 'nonexistent-embedding')
# Verify
assert result is None
@@ -748,7 +822,7 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.embedding_models = []
ap.model_mgr.load_embedding_model_with_provider = AsyncMock(return_value=Mock())
@@ -760,11 +834,12 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel:
# Execute
model_uuid = await service.create_embedding_model(
+ WORKSPACE_UUID,
{
'name': 'New Embedding',
'provider_uuid': 'provider-uuid',
'extra_args': {},
- }
+ },
)
# Verify
@@ -776,7 +851,7 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {} # Empty
mock_result = _create_mock_result([])
@@ -787,11 +862,12 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel:
# Execute & Verify
with pytest.raises(Exception, match='provider not found'):
await service.create_embedding_model(
+ WORKSPACE_UUID,
{
'name': 'No Provider Embedding',
'provider_uuid': 'nonexistent',
'extra_args': {},
- }
+ },
)
@@ -803,7 +879,7 @@ class TestEmbeddingModelsServiceDeleteEmbeddingModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.remove_embedding_model = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
@@ -811,7 +887,7 @@ class TestEmbeddingModelsServiceDeleteEmbeddingModel:
service = EmbeddingModelsService(ap)
# Execute
- await service.delete_embedding_model('delete-embedding-uuid')
+ await service.delete_embedding_model(WORKSPACE_UUID, 'delete-embedding-uuid')
# Verify
ap.model_mgr.remove_embedding_model.assert_called_once()
@@ -832,7 +908,9 @@ class TestRerankModelsServiceGetRerankModels:
service = RerankModelsService(ap)
# Execute
- result = await service.get_rerank_models()
+ result = await service.get_rerank_models(
+ WORKSPACE_UUID,
+ )
# Verify
assert result == []
@@ -869,7 +947,9 @@ class TestRerankModelsServiceGetRerankModels:
service = RerankModelsService(ap)
# Execute
- result = await service.get_rerank_models()
+ result = await service.get_rerank_models(
+ WORKSPACE_UUID,
+ )
# Verify
assert len(result) == 1
@@ -909,7 +989,7 @@ class TestRerankModelsServiceGetRerankModel:
service = RerankModelsService(ap)
# Execute
- result = await service.get_rerank_model('found-rerank')
+ result = await service.get_rerank_model(WORKSPACE_UUID, 'found-rerank')
# Verify
assert result is not None
@@ -926,7 +1006,7 @@ class TestRerankModelsServiceGetRerankModel:
service = RerankModelsService(ap)
# Execute
- result = await service.get_rerank_model('nonexistent-rerank')
+ result = await service.get_rerank_model(WORKSPACE_UUID, 'nonexistent-rerank')
# Verify
assert result is None
@@ -940,7 +1020,7 @@ class TestRerankModelsServiceCreateRerankModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {'provider-uuid': Mock()}
ap.model_mgr.rerank_models = []
ap.model_mgr.load_rerank_model_with_provider = AsyncMock(return_value=Mock())
@@ -952,11 +1032,12 @@ class TestRerankModelsServiceCreateRerankModel:
# Execute
model_uuid = await service.create_rerank_model(
+ WORKSPACE_UUID,
{
'name': 'New Rerank',
'provider_uuid': 'provider-uuid',
'extra_args': {},
- }
+ },
)
# Verify
@@ -967,7 +1048,7 @@ class TestRerankModelsServiceCreateRerankModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.provider_dict = {}
mock_result = _create_mock_result([])
@@ -978,11 +1059,12 @@ class TestRerankModelsServiceCreateRerankModel:
# Execute & Verify
with pytest.raises(Exception, match='provider not found'):
await service.create_rerank_model(
+ WORKSPACE_UUID,
{
'name': 'No Provider Rerank',
'provider_uuid': 'nonexistent',
'extra_args': {},
- }
+ },
)
@@ -994,7 +1076,7 @@ class TestRerankModelsServiceDeleteRerankModel:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.model_mgr = SimpleNamespace()
+ ap.model_mgr = _create_runtime_model_mgr()
ap.model_mgr.remove_rerank_model = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
@@ -1002,7 +1084,7 @@ class TestRerankModelsServiceDeleteRerankModel:
service = RerankModelsService(ap)
# Execute
- await service.delete_rerank_model('delete-rerank-uuid')
+ await service.delete_rerank_model(WORKSPACE_UUID, 'delete-rerank-uuid')
# Verify
ap.model_mgr.remove_rerank_model.assert_called_once()
@@ -1027,7 +1109,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
service = EmbeddingModelsService(ap)
# Execute
- result = await service.get_embedding_models_by_provider('provider-uuid')
+ result = await service.get_embedding_models_by_provider(WORKSPACE_UUID, 'provider-uuid')
# Verify
assert len(result) == 2
@@ -1052,7 +1134,7 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
service = RerankModelsService(ap)
# Execute
- result = await service.get_rerank_models_by_provider('provider-uuid')
+ result = await service.get_rerank_models_by_provider(WORKSPACE_UUID, 'provider-uuid')
# Verify
assert len(result) == 2
@@ -1066,39 +1148,102 @@ class TestValidateProviderSupports:
"""Build a fake ap whose model_mgr resolves a manifest with support_type."""
manifest = SimpleNamespace(spec={'support_type': support_type})
runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(requester=requester_name))
- model_mgr = SimpleNamespace(
- provider_dict={'p1': runtime_provider},
- get_available_requester_manifest_by_name=lambda name: manifest if name == requester_name else None,
- )
+ model_mgr = _create_runtime_model_mgr()
+ model_mgr.provider_dict = {'p1': runtime_provider}
+ model_mgr.get_available_requester_manifest_by_name = lambda name: manifest if name == requester_name else None
return SimpleNamespace(model_mgr=model_mgr)
async def test_allows_supported_type(self):
ap = self._make_ap('cohere-rerank', ['rerank'])
# Should not raise
- await _validate_provider_supports(ap, 'p1', 'rerank')
+ await _validate_provider_supports(ap, WORKSPACE_UUID, 'p1', 'rerank')
async def test_rejects_unsupported_type(self):
ap = self._make_ap('cohere-rerank', ['rerank'])
with pytest.raises(ValueError, match='does not support llm'):
- await _validate_provider_supports(ap, 'p1', 'llm')
+ await _validate_provider_supports(ap, WORKSPACE_UUID, 'p1', 'llm')
async def test_allows_when_support_type_missing(self):
# Manifest without support_type must not block (backward compatible)
manifest = SimpleNamespace(spec={})
runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(requester='legacy'))
- model_mgr = SimpleNamespace(
- provider_dict={'p1': runtime_provider},
- get_available_requester_manifest_by_name=lambda name: manifest,
- )
+ model_mgr = _create_runtime_model_mgr()
+ model_mgr.provider_dict = {'p1': runtime_provider}
+ model_mgr.get_available_requester_manifest_by_name = lambda name: manifest
ap = SimpleNamespace(model_mgr=model_mgr)
- await _validate_provider_supports(ap, 'p1', 'rerank')
+ await _validate_provider_supports(ap, WORKSPACE_UUID, 'p1', 'rerank')
async def test_allows_when_provider_unknown(self):
ap = self._make_ap('cohere-rerank', ['rerank'])
# Unknown provider uuid -> no entry -> no block
- await _validate_provider_supports(ap, 'missing', 'llm')
+ await _validate_provider_supports(ap, WORKSPACE_UUID, 'missing', 'llm')
async def test_degrades_when_model_mgr_incomplete(self):
# A bare ap without a usable model_mgr must not raise (defensive)
ap = SimpleNamespace(model_mgr=SimpleNamespace())
- await _validate_provider_supports(ap, 'p1', 'llm')
+ await _validate_provider_supports(ap, WORKSPACE_UUID, 'p1', 'llm')
+
+
+class TestModelSecretRoundtrip:
+ async def test_provider_filtered_list_redacts_extra_args_without_mutating_source(self):
+ model = _create_mock_llm_model(extra_args={'headers': {'Authorization': 'Bearer secret'}})
+ raw = {
+ 'uuid': model.uuid,
+ 'provider_uuid': model.provider_uuid,
+ 'extra_args': {'headers': {'Authorization': 'Bearer secret'}},
+ }
+ ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(
+ execute_async=AsyncMock(return_value=_create_mock_result([model])),
+ serialize_model=Mock(return_value=raw),
+ )
+ )
+ service = LLMModelsService(ap)
+
+ redacted = await service.get_llm_models_by_provider(WORKSPACE_UUID, model.provider_uuid)
+ unredacted = await service.get_llm_models_by_provider(
+ WORKSPACE_UUID,
+ model.provider_uuid,
+ include_secret=True,
+ )
+
+ assert redacted[0]['extra_args']['headers']['Authorization'] == '***'
+ assert unredacted[0]['extra_args']['headers']['Authorization'] == 'Bearer secret'
+ assert raw['extra_args']['headers']['Authorization'] == 'Bearer secret'
+
+ async def test_masked_extra_args_update_restores_existing_header(self):
+ existing = _existing_llm_data()
+ existing['extra_args'] = {
+ 'headers': {'Authorization': 'Bearer secret', 'X-API-Key': 'key-secret'},
+ 'timeout': 30,
+ }
+ runtime_provider = SimpleNamespace(provider_entity=SimpleNamespace(requester=None))
+ write_result = Mock(rowcount=1)
+ model_mgr = _create_runtime_model_mgr()
+ model_mgr.provider_dict = {'provider-uuid': runtime_provider}
+ ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=write_result)),
+ model_mgr=model_mgr,
+ )
+ service = LLMModelsService(ap)
+ service.get_llm_model = AsyncMock(return_value=existing)
+
+ await service.update_llm_model(
+ WORKSPACE_UUID,
+ 'existing-uuid',
+ {
+ 'extra_args': {
+ 'headers': {'Authorization': '***', 'X-API-Key': ''},
+ 'timeout': 60,
+ }
+ },
+ )
+
+ statement = ap.persistence_mgr.execute_async.await_args.args[0]
+ stored_extra_args = next(
+ value.value for column, value in statement._values.items() if column.key == 'extra_args'
+ )
+ assert stored_extra_args == {
+ 'headers': {'Authorization': 'Bearer secret', 'X-API-Key': ''},
+ 'timeout': 60,
+ }
diff --git a/tests/unit_tests/api/service/test_monitoring_tenancy.py b/tests/unit_tests/api/service/test_monitoring_tenancy.py
new file mode 100644
index 000000000..a24d937ff
--- /dev/null
+++ b/tests/unit_tests/api/service/test_monitoring_tenancy.py
@@ -0,0 +1,392 @@
+from __future__ import annotations
+
+import datetime
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.api.http.service.monitoring import MonitoringService
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
+from langbot.pkg.entity.persistence.workspace import Workspace
+from langbot.pkg.persistence.mgr import PersistenceManager
+
+
+pytestmark = pytest.mark.asyncio
+
+WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
+
+
+def _context(workspace_uuid: str) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid='instance',
+ workspace_uuid=workspace_uuid,
+ placement_generation=3,
+ bot_uuid='same-bot',
+ pipeline_uuid='same-pipeline',
+ )
+
+
+class _PersistenceManager:
+ def __init__(self, engine):
+ self.engine = engine
+
+ async def execute_async(self, *args, **kwargs):
+ async with self.engine.connect() as connection:
+ result = await connection.execute(*args, **kwargs)
+ await connection.commit()
+ return result
+
+ def get_db_engine(self):
+ return self.engine
+
+ @staticmethod
+ def serialize_model(model, data, masked_columns=None):
+ return {
+ column.name: (
+ getattr(data, column.name).isoformat()
+ if isinstance(getattr(data, column.name), datetime.datetime)
+ else getattr(data, column.name)
+ )
+ for column in model.__table__.columns
+ if column.name not in (masked_columns or [])
+ }
+
+
+@pytest.fixture
+async def service(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "monitoring.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ await connection.execute(
+ sqlalchemy.insert(Workspace),
+ [
+ {
+ 'uuid': WORKSPACE_A,
+ 'instance_uuid': 'instance',
+ 'name': 'A',
+ 'slug': 'a',
+ 'source': 'cloud_projection',
+ },
+ {
+ 'uuid': WORKSPACE_B,
+ 'instance_uuid': 'instance',
+ 'name': 'B',
+ 'slug': 'b',
+ 'source': 'cloud_projection',
+ },
+ ],
+ )
+ application = SimpleNamespace(
+ persistence_mgr=_PersistenceManager(engine),
+ instance_config=SimpleNamespace(data={'database': {'use': 'sqlite'}}),
+ )
+ yield MonitoringService(application)
+ await engine.dispose()
+
+
+async def _record_message(service, context, content):
+ return await service.record_message(
+ context,
+ bot_id='same-bot',
+ bot_name='Same Bot',
+ pipeline_id='same-pipeline',
+ pipeline_name='Same Pipeline',
+ message_content=content,
+ session_id='same-session',
+ )
+
+
+async def test_monitoring_write_without_execution_context_fails_closed(service):
+ with pytest.raises(WorkspaceRequiredError):
+ await _record_message(service, None, 'unscoped')
+
+
+async def test_same_session_and_resource_ids_do_not_collide(service):
+ context_a = _context(WORKSPACE_A)
+ context_b = _context(WORKSPACE_B)
+ message_a = await _record_message(service, context_a, 'tenant-a')
+ message_b = await _record_message(service, context_b, 'tenant-b')
+ await service.record_session_start(
+ context_a,
+ session_id='same-session',
+ bot_id='same-bot',
+ bot_name='Same Bot',
+ pipeline_id='same-pipeline',
+ pipeline_name='Same Pipeline',
+ )
+ await service.record_session_start(
+ context_b,
+ session_id='same-session',
+ bot_id='same-bot',
+ bot_name='Same Bot',
+ pipeline_id='same-pipeline',
+ pipeline_name='Same Pipeline',
+ )
+
+ messages_a, total_a = await service.get_messages(context_a)
+ messages_b, total_b = await service.get_messages(context_b)
+ assert total_a == total_b == 1
+ assert messages_a[0]['message_content'] == 'tenant-a'
+ assert messages_b[0]['message_content'] == 'tenant-b'
+ assert (await service.get_message_details(context_b, message_a))['found'] is False
+ assert (await service.get_message_details(context_a, message_b))['found'] is False
+
+
+async def test_tool_call_inherits_context_from_connection_message_row(service):
+ context = _context(WORKSPACE_A)
+ message_id = await _record_message(service, context, 'tool context')
+
+ await service.record_tool_call(
+ context,
+ tool_name='search',
+ tool_source='native',
+ duration=12,
+ message_id=message_id,
+ )
+
+ tool_calls, total = await service.get_tool_calls(context)
+ assert total == 1
+ assert tool_calls[0]['bot_id'] == 'same-bot'
+ assert tool_calls[0]['pipeline_id'] == 'same-pipeline'
+ assert tool_calls[0]['session_id'] == 'same-session'
+ assert tool_calls[0]['message_id'] == message_id
+
+
+async def test_feedback_upsert_and_cancel_are_workspace_scoped(service):
+ context_a = _context(WORKSPACE_A)
+ context_b = _context(WORKSPACE_B)
+ await service.record_feedback(context_a, feedback_id='same-feedback', feedback_type=1)
+ await service.record_feedback(context_b, feedback_id='same-feedback', feedback_type=2)
+
+ stats_a = await service.get_feedback_stats(context_a)
+ stats_b = await service.get_feedback_stats(context_b)
+ assert stats_a['total_likes'] == 1
+ assert stats_a['total_dislikes'] == 0
+ assert stats_b['total_likes'] == 0
+ assert stats_b['total_dislikes'] == 1
+
+ await service.record_feedback(context_a, feedback_id='same-feedback', feedback_type=3)
+ assert (await service.get_feedback_stats(context_a))['total_feedback'] == 0
+ assert (await service.get_feedback_stats(context_b))['total_feedback'] == 1
+
+
+async def test_monitoring_queries_and_detail_views_are_strictly_bounded(service):
+ context = _context(WORKSPACE_A)
+ service.ap.instance_config.data['monitoring'] = {
+ 'query_limits': {
+ 'page_rows': 2,
+ 'export_rows': 2,
+ 'detail_rows': 2,
+ 'timeseries_buckets': 2,
+ 'max_offset': 10,
+ }
+ }
+ await service.record_session_start(
+ context,
+ session_id='same-session',
+ bot_id='same-bot',
+ bot_name='Same Bot',
+ pipeline_id='same-pipeline',
+ pipeline_name='Same Pipeline',
+ )
+ message_ids = [await _record_message(service, context, f'message-{index}') for index in range(4)]
+ for index in range(3):
+ await service.record_llm_call(
+ context,
+ bot_id='same-bot',
+ bot_name='Same Bot',
+ pipeline_id='same-pipeline',
+ pipeline_name='Same Pipeline',
+ session_id='same-session',
+ model_name='model',
+ input_tokens=1,
+ output_tokens=2,
+ duration=10,
+ message_id=message_ids[0],
+ )
+ await service.record_tool_call(
+ context,
+ tool_name=f'tool-{index}',
+ tool_source='native',
+ duration=5,
+ session_id='same-session',
+ message_id=message_ids[0],
+ )
+ await service.record_error(
+ context,
+ bot_id='same-bot',
+ bot_name='Same Bot',
+ pipeline_id='same-pipeline',
+ pipeline_name='Same Pipeline',
+ error_type='Failure',
+ error_message=f'error-{index}',
+ session_id='same-session',
+ message_id=message_ids[0],
+ )
+
+ page, total = await service.get_messages(context, limit=100000, offset=-5)
+ exported = await service.export_messages(context, limit=100000)
+ session_detail = await service.get_session_analysis(context, 'same-session')
+ message_detail = await service.get_message_details(context, message_ids[0])
+
+ assert total == 4
+ assert len(page) == 2
+ assert len(exported) == 2
+ assert session_detail['message_stats']['total'] == 4
+ assert session_detail['llm_stats']['total_calls'] == 3
+ assert session_detail['tool_stats']['total_calls'] == 3
+ assert len(session_detail['tool_calls']) == 2
+ assert len(session_detail['errors']) == 2
+ assert session_detail['detail_truncated'] == {
+ 'tool_calls': True,
+ 'errors': True,
+ }
+ assert message_detail['llm_stats']['total_calls'] == 3
+ assert len(message_detail['llm_calls']) == 2
+ assert len(message_detail['errors']) == 2
+ assert message_detail['detail_truncated'] == {
+ 'llm_calls': True,
+ 'errors': True,
+ }
+
+ service.ap.instance_config.data['monitoring']['query_limits'] = {
+ 'page_rows': 999999,
+ 'export_rows': 999999,
+ 'detail_rows': 999999,
+ 'timeseries_buckets': 999999,
+ 'max_offset': 99999999,
+ }
+ assert service.normalize_page_window(999999, 99999999) == (5000, 10000000)
+ assert service.normalize_export_limit(999999) == 50000
+ assert service._detail_limit() == 10000
+ assert service._timeseries_bucket_limit() == 10000
+
+
+async def test_token_statistics_aggregate_and_limit_groups_in_database(service):
+ context = _context(WORKSPACE_A)
+ service.ap.instance_config.data['monitoring'] = {
+ 'query_limits': {
+ 'page_rows': 1,
+ 'timeseries_buckets': 2,
+ }
+ }
+ first_hour = datetime.datetime(2026, 7, 28, 10, 0)
+ rows = [
+ {
+ 'id': f'llm-{index}',
+ 'workspace_uuid': WORKSPACE_A,
+ 'timestamp': first_hour + datetime.timedelta(hours=hour, minutes=index),
+ 'model_name': model,
+ 'input_tokens': input_tokens,
+ 'output_tokens': output_tokens,
+ 'total_tokens': input_tokens + output_tokens,
+ 'duration': 100,
+ 'cost': 0.01,
+ 'status': 'success',
+ 'bot_id': 'same-bot',
+ 'bot_name': 'Same Bot',
+ 'pipeline_id': 'same-pipeline',
+ 'pipeline_name': 'Same Pipeline',
+ 'session_id': 'same-session',
+ }
+ for index, (hour, model, input_tokens, output_tokens) in enumerate(
+ [
+ (0, 'small-model', 1, 2),
+ (1, 'large-model', 3, 4),
+ (2, 'large-model', 5, 6),
+ (2, 'large-model', 7, 8),
+ ]
+ )
+ ]
+ await service.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringLLMCall), rows)
+
+ stats = await service.get_token_statistics(context, bucket='hour')
+
+ assert stats['summary']['total_calls'] == 4
+ assert stats['summary']['total_tokens'] == 36
+ assert stats['by_model_truncated'] is True
+ assert [model['model_name'] for model in stats['by_model']] == ['large-model']
+ assert stats['timeseries_truncated'] is True
+ assert stats['timeseries'] == [
+ {
+ 'bucket': '2026-07-28 11:00',
+ 'input_tokens': 3,
+ 'output_tokens': 4,
+ 'total_tokens': 7,
+ 'calls': 1,
+ },
+ {
+ 'bucket': '2026-07-28 12:00',
+ 'input_tokens': 12,
+ 'output_tokens': 14,
+ 'total_tokens': 26,
+ 'calls': 2,
+ },
+ ]
+
+
+async def test_cleanup_commits_sqlite_delete_before_vacuum(tmp_path):
+ engine = create_async_engine(
+ f'sqlite+aiosqlite:///{tmp_path / "monitoring-cleanup.db"}',
+ connect_args={'timeout': 0.1},
+ )
+ application = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'database': {'use': 'sqlite'}}),
+ )
+ manager = PersistenceManager(application)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ application.persistence_mgr = manager
+ try:
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ await connection.execute(
+ sqlalchemy.insert(Workspace).values(
+ uuid=WORKSPACE_A,
+ instance_uuid='instance',
+ name='A',
+ slug='a',
+ source='cloud_projection',
+ )
+ )
+ await connection.execute(
+ sqlalchemy.insert(MonitoringMessage),
+ [
+ {
+ 'id': f'expired-message-{index}',
+ 'workspace_uuid': WORKSPACE_A,
+ 'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
+ - datetime.timedelta(days=30),
+ 'bot_id': 'bot',
+ 'bot_name': 'Bot',
+ 'pipeline_id': 'pipeline',
+ 'pipeline_name': 'Pipeline',
+ 'message_content': 'expired',
+ 'session_id': 'session',
+ 'status': 'success',
+ 'level': 'info',
+ }
+ for index in range(5)
+ ],
+ )
+
+ deleted = await MonitoringService(application).cleanup_expired_records(
+ _context(WORKSPACE_A),
+ retention_days=1,
+ batch_size=2,
+ max_batches_per_table=1,
+ )
+
+ assert deleted['monitoring_messages'] == 2
+ async with engine.connect() as connection:
+ remaining = await connection.scalar(
+ sqlalchemy.select(sqlalchemy.func.count()).select_from(MonitoringMessage)
+ )
+ assert remaining == 3
+ finally:
+ await engine.dispose()
diff --git a/tests/unit_tests/api/service/test_pipeline_service.py b/tests/unit_tests/api/service/test_pipeline_service.py
index fade30372..a4ef851db 100644
--- a/tests/unit_tests/api/service/test_pipeline_service.py
+++ b/tests/unit_tests/api/service/test_pipeline_service.py
@@ -21,10 +21,13 @@ import json
from langbot.pkg.api.http.service.pipeline import PipelineService, default_stage_order
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio
+WORKSPACE_UUID = 'workspace-a'
+
def _create_mock_pipeline(
pipeline_uuid: str = None,
@@ -77,7 +80,9 @@ class TestPipelineServiceGetPipelineMetadata:
service = PipelineService(ap)
# Execute
- result = await service.get_pipeline_metadata()
+ result = await service.get_pipeline_metadata(
+ WORKSPACE_UUID,
+ )
# Verify
assert len(result) == 4
@@ -107,7 +112,9 @@ class TestPipelineServiceGetPipelines:
service = PipelineService(ap)
# Execute
- result = await service.get_pipelines()
+ result = await service.get_pipelines(
+ WORKSPACE_UUID,
+ )
# Verify
assert result == []
@@ -133,7 +140,9 @@ class TestPipelineServiceGetPipelines:
service = PipelineService(ap)
# Execute
- result = await service.get_pipelines()
+ result = await service.get_pipelines(
+ WORKSPACE_UUID,
+ )
# Verify
assert len(result) == 2
@@ -152,7 +161,7 @@ class TestPipelineServiceGetPipelines:
service = PipelineService(ap)
# Execute
- await service.get_pipelines(sort_by='updated_at', sort_order='ASC')
+ await service.get_pipelines(WORKSPACE_UUID, sort_by='updated_at', sort_order='ASC')
# Verify - execute was called with sort parameters
ap.persistence_mgr.execute_async.assert_called_once()
@@ -181,7 +190,7 @@ class TestPipelineServiceGetPipeline:
service = PipelineService(ap)
# Execute
- result = await service.get_pipeline('test-uuid')
+ result = await service.get_pipeline(WORKSPACE_UUID, 'test-uuid')
# Verify
assert result is not None
@@ -200,7 +209,7 @@ class TestPipelineServiceGetPipeline:
service = PipelineService(ap)
# Execute
- result = await service.get_pipeline('nonexistent-uuid')
+ result = await service.get_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify
assert result is None
@@ -229,7 +238,7 @@ class TestPipelineServiceCreatePipeline:
# Execute & Verify
with pytest.raises(ValueError, match='Maximum number of pipelines'):
- await service.create_pipeline({'name': 'New Pipeline'})
+ await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
async def test_create_pipeline_no_limit(self):
"""Creates pipeline without limit when max_pipelines=-1."""
@@ -258,7 +267,7 @@ class TestPipelineServiceCreatePipeline:
with patch(
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
):
- bot_uuid = await service.create_pipeline({'name': 'New Pipeline'})
+ bot_uuid = await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
# Verify
assert bot_uuid is not None
@@ -293,7 +302,7 @@ class TestPipelineServiceCreatePipeline:
with patch(
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
):
- await service.create_pipeline({'name': 'Default Pipeline'}, default=True)
+ await service.create_pipeline(WORKSPACE_UUID, {'name': 'Default Pipeline'}, default=True)
# Verify - execute was called
ap.persistence_mgr.execute_async.assert_called()
@@ -340,7 +349,7 @@ class TestPipelineServiceCreatePipeline:
with patch(
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
):
- await service.create_pipeline({'name': 'New Pipeline'})
+ await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
assert len(insert_params) == 1
assert insert_params[0]['extensions_preferences'] == {
@@ -394,7 +403,7 @@ class TestPipelineServiceUpdatePipeline:
'is_default': True,
'description': 'New description', # Not name change, so no bot_service needed
}
- await service.update_pipeline('test-uuid', pipeline_data)
+ await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', pipeline_data)
update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
assert update_params['description'] == 'New description'
@@ -450,7 +459,7 @@ class TestPipelineServiceUpdatePipeline:
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'New Name'})
# Execute with name change
- await service.update_pipeline('test-uuid', {'name': 'New Name'})
+ await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', {'name': 'New Name'})
# Verify - bot_service.update_bot was called for each bot
assert ap.bot_service.update_bot.call_count == 2
@@ -478,7 +487,7 @@ class TestPipelineServiceUpdatePipeline:
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
# Execute
- await service.update_pipeline('test-uuid', {'description': 'Updated'})
+ await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', {'description': 'Updated'})
# Verify - conversation was cleared
assert session.using_conversation is None
@@ -499,10 +508,10 @@ class TestPipelineServiceDeletePipeline:
service = PipelineService(ap)
# Execute
- await service.delete_pipeline('test-uuid')
+ await service.delete_pipeline(WORKSPACE_UUID, 'test-uuid')
# Verify
- ap.pipeline_mgr.remove_pipeline.assert_called_once_with('test-uuid')
+ ap.pipeline_mgr.remove_pipeline.assert_called_once_with(WORKSPACE_UUID, 'test-uuid')
ap.persistence_mgr.execute_async.assert_called_once()
async def test_delete_pipeline_nonexistent_uuid(self):
@@ -517,7 +526,7 @@ class TestPipelineServiceDeletePipeline:
service = PipelineService(ap)
# Execute - should not raise
- await service.delete_pipeline('nonexistent-uuid')
+ await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify
ap.pipeline_mgr.remove_pipeline.assert_called_once()
@@ -549,7 +558,7 @@ class TestPipelineServiceCopyPipeline:
# Execute & Verify
with pytest.raises(ValueError, match='Maximum number of pipelines'):
- await service.copy_pipeline('original-uuid')
+ await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
async def test_copy_pipeline_not_found_raises(self):
"""Raises ValueError when original pipeline not found."""
@@ -570,8 +579,8 @@ class TestPipelineServiceCopyPipeline:
ap.persistence_mgr.serialize_model = Mock(return_value={})
# Execute & Verify
- with pytest.raises(ValueError, match='Pipeline original-uuid not found'):
- await service.copy_pipeline('original-uuid')
+ with pytest.raises(WorkspaceNotFoundError, match='Pipeline original-uuid not found'):
+ await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
async def test_copy_pipeline_creates_copy(self):
"""Creates a copy with (Copy) suffix."""
@@ -614,7 +623,7 @@ class TestPipelineServiceCopyPipeline:
)
# Execute
- new_uuid = await service.copy_pipeline('original-uuid')
+ new_uuid = await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
# Verify
assert new_uuid is not None
@@ -647,7 +656,7 @@ class TestPipelineServiceCopyPipeline:
service.get_pipeline = AsyncMock(return_value={'uuid': 'copy-uuid', 'is_default': False})
# Execute
- await service.copy_pipeline('original-uuid')
+ await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
# Verify - pipeline_mgr.load_pipeline called (copy created)
ap.pipeline_mgr.load_pipeline.assert_called_once()
@@ -667,8 +676,8 @@ class TestPipelineServiceUpdatePipelineExtensions:
service = PipelineService(ap)
# Execute & Verify
- with pytest.raises(ValueError, match='Pipeline nonexistent-uuid not found'):
- await service.update_pipeline_extensions('nonexistent-uuid', [])
+ with pytest.raises(WorkspaceNotFoundError, match='Pipeline nonexistent-uuid not found'):
+ await service.update_pipeline_extensions(WORKSPACE_UUID, 'nonexistent-uuid', [])
async def test_update_extensions_sets_plugins(self):
"""Updates plugins in extensions_preferences."""
@@ -715,6 +724,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
# Execute
bound_plugins = [{'plugin_uuid': 'plugin-1'}]
await service.update_pipeline_extensions(
+ WORKSPACE_UUID,
'test-uuid',
bound_plugins=bound_plugins,
enable_all_plugins=False,
@@ -764,6 +774,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
# Execute
await service.update_pipeline_extensions(
+ WORKSPACE_UUID,
'test-uuid',
bound_plugins=[],
bound_mcp_servers=['mcp-server-1'],
@@ -811,7 +822,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
)
# Execute - bound_mcp_servers is None (not provided)
- await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
+ await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
# Verify - persistence was called
ap.persistence_mgr.execute_async.assert_called()
@@ -850,7 +861,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
service = PipelineService(ap)
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
- await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
+ await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
assert original_pipeline.extensions_preferences['mcp_resources'] == [
@@ -858,6 +869,82 @@ class TestPipelineServiceUpdatePipelineExtensions:
]
+class TestPipelineSecretRoundtrip:
+ async def test_resource_view_redacts_runner_secrets_without_mutating_serialized_data(self):
+ raw = {
+ 'uuid': 'pipeline-secret',
+ 'config': {
+ 'ai': {
+ 'n8n': {
+ 'webhook-url': 'https://hook.invalid/bearer-secret',
+ 'headers': {'Authorization': 'Bearer secret'},
+ }
+ }
+ },
+ }
+ pipeline = _create_mock_pipeline(pipeline_uuid='pipeline-secret')
+ ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(
+ execute_async=AsyncMock(return_value=_create_mock_result([pipeline])),
+ serialize_model=Mock(return_value=raw),
+ )
+ )
+
+ redacted = await PipelineService(ap).get_pipelines(WORKSPACE_UUID)
+
+ assert redacted[0]['config']['ai']['n8n']['webhook-url'] == '***'
+ assert redacted[0]['config']['ai']['n8n']['headers']['Authorization'] == '***'
+ assert raw['config']['ai']['n8n']['webhook-url'] == 'https://hook.invalid/bearer-secret'
+
+ async def test_masked_runner_config_update_restores_existing_secret(self):
+ raw_config = {
+ 'ai': {
+ 'n8n': {
+ 'webhook-url': 'https://hook.invalid/bearer-secret',
+ 'headers': {'Authorization': 'Bearer secret'},
+ 'timeout': 30,
+ }
+ }
+ }
+ current_pipeline = {'uuid': 'pipeline-secret', 'config': raw_config}
+ write_result = Mock(rowcount=1)
+ ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=write_result)),
+ pipeline_mgr=SimpleNamespace(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()),
+ sess_mgr=SimpleNamespace(session_list=[]),
+ )
+ service = PipelineService(ap)
+ service.get_pipeline = AsyncMock(side_effect=[current_pipeline, current_pipeline])
+
+ await service.update_pipeline(
+ WORKSPACE_UUID,
+ 'pipeline-secret',
+ {
+ 'config': {
+ 'ai': {
+ 'n8n': {
+ 'webhook-url': '***',
+ 'headers': {'Authorization': '***'},
+ 'timeout': 60,
+ }
+ }
+ }
+ },
+ )
+
+ statement = ap.persistence_mgr.execute_async.await_args.args[0]
+ stored_config = next(value.value for column, value in statement._values.items() if column.key == 'config')
+ assert stored_config == {
+ 'ai': {
+ 'n8n': {
+ 'webhook-url': 'https://hook.invalid/bearer-secret',
+ 'headers': {'Authorization': 'Bearer secret'},
+ 'timeout': 60,
+ }
+ }
+ }
+
+
class TestDefaultStageOrder:
"""Tests for default_stage_order constant."""
diff --git a/tests/unit_tests/api/service/test_provider_service.py b/tests/unit_tests/api/service/test_provider_service.py
index acc00b357..15b995895 100644
--- a/tests/unit_tests/api/service/test_provider_service.py
+++ b/tests/unit_tests/api/service/test_provider_service.py
@@ -19,10 +19,13 @@ from types import SimpleNamespace
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio
+WORKSPACE_UUID = 'workspace-a'
+
def _create_mock_provider(
provider_uuid: str = 'test-provider-uuid',
@@ -86,7 +89,9 @@ class TestModelProviderServiceGetProviders:
service = ModelProviderService(ap)
# Execute
- result = await service.get_providers()
+ result = await service.get_providers(
+ WORKSPACE_UUID,
+ )
# Verify
assert result == []
@@ -115,7 +120,9 @@ class TestModelProviderServiceGetProviders:
service = ModelProviderService(ap)
# Execute
- result = await service.get_providers()
+ result = await service.get_providers(
+ WORKSPACE_UUID,
+ )
# Verify
assert len(result) == 2
@@ -143,7 +150,10 @@ class TestModelProviderServiceGetProviders:
service = ModelProviderService(ap)
# Execute
- result = await service.get_providers()
+ result = await service.get_providers(
+ WORKSPACE_UUID,
+ include_secret=True,
+ )
# Verify - api_keys should be parsed from string
assert result[0]['api_keys'] == ['key1', 'key2']
@@ -169,11 +179,41 @@ class TestModelProviderServiceGetProviders:
service = ModelProviderService(ap)
# Execute
- result = await service.get_providers()
+ result = await service.get_providers(
+ WORKSPACE_UUID,
+ )
# Verify - invalid JSON returns empty list
assert result[0]['api_keys'] == []
+ async def test_get_providers_masks_api_keys_for_resource_view(self):
+ ap = SimpleNamespace()
+ provider = _create_mock_provider(
+ api_keys=['first', 'second'],
+ base_url=(
+ 'https://provider-user:provider-password@api.provider.invalid/v1?access_token=url-secret®ion=sg'
+ ),
+ )
+ ap.persistence_mgr = SimpleNamespace(
+ execute_async=AsyncMock(return_value=_create_mock_result([provider])),
+ serialize_model=Mock(
+ return_value={
+ 'uuid': provider.uuid,
+ 'name': provider.name,
+ 'base_url': provider.base_url,
+ 'api_keys': provider.api_keys,
+ }
+ ),
+ )
+
+ result = await ModelProviderService(ap).get_providers(
+ WORKSPACE_UUID,
+ include_secret=False,
+ )
+
+ assert result[0]['api_keys'] == ['***', '***']
+ assert result[0]['base_url'] == ('https://***@api.provider.invalid/v1?access_token=***®ion=sg')
+
class TestModelProviderServiceGetProvider:
"""Tests for get_provider method."""
@@ -199,7 +239,7 @@ class TestModelProviderServiceGetProvider:
service = ModelProviderService(ap)
# Execute
- result = await service.get_provider('found-uuid')
+ result = await service.get_provider(WORKSPACE_UUID, 'found-uuid')
# Verify
assert result is not None
@@ -217,7 +257,7 @@ class TestModelProviderServiceGetProvider:
service = ModelProviderService(ap)
# Execute
- result = await service.get_provider('nonexistent-uuid')
+ result = await service.get_provider(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify
assert result is None
@@ -239,6 +279,7 @@ class TestModelProviderServiceCreateProvider:
runtime_provider.provider_entity = Mock()
runtime_provider.provider_entity.uuid = 'generated-uuid'
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
+ ap.model_mgr.cache_provider = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
@@ -246,12 +287,13 @@ class TestModelProviderServiceCreateProvider:
# Execute
provider_uuid = await service.create_provider(
+ WORKSPACE_UUID,
{
'name': 'New Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
- }
+ },
)
# Verify - UUID is generated
@@ -270,6 +312,7 @@ class TestModelProviderServiceCreateProvider:
runtime_provider.provider_entity = Mock()
runtime_provider.provider_entity.uuid = 'runtime-uuid'
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
+ ap.model_mgr.cache_provider = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock()
@@ -277,12 +320,13 @@ class TestModelProviderServiceCreateProvider:
# Execute
result_uuid = await service.create_provider(
+ WORKSPACE_UUID,
{
'name': 'Runtime Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
- }
+ },
)
# Verify - provider added to runtime dict and UUID generated
@@ -307,6 +351,7 @@ class TestModelProviderServiceUpdateProvider:
# Execute
await service.update_provider(
+ WORKSPACE_UUID,
'existing-uuid',
{
'uuid': 'should-be-removed', # Will be removed
@@ -315,7 +360,7 @@ class TestModelProviderServiceUpdateProvider:
)
# Verify - reload called
- ap.model_mgr.reload_provider.assert_called_once_with('existing-uuid')
+ ap.model_mgr.reload_provider.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid')
async def test_update_provider_reloads_runtime(self):
"""Reloads provider in runtime after update."""
@@ -330,7 +375,7 @@ class TestModelProviderServiceUpdateProvider:
service = ModelProviderService(ap)
# Execute
- await service.update_provider('update-uuid', {'name': 'New Name'})
+ await service.update_provider(WORKSPACE_UUID, 'update-uuid', {'name': 'New Name'})
# Verify
ap.model_mgr.reload_provider.assert_called_once()
@@ -354,7 +399,7 @@ class TestModelProviderServiceDeleteProvider:
# Execute & Verify
with pytest.raises(ValueError, match='Cannot delete provider: LLM models'):
- await service.delete_provider('provider-with-llm')
+ await service.delete_provider(WORKSPACE_UUID, 'provider-with-llm')
async def test_delete_provider_with_embedding_models_raises_error(self):
"""Raises ValueError when Embedding models reference provider."""
@@ -387,7 +432,7 @@ class TestModelProviderServiceDeleteProvider:
# Execute & Verify - should raise embedding error (LLM check passes, embedding check fails)
with pytest.raises(ValueError, match='Cannot delete provider: Embedding models'):
- await service.delete_provider('provider-with-embedding')
+ await service.delete_provider(WORKSPACE_UUID, 'provider-with-embedding')
async def test_delete_provider_with_rerank_models_raises_error(self):
"""Raises ValueError when Rerank models reference provider."""
@@ -420,7 +465,7 @@ class TestModelProviderServiceDeleteProvider:
# Execute & Verify - should raise rerank error (LLM and embedding checks pass, rerank check fails)
with pytest.raises(ValueError, match='Cannot delete provider: Rerank models'):
- await service.delete_provider('provider-with-rerank')
+ await service.delete_provider(WORKSPACE_UUID, 'provider-with-rerank')
async def test_delete_provider_no_models_success(self):
"""Deletes provider when no models reference it."""
@@ -439,10 +484,10 @@ class TestModelProviderServiceDeleteProvider:
service = ModelProviderService(ap)
# Execute
- await service.delete_provider('provider-no-models')
+ await service.delete_provider(WORKSPACE_UUID, 'provider-no-models')
# Verify - delete and remove called
- ap.model_mgr.remove_provider.assert_called_once_with('provider-no-models')
+ ap.model_mgr.remove_provider.assert_called_once_with(WORKSPACE_UUID, 'provider-no-models')
class TestModelProviderServiceGetProviderModelCounts:
@@ -476,9 +521,10 @@ class TestModelProviderServiceGetProviderModelCounts:
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
service = ModelProviderService(ap)
+ service.get_provider = AsyncMock(return_value={'uuid': 'provider-uuid'})
# Execute
- result = await service.get_provider_model_counts('provider-uuid')
+ result = await service.get_provider_model_counts(WORKSPACE_UUID, 'provider-uuid')
# Verify
assert result['llm_count'] == 3
@@ -497,9 +543,10 @@ class TestModelProviderServiceGetProviderModelCounts:
ap.persistence_mgr.execute_async = AsyncMock(return_value=zero_result)
service = ModelProviderService(ap)
+ service.get_provider = AsyncMock(return_value={'uuid': 'empty-provider'})
# Execute
- result = await service.get_provider_model_counts('empty-provider')
+ result = await service.get_provider_model_counts(WORKSPACE_UUID, 'empty-provider')
# Verify
assert result['llm_count'] == 0
@@ -530,6 +577,7 @@ class TestModelProviderServiceFindOrCreateProvider:
# Execute
result = await service.find_or_create_provider(
+ WORKSPACE_UUID,
requester='openai',
base_url='https://api.openai.com',
api_keys=['key1', 'key2'], # Same keys (sorted)
@@ -558,6 +606,7 @@ class TestModelProviderServiceFindOrCreateProvider:
# Execute with reversed key order
result = await service.find_or_create_provider(
+ WORKSPACE_UUID,
requester='openai',
base_url='https://api.openai.com',
api_keys=['key2', 'key1'], # Different order, should still match
@@ -578,6 +627,7 @@ class TestModelProviderServiceFindOrCreateProvider:
runtime_provider.provider_entity = Mock()
runtime_provider.provider_entity.uuid = None # Will be set by uuid.uuid4()
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
+ ap.model_mgr.cache_provider = AsyncMock()
# Mock no existing providers
mock_result = _create_mock_result([])
@@ -587,6 +637,7 @@ class TestModelProviderServiceFindOrCreateProvider:
# Execute
result = await service.find_or_create_provider(
+ WORKSPACE_UUID,
requester='new-requester',
base_url='https://new.api.com',
api_keys=['new-key'],
@@ -610,6 +661,7 @@ class TestModelProviderServiceFindOrCreateProvider:
runtime_provider.provider_entity = Mock()
runtime_provider.provider_entity.uuid = 'parsed-url-uuid'
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
+ ap.model_mgr.cache_provider = AsyncMock()
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
@@ -618,6 +670,7 @@ class TestModelProviderServiceFindOrCreateProvider:
# Execute
result_uuid = await service.find_or_create_provider(
+ WORKSPACE_UUID,
requester='custom',
base_url='https://api.example.com/v1',
api_keys=['key'],
@@ -644,17 +697,20 @@ class TestModelProviderServiceUpdateSpaceModelProviderApiKeys:
service = ModelProviderService(ap)
# Execute
- await service.update_space_model_provider_api_keys('space-api-key')
+ await service.update_space_model_provider_api_keys(WORKSPACE_UUID, 'space-api-key')
# Verify - update and reload called for Space provider UUID
- ap.model_mgr.reload_provider.assert_called_once_with('00000000-0000-0000-0000-000000000000')
+ ap.model_mgr.reload_provider.assert_called_once_with(
+ WORKSPACE_UUID,
+ '00000000-0000-0000-0000-000000000000',
+ )
class TestModelProviderServiceScanProviderModels:
"""Tests for scan_provider_models method."""
async def test_scan_provider_not_found_raises_error(self):
- """Raises ValueError when provider not found."""
+ """Raises a non-enumerating not-found error when provider is outside the Workspace."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
@@ -665,8 +721,8 @@ class TestModelProviderServiceScanProviderModels:
service = ModelProviderService(ap)
# Execute & Verify
- with pytest.raises(ValueError, match='provider not found'):
- await service.scan_provider_models('nonexistent-uuid')
+ with pytest.raises(WorkspaceNotFoundError, match='Provider not found'):
+ await service.scan_provider_models(WORKSPACE_UUID, 'nonexistent-uuid')
async def test_scan_provider_returns_models_list(self):
"""Returns scanned models list."""
@@ -718,7 +774,7 @@ class TestModelProviderServiceScanProviderModels:
service = ModelProviderService(ap)
# Execute
- result = await service.scan_provider_models('scan-uuid')
+ result = await service.scan_provider_models(WORKSPACE_UUID, 'scan-uuid')
# Verify
assert 'models' in result
@@ -771,7 +827,7 @@ class TestModelProviderServiceScanProviderModels:
service = ModelProviderService(ap)
# Execute - filter for LLM only
- result = await service.scan_provider_models('filter-uuid', model_type='llm')
+ result = await service.scan_provider_models(WORKSPACE_UUID, 'filter-uuid', model_type='llm')
# Verify - only LLM models returned
assert len(result['models']) == 1
@@ -806,11 +862,11 @@ class TestModelProviderServiceScanProviderModels:
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
- ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(
- return_value=[{'name': 'Qwen3-Reranker-8B'}]
- )
+ ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(return_value=[{'name': 'Qwen3-Reranker-8B'}])
- result = await ModelProviderService(ap).scan_provider_models('rerank-scan-uuid', model_type='rerank')
+ result = await ModelProviderService(ap).scan_provider_models(
+ WORKSPACE_UUID, 'rerank-scan-uuid', model_type='rerank'
+ )
assert result['models'][0]['type'] == 'rerank'
assert result['models'][0]['already_added'] is True
@@ -848,7 +904,7 @@ class TestModelProviderServiceScanProviderModels:
# Execute & Verify
with pytest.raises(ValueError, match='current provider does not support model scanning'):
- await service.scan_provider_models('no-scan-uuid')
+ await service.scan_provider_models(WORKSPACE_UUID, 'no-scan-uuid')
async def test_scan_provider_marks_already_added_models(self):
"""Marks models that are already added."""
@@ -898,7 +954,7 @@ class TestModelProviderServiceScanProviderModels:
service = ModelProviderService(ap)
# Execute
- result = await service.scan_provider_models('already-added-uuid')
+ result = await service.scan_provider_models(WORKSPACE_UUID, 'already-added-uuid')
# Verify - existing model marked as already_added
existing_model = next(m for m in result['models'] if m['name'] == 'Existing Model')
@@ -906,3 +962,46 @@ class TestModelProviderServiceScanProviderModels:
new_model = next(m for m in result['models'] if m['name'] == 'New Model')
assert new_model['already_added'] is False
+
+
+class TestProviderSecretRoundtrip:
+ async def test_masked_api_keys_update_preserves_existing_values(self):
+ write_result = Mock(rowcount=1)
+ ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=write_result)),
+ model_mgr=SimpleNamespace(reload_provider=AsyncMock()),
+ )
+ service = ModelProviderService(ap)
+ service.get_provider = AsyncMock(
+ return_value={
+ 'uuid': 'provider-secret',
+ 'api_keys': ['first-secret', 'second-secret'],
+ }
+ )
+
+ await service.update_provider(
+ WORKSPACE_UUID,
+ 'provider-secret',
+ {'name': 'Updated', 'api_keys': ['***', 'replacement-secret']},
+ )
+
+ statement = ap.persistence_mgr.execute_async.await_args.args[0]
+ stored_api_keys = next(value.value for column, value in statement._values.items() if column.key == 'api_keys')
+ assert stored_api_keys == ['first-secret', 'replacement-secret']
+
+ async def test_extra_masked_api_key_is_rejected(self):
+ ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock()),
+ model_mgr=SimpleNamespace(reload_provider=AsyncMock()),
+ )
+ service = ModelProviderService(ap)
+ service.get_provider = AsyncMock(return_value={'uuid': 'provider-secret', 'api_keys': ['only-secret']})
+
+ with pytest.raises(ValueError, match='no existing value'):
+ await service.update_provider(
+ WORKSPACE_UUID,
+ 'provider-secret',
+ {'api_keys': ['***', '***']},
+ )
+
+ ap.persistence_mgr.execute_async.assert_not_awaited()
diff --git a/tests/unit_tests/api/service/test_secret_redaction.py b/tests/unit_tests/api/service/test_secret_redaction.py
new file mode 100644
index 000000000..432613a06
--- /dev/null
+++ b/tests/unit_tests/api/service/test_secret_redaction.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+import copy
+
+import pytest
+
+from langbot.pkg.api.http.service.secrets import (
+ contains_secret_placeholder,
+ redact_secrets,
+ restore_secret_placeholders,
+)
+
+
+RAW_CONFIG = {
+ 'apiKey': 'api-secret',
+ 'dify_apikey': 'dify-secret',
+ 'base_url': (
+ 'https://service-user:service-password@api.invalid/v1'
+ '?api_key=query-secret®ion=sg&X-Amz-Signature=signed-secret'
+ ),
+ 'nested': {
+ 'headers': {
+ 'Authorization': 'Bearer nested-secret',
+ 'X-API-Key': 'header-secret',
+ 'Accept': 'application/json',
+ },
+ 'webhook-url': 'https://hooks.invalid/path?token=secret',
+ 'public_key': 'public-material',
+ 'tokenizer': 'not-a-secret',
+ },
+ 'credentials': {'username': 'service-user', 'password': 'service-password'},
+ 'secret_list': ['first-secret', {'value': 'second-secret'}],
+ 'empty_secret': '',
+ 'enabled': True,
+}
+
+
+def test_recursive_redaction_is_shape_preserving_and_does_not_mutate_source():
+ source = copy.deepcopy(RAW_CONFIG)
+
+ redacted = redact_secrets(source)
+
+ assert redacted['apiKey'] == '***'
+ assert redacted['dify_apikey'] == '***'
+ assert redacted['base_url'] == ('https://***@api.invalid/v1?api_key=***®ion=sg&X-Amz-Signature=***')
+ assert redacted['nested']['headers'] == {
+ 'Authorization': '***',
+ 'X-API-Key': '***',
+ 'Accept': 'application/json',
+ }
+ assert redacted['nested']['webhook-url'] == '***'
+ assert redacted['nested']['public_key'] == 'public-material'
+ assert redacted['nested']['tokenizer'] == 'not-a-secret'
+ assert redacted['credentials'] == {'username': '***', 'password': '***'}
+ assert redacted['secret_list'] == ['***', {'value': '***'}]
+ assert redacted['empty_secret'] == ''
+ assert redacted['enabled'] is True
+ assert source == RAW_CONFIG
+
+
+def test_masked_roundtrip_preserves_existing_secrets_and_accepts_replace_and_clear():
+ submitted = redact_secrets(RAW_CONFIG)
+ submitted['enabled'] = False
+ submitted['apiKey'] = 'replacement-secret'
+ submitted['nested']['headers']['X-API-Key'] = ''
+
+ restored = restore_secret_placeholders(submitted, RAW_CONFIG)
+
+ assert restored['apiKey'] == 'replacement-secret'
+ assert restored['dify_apikey'] == 'dify-secret'
+ assert restored['nested']['headers']['Authorization'] == 'Bearer nested-secret'
+ assert restored['nested']['headers']['X-API-Key'] == ''
+ assert restored['nested']['webhook-url'] == RAW_CONFIG['nested']['webhook-url']
+ assert restored['base_url'] == RAW_CONFIG['base_url']
+ assert restored['enabled'] is False
+ assert RAW_CONFIG['apiKey'] == 'api-secret'
+
+
+def test_new_or_extra_masked_secret_fails_closed():
+ assert contains_secret_placeholder({'headers': {'Authorization': '***'}})
+ assert contains_secret_placeholder({'base_url': 'https://***@api.invalid?token=***'})
+ with pytest.raises(ValueError, match='no existing value'):
+ restore_secret_placeholders({'api_key': '***'})
+ with pytest.raises(ValueError, match='no existing value'):
+ restore_secret_placeholders(
+ {'api_keys': ['***', '***']},
+ {'api_keys': ['existing']},
+ )
diff --git a/tests/unit_tests/api/service/test_space_service.py b/tests/unit_tests/api/service/test_space_service.py
index f02a18b5b..d92265743 100644
--- a/tests/unit_tests/api/service/test_space_service.py
+++ b/tests/unit_tests/api/service/test_space_service.py
@@ -13,6 +13,10 @@ Source: src/langbot/pkg/api/http/service/space.py
from __future__ import annotations
+from collections import OrderedDict
+import json
+from urllib.parse import parse_qs, urlsplit
+
import pytest
from unittest.mock import AsyncMock, Mock, patch, MagicMock
from types import SimpleNamespace
@@ -26,6 +30,23 @@ from langbot.pkg.entity.persistence.user import User
pytestmark = pytest.mark.asyncio
+def _set_response_body(response: MagicMock, body: dict | str) -> None:
+ """Configure an aiohttp-like streaming body on an HTTP response mock."""
+
+ raw_body = body.encode() if isinstance(body, str) else json.dumps(body).encode()
+
+ class Content:
+ async def iter_chunked(self, _chunk_size: int):
+ midpoint = max(len(raw_body) // 2, 1)
+ yield raw_body[:midpoint]
+ if midpoint < len(raw_body):
+ yield raw_body[midpoint:]
+
+ response.headers = {}
+ response.content = Content()
+ response.charset = 'utf-8'
+
+
def _create_mock_user(
email: str = 'test@example.com',
account_type: str = 'space',
@@ -73,7 +94,7 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
result = service.get_oauth_authorize_url('http://localhost/callback')
# Verify
- assert 'redirect_uri=http://localhost/callback' in result
+ assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback']
assert 'https://space.langbot.app/auth/authorize' in result
def test_get_oauth_authorize_url_with_state(self):
@@ -93,8 +114,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
result = service.get_oauth_authorize_url('http://localhost/callback', state='random_state')
# Verify
- assert 'redirect_uri=http://localhost/callback' in result
- assert 'state=random_state' in result
+ params = parse_qs(urlsplit(result).query)
+ assert params['redirect_uri'] == ['http://localhost/callback']
+ assert params['state'] == ['random_state']
def test_get_oauth_authorize_url_default_config(self):
"""Uses default OAuth URL when config not set."""
@@ -289,6 +311,40 @@ class TestSpaceServiceGetCredits:
# Verify - returns cached value without API call
assert result == 100
+ async def test_cached_credit_lookup_does_not_scan_all_users(self):
+ ap = SimpleNamespace()
+ ap.instance_config = SimpleNamespace(data={})
+ ap.persistence_mgr = SimpleNamespace()
+ service = SpaceService(ap)
+
+ class AtMostOneStepOrderedDict(OrderedDict):
+ def __iter__(self):
+ iterator = super().__iter__()
+ yielded = False
+
+ def next_entry():
+ nonlocal yielded
+ if yielded:
+ raise AssertionError('credits cache scanned all users')
+ yielded = True
+ return next(iterator)
+
+ class AtMostOneStepIterator:
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ return next_entry()
+
+ return AtMostOneStepIterator()
+
+ now = time.time()
+ service._credits_cache = AtMostOneStepOrderedDict(
+ (f'user-{index}@example.com', (index, now)) for index in range(512)
+ )
+
+ assert await service.get_credits('user-511@example.com') == 511
+
async def test_get_credits_cache_expired_refreshes(self):
"""Refreshes expired cache."""
# Setup
@@ -403,6 +459,7 @@ class TestSpaceServiceRefreshToken:
},
}
)
+ _set_response_body(mock_response, mock_response.json.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
@@ -438,6 +495,7 @@ class TestSpaceServiceRefreshToken:
}
)
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Invalid refresh token"}')
+ _set_response_body(mock_response, mock_response.json.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
@@ -464,6 +522,7 @@ class TestSpaceServiceRefreshToken:
mock_response = MagicMock()
mock_response.status = 500
mock_response.text = AsyncMock(return_value='Internal Server Error')
+ _set_response_body(mock_response, mock_response.text.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
@@ -503,6 +562,7 @@ class TestSpaceServiceExchangeOAuthCode:
},
}
)
+ _set_response_body(mock_response, mock_response.json.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
@@ -532,6 +592,7 @@ class TestSpaceServiceExchangeOAuthCode:
mock_response.status = 200
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Invalid code'})
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Invalid code"}')
+ _set_response_body(mock_response, mock_response.json.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
@@ -570,6 +631,7 @@ class TestSpaceServiceGetUserInfoRaw:
},
}
)
+ _set_response_body(mock_response, mock_response.json.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
@@ -600,6 +662,7 @@ class TestSpaceServiceGetUserInfoRaw:
mock_response.status = 200
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Unauthorized'})
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Unauthorized"}')
+ _set_response_body(mock_response, mock_response.json.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
@@ -700,6 +763,7 @@ class TestSpaceServiceGetModels:
},
}
)
+ _set_response_body(mock_response, mock_response.json.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
@@ -730,6 +794,7 @@ class TestSpaceServiceGetModels:
mock_response.status = 200
mock_response.json = AsyncMock(return_value={'code': 1, 'msg': 'Unauthorized'})
mock_response.text = AsyncMock(return_value='{"code":1,"msg":"Unauthorized"}')
+ _set_response_body(mock_response, mock_response.json.return_value)
with patch('langbot.pkg.api.http.service.space.httpclient.get_session') as mock_session:
mock_session_obj = MagicMock()
diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py
index c5d37f167..96d501b95 100644
--- a/tests/unit_tests/api/service/test_user_service.py
+++ b/tests/unit_tests/api/service/test_user_service.py
@@ -14,17 +14,124 @@ Source: src/langbot/pkg/api/http/service/user.py
from __future__ import annotations
import pytest
+import jwt
+import datetime
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
-from langbot.pkg.api.http.service.user import UserService
-from langbot.pkg.entity.persistence.user import User
-from langbot.pkg.entity.errors.account import AccountEmailMismatchError
+from langbot.pkg.api.http.service.user import (
+ ControlPlaneDirectoryRequiredError,
+ UserService,
+)
+from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User
+from langbot.pkg.entity.errors.account import (
+ AccountEmailMismatchError,
+ SpaceAccountBindingRequiredError,
+ SpaceAccountNotRegisteredError,
+)
+from langbot.pkg.utils.bounded_executor import BlockingWorkCapacityError
pytestmark = pytest.mark.asyncio
+async def test_password_hashing_rejects_concurrent_waiters() -> None:
+ service = UserService(SimpleNamespace())
+ await service._password_hash_lock.acquire()
+ try:
+ with pytest.raises(
+ BlockingWorkCapacityError,
+ match='Password hashing capacity reached',
+ ):
+ await service._hash_password('secret')
+ finally:
+ service._password_hash_lock.release()
+
+
+class TestSpaceOAuthState:
+ async def test_login_state_is_opaque_single_use(self):
+ service = UserService(SimpleNamespace())
+
+ state = await service.issue_space_oauth_state('login')
+
+ assert state.count('.') == 0
+ assert await service.consume_space_oauth_state(state, 'login') is None
+ with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
+ await service.consume_space_oauth_state(state, 'login')
+
+ async def test_bind_state_resolves_only_bound_active_account(self):
+ service = UserService(SimpleNamespace())
+ account = SimpleNamespace(uuid='account-a', status=AccountStatus.ACTIVE.value)
+ service.get_user_by_uuid = AsyncMock(return_value=account)
+
+ state = await service.issue_space_oauth_state('bind', account_uuid='account-a')
+
+ assert await service.consume_space_oauth_state(state, 'bind') is account
+ service.get_user_by_uuid.assert_awaited_once_with('account-a')
+
+ async def test_state_purpose_mismatch_is_rejected_and_consumed(self):
+ service = UserService(SimpleNamespace())
+ state = await service.issue_space_oauth_state('login')
+
+ with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
+ await service.consume_space_oauth_state(state, 'bind')
+ with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
+ await service.consume_space_oauth_state(state, 'login')
+
+ async def test_expired_state_is_rejected(self):
+ service = UserService(SimpleNamespace())
+ state = await service.issue_space_oauth_state('login')
+ digest = service._space_oauth_state_digest(state)
+ purpose, account_uuid, _, launch_workspace_uuid = service._space_oauth_states[digest]
+ service._space_oauth_states[digest] = (purpose, account_uuid, 0, launch_workspace_uuid)
+
+ with pytest.raises(ValueError, match='Invalid or expired OAuth state'):
+ await service.consume_space_oauth_state(state, 'login')
+
+ async def test_login_state_can_carry_launch_workspace_without_changing_normal_return(self):
+ service = UserService(SimpleNamespace())
+ state = await service.issue_space_oauth_state(
+ 'login',
+ launch_workspace_uuid='workspace-a',
+ )
+
+ assert await service.consume_space_oauth_state(state, 'login') is None
+
+ state = await service.issue_space_oauth_state(
+ 'login',
+ launch_workspace_uuid='workspace-a',
+ )
+ consumed = await service.consume_space_oauth_state_details(state, 'login')
+ assert consumed.account is None
+ assert consumed.launch_workspace_uuid == 'workspace-a'
+
+ async def test_issue_state_does_not_scan_all_live_states(self, monkeypatch):
+ service = UserService(SimpleNamespace())
+ for _ in range(512):
+ await service.issue_space_oauth_state('login')
+
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('OAuth state issuance scanned all live states')
+
+ def keys(self):
+ raise AssertionError('OAuth state issuance scanned all live states')
+
+ def items(self):
+ raise AssertionError('OAuth state issuance scanned all live states')
+
+ def values(self):
+ raise AssertionError('OAuth state issuance scanned all live states')
+
+ guarded_states = NoGlobalIterationDict(service._space_oauth_states)
+ monkeypatch.setattr(service, '_space_oauth_states', guarded_states)
+
+ state = await service.issue_space_oauth_state('login')
+
+ assert await service.consume_space_oauth_state(state, 'login') is None
+ assert len(guarded_states) == 512
+
+
def _create_mock_user(
email: str = 'test@example.com',
password: str = 'hashed_password',
@@ -34,6 +141,7 @@ def _create_mock_user(
"""Helper to create mock User entity."""
user = Mock(spec=User)
user.user = email
+ user.uuid = f'account-{email}'
user.password = password
user.account_type = account_type
user.space_account_uuid = space_account_uuid
@@ -102,6 +210,41 @@ class TestUserServiceIsInitialized:
assert result is False
+class TestUserServiceGetLoginCapabilities:
+ """Tests for public login capability discovery."""
+
+ async def test_uses_explicit_identity_discovery_scope(self):
+ discovery_result = Mock()
+ discovery_result.one = Mock(return_value=(1, 2))
+ discovery_session = SimpleNamespace(execute=AsyncMock(return_value=discovery_result))
+
+ class DiscoveryContext:
+ async def __aenter__(self):
+ return SimpleNamespace(session=discovery_session)
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ ap = SimpleNamespace()
+ ap.persistence_mgr = SimpleNamespace(
+ current_session=Mock(return_value=None),
+ identity_discovery_uow=Mock(return_value=DiscoveryContext()),
+ execute_async=AsyncMock(side_effect=AssertionError('unscoped persistence access')),
+ )
+ ap.workspace_service = SimpleNamespace(instance_uuid='instance-a')
+ service = UserService(ap)
+
+ result = await service.get_login_capabilities()
+
+ assert result == {
+ 'password_login_enabled': True,
+ 'space_login_enabled': True,
+ }
+ ap.persistence_mgr.identity_discovery_uow.assert_called_once()
+ discovery_session.execute.assert_awaited_once()
+ ap.persistence_mgr.execute_async.assert_not_awaited()
+
+
class TestUserServiceGetUserByEmail:
"""Tests for get_user_by_email method."""
@@ -309,6 +452,50 @@ class TestUserServiceVerifyJwtToken:
with pytest.raises(Exception): # jwt.DecodeError or similar
await service.verify_jwt_token('invalid.token.here')
+ async def test_verify_jwt_token_rejects_foreign_audience(self):
+ ap = SimpleNamespace()
+ ap.instance_config = SimpleNamespace()
+ ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
+ service = UserService(ap)
+ token = jwt.encode(
+ {
+ 'user': 'verify@example.com',
+ 'iss': 'langbot-core',
+ 'aud': 'langbot-instance:another-instance',
+ 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1),
+ },
+ 'test_secret',
+ algorithm='HS256',
+ )
+
+ with pytest.raises(jwt.InvalidAudienceError):
+ await service.verify_jwt_token(token)
+
+ async def test_verify_jwt_token_accepts_legacy_community_token_only_in_oss(self):
+ ap = SimpleNamespace()
+ ap.instance_config = SimpleNamespace()
+ ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 3600}}}
+ ap.workspace_service = SimpleNamespace(
+ instance_uuid='instance-a',
+ policy=SimpleNamespace(multi_workspace_enabled=False),
+ )
+ service = UserService(ap)
+ legacy_token = jwt.encode(
+ {
+ 'user': 'legacy@example.com',
+ 'iss': 'LangBot-community',
+ 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1),
+ },
+ 'test_secret',
+ algorithm='HS256',
+ )
+
+ assert await service.verify_jwt_token(legacy_token) == 'legacy@example.com'
+
+ ap.workspace_service.policy.multi_workspace_enabled = True
+ with pytest.raises(jwt.MissingRequiredClaimError):
+ await service.verify_jwt_token(legacy_token)
+
class TestUserServiceResetPassword:
"""Tests for reset_password method."""
@@ -476,6 +663,71 @@ class TestUserServiceCreateOrUpdateSpaceUser:
ap.persistence_mgr.execute_async.assert_called()
assert updated_user.space_account_uuid == 'existing-space-uuid'
+ async def test_cloud_login_updates_only_the_projected_space_account(self):
+ projected = SimpleNamespace(
+ uuid='projected-space-uuid',
+ user='Cloud Owner',
+ normalized_email='owner@example.com',
+ password='',
+ account_type='space',
+ status=AccountStatus.ACTIVE.value,
+ source=AccountSource.CLOUD_PROJECTION.value,
+ projection_revision=7,
+ space_account_uuid='projected-space-uuid',
+ )
+ persistence = SimpleNamespace(execute_async=AsyncMock())
+ ap = SimpleNamespace(
+ persistence_mgr=persistence,
+ workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=True)),
+ )
+ service = UserService(ap)
+ service.get_user_by_space_account_uuid = AsyncMock(side_effect=[projected, projected])
+
+ result = await service.create_or_update_space_user(
+ space_account_uuid='projected-space-uuid',
+ email='OWNER@example.com',
+ access_token='access-token',
+ refresh_token='refresh-token',
+ api_key='api-key',
+ expires_in=3600,
+ )
+
+ assert result is projected
+ persistence.execute_async.assert_awaited_once()
+
+ async def test_cloud_login_never_creates_an_unprojected_account(self):
+ persistence = SimpleNamespace(execute_async=AsyncMock())
+ ap = SimpleNamespace(
+ persistence_mgr=persistence,
+ workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=True)),
+ )
+ service = UserService(ap)
+ service.get_user_by_space_account_uuid = AsyncMock(return_value=None)
+
+ with pytest.raises(
+ ControlPlaneDirectoryRequiredError,
+ match='verified Cloud directory',
+ ):
+ await service.create_or_update_space_user(
+ space_account_uuid='unknown-space-uuid',
+ email='unknown@example.com',
+ access_token='access-token',
+ refresh_token='refresh-token',
+ api_key='api-key',
+ expires_in=3600,
+ )
+
+ persistence.execute_async.assert_not_awaited()
+
+ async def test_cloud_invitation_registration_requires_space_identity(self):
+ ap = SimpleNamespace(
+ workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=True)),
+ )
+ service = UserService(ap)
+
+ with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
+ await service.register_invited_account('invite-token', 'member@example.com', 'password')
+
async def test_create_or_update_new_space_user_first_init(self):
"""Creates new Space user on first initialization."""
# Setup
@@ -522,8 +774,8 @@ class TestUserServiceCreateOrUpdateSpaceUser:
# Verify
assert result.space_account_uuid == 'new-space-uuid'
- async def test_create_or_update_space_user_already_initialized_raises_error(self):
- """Raises AccountEmailMismatchError when system already initialized and user not found."""
+ async def test_create_or_update_space_user_already_initialized_reports_unknown_space_email(self):
+ """Unknown Space email is distinct from an existing local Account collision."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
@@ -538,7 +790,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
service.is_initialized = AsyncMock(return_value=True) # Already initialized
# Execute & Verify
- with pytest.raises(AccountEmailMismatchError):
+ with pytest.raises(SpaceAccountNotRegisteredError):
await service.create_or_update_space_user(
space_account_uuid='unknown-space-uuid',
email='unknown@example.com',
@@ -548,6 +800,78 @@ class TestUserServiceCreateOrUpdateSpaceUser:
expires_in=3600,
)
+ async def test_unknown_space_subject_cannot_claim_existing_account_by_email(self):
+ """An OAuth login collision requires the explicit account-bound bind flow."""
+ existing_user = _create_mock_user(
+ email='owner@example.com',
+ account_type='local',
+ space_account_uuid=None,
+ )
+ ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock()),
+ provider_service=SimpleNamespace(update_space_model_provider_api_keys=AsyncMock()),
+ space_service=SimpleNamespace(
+ get_user_info_raw=AsyncMock(
+ return_value={
+ 'account': {
+ 'uuid': 'attacker-space-subject',
+ 'email': 'owner@example.com',
+ },
+ 'api_key': 'attacker-api-key',
+ }
+ )
+ ),
+ )
+ service = UserService(ap)
+ service.get_user_by_space_account_uuid = AsyncMock(return_value=None)
+ service.get_user_by_email = AsyncMock(return_value=existing_user)
+ service.generate_jwt_token = AsyncMock(return_value='must-not-be-issued')
+
+ with pytest.raises(SpaceAccountBindingRequiredError):
+ await service.authenticate_space_user(
+ 'attacker-access-token',
+ 'attacker-refresh-token',
+ 3600,
+ )
+
+ ap.persistence_mgr.execute_async.assert_not_awaited()
+ ap.provider_service.update_space_model_provider_api_keys.assert_not_awaited()
+ service.generate_jwt_token.assert_not_awaited()
+
+ async def test_oss_space_provider_refresh_requires_workspace_owner(self):
+ member_account = _create_mock_user(email='member@example.com', space_account_uuid='space-member')
+ access = SimpleNamespace(
+ workspace=SimpleNamespace(uuid='workspace-a'),
+ membership=SimpleNamespace(role='admin'),
+ )
+ provider_service = SimpleNamespace(update_space_model_provider_api_keys=AsyncMock())
+ ap = SimpleNamespace(
+ workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=False)),
+ workspace_collaboration_service=SimpleNamespace(list_account_workspaces=AsyncMock(return_value=[access])),
+ provider_service=provider_service,
+ )
+
+ await UserService(ap)._update_space_provider_for_account(member_account, 'member-api-key')
+
+ provider_service.update_space_model_provider_api_keys.assert_not_awaited()
+
+ async def test_oss_space_provider_refresh_uses_workspace_owner_credentials(self):
+ owner_account = _create_mock_user(email='owner@example.com', space_account_uuid='space-owner')
+ access = SimpleNamespace(
+ workspace=SimpleNamespace(uuid='workspace-a'),
+ membership=SimpleNamespace(role='owner'),
+ )
+ provider_service = SimpleNamespace(update_space_model_provider_api_keys=AsyncMock())
+ ap = SimpleNamespace(
+ workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=False)),
+ workspace_collaboration_service=SimpleNamespace(list_account_workspaces=AsyncMock(return_value=[access])),
+ provider_service=provider_service,
+ )
+
+ await UserService(ap)._update_space_provider_for_account(owner_account, 'owner-api-key')
+
+ provider_service.update_space_model_provider_api_keys.assert_awaited_once_with('workspace-a', 'owner-api-key')
+
async def test_create_or_update_space_user_no_expiry(self):
"""Creates Space user without token expiry."""
# Setup
@@ -594,6 +918,58 @@ class TestUserServiceCreateOrUpdateSpaceUser:
assert result is not None
assert result.space_account_uuid == 'noexpiry-uuid'
+ async def test_bind_space_account_rejects_different_email(self):
+ service = UserService(SimpleNamespace())
+ service.get_user_by_email = AsyncMock(return_value=_create_mock_user(email='invited@example.com'))
+ service.ap.space_service = SimpleNamespace(
+ exchange_oauth_code=AsyncMock(
+ return_value={'access_token': 'access', 'refresh_token': 'refresh', 'expires_in': 3600}
+ ),
+ get_user_info_raw=AsyncMock(
+ return_value={
+ 'account': {'uuid': 'space-other', 'email': 'other@example.com'},
+ 'api_key': 'key',
+ }
+ ),
+ )
+ service.get_user_by_space_account_uuid = AsyncMock(return_value=None)
+ service._identity_execute = AsyncMock()
+
+ with pytest.raises(AccountEmailMismatchError):
+ await service.bind_space_account('invited@example.com', 'code')
+
+ service._identity_execute.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_get_workspace_owner_returns_user_object_from_core_connection_result(self):
+ service = UserService(SimpleNamespace())
+ owner = _create_mock_user('owner@example.com', password='pw')
+ service.ap.persistence_mgr = SimpleNamespace(
+ current_session=lambda: SimpleNamespace(scalar=AsyncMock(return_value=owner)),
+ )
+
+ resolved = await service.get_workspace_owner('workspace-1')
+
+ assert resolved is owner
+
+
+class TestUserServiceLoginCapabilities:
+ async def test_capabilities_are_derived_from_all_accounts(self):
+ result = SimpleNamespace(one=lambda: (2, 1))
+ ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=result)))
+
+ capabilities = await UserService(ap).get_login_capabilities()
+
+ assert capabilities == {'password_login_enabled': True, 'space_login_enabled': True}
+
+ async def test_capabilities_disable_absent_login_methods(self):
+ result = SimpleNamespace(one=lambda: (0, 0))
+ ap = SimpleNamespace(persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=result)))
+
+ capabilities = await UserService(ap).get_login_capabilities()
+
+ assert capabilities == {'password_login_enabled': False, 'space_login_enabled': False}
+
class TestUserServiceCreateUserLock:
"""Tests for create_user_lock attribute."""
diff --git a/tests/unit_tests/api/service/test_webhook_service.py b/tests/unit_tests/api/service/test_webhook_service.py
index 7a5a075ef..64f58de05 100644
--- a/tests/unit_tests/api/service/test_webhook_service.py
+++ b/tests/unit_tests/api/service/test_webhook_service.py
@@ -14,15 +14,23 @@ Source: src/langbot/pkg/api/http/service/webhook.py
from __future__ import annotations
+import datetime
+
import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import create_async_engine
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
from langbot.pkg.api.http.service.webhook import WebhookService
+from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.webhook import Webhook
+from langbot.pkg.entity.persistence.workspace import Workspace
pytestmark = pytest.mark.asyncio
+WORKSPACE_UUID = 'workspace-a'
def _create_mock_webhook(
@@ -42,11 +50,20 @@ def _create_mock_webhook(
return webhook
-def _create_mock_result(items: list = None, first_item=None):
+def _create_mock_result(items: list = None, first_item=None, scalar_value=None):
"""Create mock result object for persistence queries."""
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
+ result.scalar = Mock(return_value=scalar_value)
+ result.rowcount = 1
+ return result
+
+
+def _create_write_result(rowcount: int = 1, inserted_id: int = 1):
+ result = Mock()
+ result.rowcount = rowcount
+ result.inserted_primary_key = [inserted_id]
return result
@@ -71,7 +88,7 @@ class TestWebhookServiceGetWebhooks:
service = WebhookService(ap)
# Execute
- result = await service.get_webhooks()
+ result = await service.get_webhooks(WORKSPACE_UUID)
# Verify
assert result == []
@@ -100,7 +117,7 @@ class TestWebhookServiceGetWebhooks:
service = WebhookService(ap)
# Execute
- result = await service.get_webhooks()
+ result = await service.get_webhooks(WORKSPACE_UUID)
# Verify
assert len(result) == 2
@@ -119,6 +136,7 @@ class TestWebhookServiceCreateWebhook:
# Mock insert result
insert_result = Mock()
+ insert_result.inserted_primary_key = [1]
# Mock select result for retrieving created webhook
created_webhook = _create_mock_webhook(
@@ -137,6 +155,8 @@ class TestWebhookServiceCreateWebhook:
nonlocal call_count
call_count += 1
if call_count == 1:
+ return _create_mock_result(scalar_value=0) # Count
+ if call_count == 2:
return insert_result # Insert
return select_result # Select
@@ -155,6 +175,7 @@ class TestWebhookServiceCreateWebhook:
# Execute
result = await service.create_webhook(
+ WORKSPACE_UUID,
name='New Webhook',
url='http://new.example.com/webhook',
description='New Description',
@@ -187,7 +208,9 @@ class TestWebhookServiceCreateWebhook:
nonlocal call_count
call_count += 1
if call_count == 1:
- return Mock() # Insert
+ return _create_mock_result(scalar_value=0)
+ if call_count == 2:
+ return _create_write_result() # Insert
return _create_mock_result(first_item=created_webhook)
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
@@ -204,7 +227,11 @@ class TestWebhookServiceCreateWebhook:
service = WebhookService(ap)
# Execute - only name and url required
- result = await service.create_webhook(name='Minimal Webhook', url='http://minimal.example.com')
+ result = await service.create_webhook(
+ WORKSPACE_UUID,
+ name='Minimal Webhook',
+ url='http://minimal.example.com',
+ )
# Verify defaults
assert result['description'] == ''
@@ -224,7 +251,9 @@ class TestWebhookServiceCreateWebhook:
nonlocal call_count
call_count += 1
if call_count == 1:
- return Mock()
+ return _create_mock_result(scalar_value=0)
+ if call_count == 2:
+ return _create_write_result()
return _create_mock_result(first_item=created_webhook)
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
@@ -233,11 +262,52 @@ class TestWebhookServiceCreateWebhook:
service = WebhookService(ap)
# Execute
- result = await service.create_webhook(name='Disabled', url='http://disabled.com', enabled=False)
+ result = await service.create_webhook(
+ WORKSPACE_UUID,
+ name='Disabled',
+ url='http://disabled.com',
+ enabled=False,
+ )
# Verify
assert result['enabled'] is False
+ async def test_create_webhook_rejects_workspace_at_capacity(self):
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={'webhooks': {'max_per_workspace': 2}},
+ ),
+ persistence_mgr=SimpleNamespace(
+ execute_async=AsyncMock(return_value=_create_mock_result(scalar_value=2)),
+ ),
+ )
+
+ service = WebhookService(ap)
+
+ with pytest.raises(ValueError, match=r'Maximum number of webhooks \(2\) reached'):
+ await service.create_webhook(
+ WORKSPACE_UUID,
+ name='Too many',
+ url='https://example.invalid',
+ )
+
+ ap.persistence_mgr.execute_async.assert_awaited_once()
+
+ async def test_max_per_workspace_clamps_invalid_and_oversized_values(self):
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={'webhooks': {'max_per_workspace': 999999}},
+ )
+ )
+ service = WebhookService(ap)
+ assert service.max_per_workspace() == 64
+
+ ap.instance_config.data['webhooks']['max_per_workspace'] = 0
+ assert service.max_per_workspace() == 1
+
+ ap.instance_config.data['webhooks']['max_per_workspace'] = 'invalid'
+ assert service.max_per_workspace() == 16
+
class TestWebhookServiceGetWebhook:
"""Tests for get_webhook method."""
@@ -262,7 +332,7 @@ class TestWebhookServiceGetWebhook:
service = WebhookService(ap)
# Execute
- result = await service.get_webhook(1)
+ result = await service.get_webhook(WORKSPACE_UUID, 1)
# Verify
assert result is not None
@@ -281,7 +351,7 @@ class TestWebhookServiceGetWebhook:
service = WebhookService(ap)
# Execute
- result = await service.get_webhook(999)
+ result = await service.get_webhook(WORKSPACE_UUID, 999)
# Verify
assert result is None
@@ -298,7 +368,7 @@ class TestWebhookServiceGetWebhook:
service = WebhookService(ap)
# Execute
- result = await service.get_webhook(0)
+ result = await service.get_webhook(WORKSPACE_UUID, 0)
# Verify - should return None (no webhook with ID 0)
assert result is None
@@ -312,12 +382,12 @@ class TestWebhookServiceUpdateWebhook:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result())
service = WebhookService(ap)
# Execute
- await service.update_webhook(1, name='Updated Name')
+ await service.update_webhook(WORKSPACE_UUID, 1, name='Updated Name')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
@@ -327,12 +397,12 @@ class TestWebhookServiceUpdateWebhook:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result())
service = WebhookService(ap)
# Execute
- await service.update_webhook(1, url='http://updated.example.com')
+ await service.update_webhook(WORKSPACE_UUID, 1, url='http://updated.example.com')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
@@ -342,12 +412,12 @@ class TestWebhookServiceUpdateWebhook:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result())
service = WebhookService(ap)
# Execute
- await service.update_webhook(1, description='Updated description')
+ await service.update_webhook(WORKSPACE_UUID, 1, description='Updated description')
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
@@ -357,12 +427,12 @@ class TestWebhookServiceUpdateWebhook:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result())
service = WebhookService(ap)
# Execute
- await service.update_webhook(1, enabled=False)
+ await service.update_webhook(WORKSPACE_UUID, 1, enabled=False)
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
@@ -372,12 +442,13 @@ class TestWebhookServiceUpdateWebhook:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result())
service = WebhookService(ap)
# Execute
await service.update_webhook(
+ WORKSPACE_UUID,
1,
name='All Updated',
url='http://all.updated.com',
@@ -393,15 +464,17 @@ class TestWebhookServiceUpdateWebhook:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ existing = _create_mock_webhook(webhook_id=1)
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing))
+ ap.persistence_mgr.serialize_model = Mock(return_value={'id': 1})
service = WebhookService(ap)
# Execute - no update parameters
- await service.update_webhook(1)
+ await service.update_webhook(WORKSPACE_UUID, 1)
- # Verify - no execute call since no update_data
- ap.persistence_mgr.execute_async.assert_not_called()
+ # No write is issued; one scoped existence lookup is performed.
+ ap.persistence_mgr.execute_async.assert_called_once()
class TestWebhookServiceDeleteWebhook:
@@ -412,12 +485,12 @@ class TestWebhookServiceDeleteWebhook:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result())
service = WebhookService(ap)
# Execute
- await service.delete_webhook(1)
+ await service.delete_webhook(WORKSPACE_UUID, 1)
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
@@ -427,12 +500,12 @@ class TestWebhookServiceDeleteWebhook:
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
- ap.persistence_mgr.execute_async = AsyncMock()
+ ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_write_result(rowcount=0))
service = WebhookService(ap)
# Execute - should not raise
- await service.delete_webhook(999)
+ await service.delete_webhook(WORKSPACE_UUID, 999)
# Verify - still called
ap.persistence_mgr.execute_async.assert_called_once()
@@ -453,7 +526,7 @@ class TestWebhookServiceGetEnabledWebhooks:
service = WebhookService(ap)
# Execute
- result = await service.get_enabled_webhooks()
+ result = await service.get_enabled_webhooks(WORKSPACE_UUID)
# Verify
assert result == []
@@ -481,7 +554,7 @@ class TestWebhookServiceGetEnabledWebhooks:
service = WebhookService(ap)
# Execute
- result = await service.get_enabled_webhooks()
+ result = await service.get_enabled_webhooks(WORKSPACE_UUID)
# Verify
assert len(result) == 2
@@ -501,7 +574,170 @@ class TestWebhookServiceGetEnabledWebhooks:
service = WebhookService(ap)
# Execute
- result = await service.get_enabled_webhooks()
+ result = await service.get_enabled_webhooks(WORKSPACE_UUID)
# Verify - should be empty (SQL would filter disabled)
assert result == []
+
+
+ISOLATION_WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+ISOLATION_WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
+
+
+class _RealPersistenceManager:
+ def __init__(self, engine):
+ self.engine = engine
+
+ async def execute_async(self, *args, **kwargs):
+ async with self.engine.connect() as connection:
+ result = await connection.execute(*args, **kwargs)
+ await connection.commit()
+ return result
+
+ @staticmethod
+ def serialize_model(model, data, masked_columns=None):
+ return {
+ column.name: (
+ getattr(data, column.name).isoformat()
+ if isinstance(getattr(data, column.name), datetime.datetime)
+ else getattr(data, column.name)
+ )
+ for column in model.__table__.columns
+ if column.name not in (masked_columns or [])
+ }
+
+
+@pytest.fixture
+async def tenant_webhook_service(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "webhooks.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ await connection.execute(
+ sqlalchemy.insert(Workspace),
+ [
+ {
+ 'uuid': ISOLATION_WORKSPACE_A,
+ 'instance_uuid': 'instance',
+ 'name': 'A',
+ 'slug': 'a',
+ 'source': 'cloud_projection',
+ },
+ {
+ 'uuid': ISOLATION_WORKSPACE_B,
+ 'instance_uuid': 'instance',
+ 'name': 'B',
+ 'slug': 'b',
+ 'source': 'cloud_projection',
+ },
+ ],
+ )
+ service = WebhookService(SimpleNamespace(persistence_mgr=_RealPersistenceManager(engine)))
+ yield service
+ await engine.dispose()
+
+
+async def test_webhook_service_requires_workspace(tenant_webhook_service):
+ with pytest.raises(WorkspaceRequiredError):
+ await tenant_webhook_service.get_webhooks(None)
+
+
+async def test_same_name_webhooks_are_isolated(tenant_webhook_service):
+ created_a = await tenant_webhook_service.create_webhook(
+ ISOLATION_WORKSPACE_A,
+ 'deploy',
+ 'https://a.invalid',
+ )
+ created_b = await tenant_webhook_service.create_webhook(
+ ISOLATION_WORKSPACE_B,
+ 'deploy',
+ 'https://b.invalid',
+ )
+
+ assert created_a['workspace_uuid'] == ISOLATION_WORKSPACE_A
+ assert created_b['workspace_uuid'] == ISOLATION_WORKSPACE_B
+ assert [item['url'] for item in await tenant_webhook_service.get_webhooks(ISOLATION_WORKSPACE_A)] == ['***']
+ assert [item['url'] for item in await tenant_webhook_service.get_webhooks(ISOLATION_WORKSPACE_B)] == ['***']
+ assert [
+ item['url'] for item in await tenant_webhook_service.get_webhooks(ISOLATION_WORKSPACE_A, include_secret=True)
+ ] == ['https://a.invalid']
+ assert [
+ item['url'] for item in await tenant_webhook_service.get_webhooks(ISOLATION_WORKSPACE_B, include_secret=True)
+ ] == ['https://b.invalid']
+
+
+async def test_cross_workspace_id_guessing_is_not_found(tenant_webhook_service):
+ created = await tenant_webhook_service.create_webhook(
+ ISOLATION_WORKSPACE_A,
+ 'secret',
+ 'https://a.invalid/hook',
+ )
+ webhook_id = created['id']
+
+ assert await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_B, webhook_id) is None
+ assert not await tenant_webhook_service.update_webhook(
+ ISOLATION_WORKSPACE_B,
+ webhook_id,
+ name='stolen',
+ )
+ assert not await tenant_webhook_service.delete_webhook(ISOLATION_WORKSPACE_B, webhook_id)
+ assert (await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_A, webhook_id))['name'] == 'secret'
+
+
+async def test_update_and_delete_are_scoped(tenant_webhook_service):
+ created = await tenant_webhook_service.create_webhook(
+ ISOLATION_WORKSPACE_A,
+ 'old',
+ 'https://a.invalid/old',
+ )
+ assert await tenant_webhook_service.update_webhook(
+ ISOLATION_WORKSPACE_A,
+ created['id'],
+ name='new',
+ enabled=False,
+ )
+ assert await tenant_webhook_service.get_enabled_webhooks(ISOLATION_WORKSPACE_A) == []
+ assert await tenant_webhook_service.delete_webhook(ISOLATION_WORKSPACE_A, created['id'])
+ assert await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_A, created['id']) is None
+
+
+async def test_masked_webhook_url_roundtrip_preserves_replace_and_clear(tenant_webhook_service):
+ created = await tenant_webhook_service.create_webhook(
+ ISOLATION_WORKSPACE_A,
+ 'roundtrip',
+ 'https://a.invalid/bearer-secret',
+ )
+
+ masked = await tenant_webhook_service.get_webhook(ISOLATION_WORKSPACE_A, created['id'])
+ assert masked['url'] == '***'
+ assert await tenant_webhook_service.update_webhook(
+ ISOLATION_WORKSPACE_A,
+ created['id'],
+ name='preserved',
+ url=masked['url'],
+ )
+ preserved = await tenant_webhook_service.get_webhook(
+ ISOLATION_WORKSPACE_A,
+ created['id'],
+ include_secret=True,
+ )
+ assert preserved['url'] == 'https://a.invalid/bearer-secret'
+
+ assert await tenant_webhook_service.update_webhook(
+ ISOLATION_WORKSPACE_A,
+ created['id'],
+ url='https://a.invalid/replacement',
+ )
+ replaced = await tenant_webhook_service.get_webhook(
+ ISOLATION_WORKSPACE_A,
+ created['id'],
+ include_secret=True,
+ )
+ assert replaced['url'] == 'https://a.invalid/replacement'
+
+ assert await tenant_webhook_service.update_webhook(ISOLATION_WORKSPACE_A, created['id'], url='')
+ cleared = await tenant_webhook_service.get_webhook(
+ ISOLATION_WORKSPACE_A,
+ created['id'],
+ include_secret=True,
+ )
+ assert cleared['url'] == ''
diff --git a/tests/unit_tests/api/test_adapter_session_scoping.py b/tests/unit_tests/api/test_adapter_session_scoping.py
new file mode 100644
index 000000000..3e8642aad
--- /dev/null
+++ b/tests/unit_tests/api/test_adapter_session_scoping.py
@@ -0,0 +1,254 @@
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import lark_oapi
+import pytest
+import quart
+
+from langbot.pkg.api.http.context import (
+ PrincipalContext,
+ PrincipalType,
+ RequestContext,
+ WorkspaceContext,
+)
+from langbot.pkg.api.http.controller.groups.platform.adapters import (
+ AdaptersRouterGroup,
+ _AdapterSessionScope,
+ _bind_session_scope,
+ _get_owned_session,
+ _make_room_for_session,
+ _pop_owned_session,
+ _start_adapter_session_task,
+)
+
+
+pytestmark = pytest.mark.asyncio
+
+
+SENSITIVE_ADAPTER_ROUTES = (
+ ('post', '/api/v1/platform/adapters/lark/create-app'),
+ ('get', '/api/v1/platform/adapters/lark/create-app/status/missing'),
+ ('delete', '/api/v1/platform/adapters/lark/create-app/missing'),
+ ('post', '/api/v1/platform/adapters/weixin/login'),
+ ('get', '/api/v1/platform/adapters/weixin/login/status/missing'),
+ ('delete', '/api/v1/platform/adapters/weixin/login/missing'),
+ ('post', '/api/v1/platform/adapters/dingtalk/create-app'),
+ ('get', '/api/v1/platform/adapters/dingtalk/create-app/status/missing'),
+ ('delete', '/api/v1/platform/adapters/dingtalk/create-app/missing'),
+ ('post', '/api/v1/platform/adapters/wecombot/create-bot'),
+ ('get', '/api/v1/platform/adapters/wecombot/create-bot/status/missing'),
+ ('delete', '/api/v1/platform/adapters/wecombot/create-bot/missing'),
+ ('post', '/api/v1/platform/adapters/qqofficial/bind'),
+ ('get', '/api/v1/platform/adapters/qqofficial/bind/status/missing'),
+ ('delete', '/api/v1/platform/adapters/qqofficial/bind/missing'),
+)
+
+
+def _request_context(
+ *,
+ account_uuid: str = 'account-a',
+ workspace_uuid: str = 'workspace-a',
+ placement_generation: int = 1,
+) -> RequestContext:
+ return RequestContext(
+ instance_uuid='instance-test',
+ placement_generation=placement_generation,
+ request_id='request-test',
+ auth_type='user-token',
+ principal=PrincipalContext(
+ principal_type=PrincipalType.ACCOUNT,
+ account_uuid=account_uuid,
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid=workspace_uuid,
+ membership_uuid='membership-test',
+ role='developer',
+ permissions=frozenset({'resource.manage'}),
+ ),
+ )
+
+
+async def _create_client(*, role: str = 'developer'):
+ quart_app = quart.Quart(__name__)
+ accounts = {
+ 'owner-token': SimpleNamespace(uuid='account-a', user='owner@example.com'),
+ 'other-token': SimpleNamespace(uuid='account-b', user='other@example.com'),
+ }
+
+ async def get_authenticated_account(token: str):
+ return accounts[token]
+
+ async def resolve_account_workspace(account_uuid: str, requested_workspace_uuid: str | None):
+ workspace_uuid = requested_workspace_uuid or 'workspace-a'
+ return SimpleNamespace(
+ execution=SimpleNamespace(
+ instance_uuid='instance-test',
+ placement_generation=1,
+ ),
+ workspace=SimpleNamespace(uuid=workspace_uuid),
+ membership=SimpleNamespace(
+ uuid=f'membership-{account_uuid}-{workspace_uuid}',
+ role=role,
+ projection_revision=1,
+ ),
+ )
+
+ class TestTaskManager:
+ def create_user_task(self, coro, **_kwargs):
+ return SimpleNamespace(task=asyncio.create_task(coro))
+
+ application = SimpleNamespace(
+ user_service=SimpleNamespace(
+ get_authenticated_account=AsyncMock(side_effect=get_authenticated_account),
+ ),
+ workspace_collaboration_service=SimpleNamespace(
+ resolve_account_workspace=AsyncMock(side_effect=resolve_account_workspace),
+ ),
+ platform_mgr=SimpleNamespace(),
+ task_mgr=TestTaskManager(),
+ )
+ router = AdaptersRouterGroup(application, quart_app)
+ await router.initialize()
+ return quart_app.test_client()
+
+
+@pytest.mark.parametrize(('method', 'path'), SENSITIVE_ADAPTER_ROUTES)
+async def test_sensitive_adapter_flows_require_resource_manage(method: str, path: str):
+ client = await _create_client(role='viewer')
+
+ response = await getattr(client, method)(
+ path,
+ headers={'Authorization': 'Bearer owner-token'},
+ )
+
+ assert response.status_code == 403
+ assert (await response.get_json())['code'] == 'permission_denied'
+
+
+async def test_session_scope_matches_exact_tenant_placement_and_principal():
+ owner_context = _request_context()
+ sessions: dict[str, dict] = {'session-test': {'status': 'waiting'}}
+ _bind_session_scope(sessions['session-test'], owner_context)
+
+ assert sessions['session-test']['scope'] == _AdapterSessionScope.from_request_context(owner_context)
+ assert _get_owned_session(sessions, 'session-test', owner_context) is sessions['session-test']
+
+ for other_context in (
+ _request_context(account_uuid='account-b'),
+ _request_context(workspace_uuid='workspace-b'),
+ _request_context(placement_generation=2),
+ ):
+ assert _get_owned_session(sessions, 'session-test', other_context) is None
+ assert _pop_owned_session(sessions, 'session-test', other_context) is None
+ assert 'session-test' in sessions
+
+ assert _pop_owned_session(sessions, 'session-test', owner_context) is not None
+ assert sessions == {}
+
+
+async def test_session_capacity_evicts_oldest_session_in_same_workspace():
+ owner_context = _request_context()
+ sessions: dict[str, dict] = {}
+ tasks = []
+ for index in range(10):
+ task = SimpleNamespace(done=Mock(return_value=False), cancel=Mock())
+ tasks.append(task)
+ session = {'created_at': float(index), 'task': task}
+ _bind_session_scope(session, owner_context)
+ sessions[f'session-{index}'] = session
+
+ _make_room_for_session(sessions, owner_context)
+
+ assert 'session-0' not in sessions
+ assert len(sessions) == 9
+ tasks[0].cancel.assert_called_once_with()
+
+
+async def test_adapter_session_task_uses_tenant_task_admission():
+ blocker = asyncio.Event()
+
+ async def credential_exchange():
+ await blocker.wait()
+
+ task_manager = SimpleNamespace(create_user_task=Mock())
+
+ def create_user_task(coro, **_kwargs):
+ return SimpleNamespace(task=asyncio.create_task(coro))
+
+ task_manager.create_user_task.side_effect = create_user_task
+ application = SimpleNamespace(task_mgr=task_manager)
+ request_context = _request_context()
+
+ returned = _start_adapter_session_task(
+ application,
+ credential_exchange(),
+ adapter='lark',
+ session_id='session-test',
+ request_context=request_context,
+ )
+
+ assert returned is not None
+ task_manager.create_user_task.assert_called_once()
+ kwargs = task_manager.create_user_task.call_args.kwargs
+ assert kwargs['kind'] == 'platform-adapter-credential-exchange'
+ assert kwargs['instance_uuid'] == request_context.instance_uuid
+ assert kwargs['workspace_uuid'] == request_context.workspace_uuid
+ assert kwargs['placement_generation'] == request_context.placement_generation
+ blocker.set()
+ await returned
+
+
+async def test_lark_session_status_and_delete_hide_cross_scope_sessions(monkeypatch):
+ registration_blocker = asyncio.Event()
+
+ async def fake_register_app(*, on_qr_code, source: str):
+ assert source == 'langbot'
+ on_qr_code({'url': 'https://example.test/lark-qr'})
+ await registration_blocker.wait()
+ raise AssertionError('registration should have been cancelled')
+
+ monkeypatch.setattr(lark_oapi, 'aregister_app', fake_register_app)
+ client = await _create_client()
+ owner_headers = {
+ 'Authorization': 'Bearer owner-token',
+ 'X-Workspace-Id': 'workspace-a',
+ }
+
+ create_response = await client.post(
+ '/api/v1/platform/adapters/lark/create-app',
+ headers=owner_headers,
+ )
+ assert create_response.status_code == 200
+ session_id = (await create_response.get_json())['data']['session_id']
+ status_path = f'/api/v1/platform/adapters/lark/create-app/status/{session_id}'
+ delete_path = f'/api/v1/platform/adapters/lark/create-app/{session_id}'
+
+ for headers in (
+ {
+ 'Authorization': 'Bearer other-token',
+ 'X-Workspace-Id': 'workspace-a',
+ },
+ {
+ 'Authorization': 'Bearer owner-token',
+ 'X-Workspace-Id': 'workspace-b',
+ },
+ ):
+ status_response = await client.get(status_path, headers=headers)
+ delete_response = await client.delete(delete_path, headers=headers)
+ assert status_response.status_code == 404
+ assert delete_response.status_code == 404
+ assert (await status_response.get_json())['msg'] == 'Session not found'
+ assert (await delete_response.get_json())['msg'] == 'Session not found'
+
+ owner_status_response = await client.get(status_path, headers=owner_headers)
+ assert owner_status_response.status_code == 200
+ assert (await owner_status_response.get_json())['data']['status'] == 'waiting'
+
+ owner_delete_response = await client.delete(delete_path, headers=owner_headers)
+ assert owner_delete_response.status_code == 200
+ missing_delete_response = await client.delete(delete_path, headers=owner_headers)
+ assert missing_delete_response.status_code == 404
+ await asyncio.sleep(0)
diff --git a/tests/unit_tests/api/test_apikey_service.py b/tests/unit_tests/api/test_apikey_service.py
index 2065ae3b7..c4fc4c12c 100644
--- a/tests/unit_tests/api/test_apikey_service.py
+++ b/tests/unit_tests/api/test_apikey_service.py
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.service.apikey import ApiKeyService
+from langbot.pkg.entity.persistence.apikey import ApiKeyStatus
@pytest.mark.asyncio
@@ -13,30 +14,70 @@ from langbot.pkg.api.http.service.apikey import ApiKeyService
async def test_verify_api_key_rejects_non_lbk_keys_without_db_query(api_key):
persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
- service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config))
+ workspace_service = SimpleNamespace(get_execution_binding=AsyncMock())
+ service = ApiKeyService(
+ SimpleNamespace(
+ persistence_mgr=persistence_mgr,
+ instance_config=instance_config,
+ workspace_service=workspace_service,
+ )
+ )
result = await service.verify_api_key(api_key)
assert result is False
persistence_mgr.execute_async.assert_not_awaited()
+ workspace_service.get_execution_binding.assert_not_awaited()
@pytest.mark.asyncio
-@pytest.mark.parametrize(
- ('db_row', 'expected'),
- [
- (object(), True),
- (None, False),
- ],
-)
-async def test_verify_api_key_keeps_db_validation_for_lbk_keys(db_row, expected):
- query_result = Mock()
- query_result.first.return_value = db_row
- persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=query_result))
+@pytest.mark.parametrize('key_exists', [True, False])
+async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists):
+ key = (
+ SimpleNamespace(
+ id=1,
+ uuid='key-uuid',
+ workspace_uuid='workspace-a',
+ status=ApiKeyStatus.ACTIVE.value,
+ expires_at=None,
+ scopes=[],
+ )
+ if key_exists
+ else None
+ )
+ discovery_result = Mock()
+ discovery_result.first.return_value = key
+ query_results = [discovery_result]
+ if key_exists:
+ scoped_result = Mock()
+ scoped_result.first.return_value = key
+ update_result = Mock()
+ update_result.scalar_one_or_none.return_value = key.id
+ query_results.extend([scoped_result, update_result])
+ persistence_mgr = SimpleNamespace(execute_async=AsyncMock(side_effect=query_results))
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
- service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config))
+ workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ )
+ )
+ service = ApiKeyService(
+ SimpleNamespace(
+ persistence_mgr=persistence_mgr,
+ instance_config=instance_config,
+ workspace_service=workspace_service,
+ )
+ )
result = await service.verify_api_key('lbk_valid_format')
- assert result is expected
- persistence_mgr.execute_async.assert_awaited_once()
+ assert result is key_exists
+ assert persistence_mgr.execute_async.await_count == (3 if key_exists else 1)
+ if key_exists:
+ workspace_service.get_execution_binding.assert_awaited_once_with('workspace-a')
+ else:
+ workspace_service.get_execution_binding.assert_not_awaited()
diff --git a/tests/unit_tests/api/test_bot_controller_secrets.py b/tests/unit_tests/api/test_bot_controller_secrets.py
new file mode 100644
index 000000000..971129315
--- /dev/null
+++ b/tests/unit_tests/api/test_bot_controller_secrets.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller.groups.platform.bots import BotsRouterGroup
+
+
+pytestmark = pytest.mark.asyncio
+
+SECRET_CONFIG = {'token': 'tenant-secret', 'app_secret': 'also-secret'}
+
+
+async def create_client(*, role: str):
+ quart_app = quart.Quart(__name__)
+ account = SimpleNamespace(uuid='account-test', user='test@example.com')
+ user_service = SimpleNamespace(
+ get_authenticated_account=AsyncMock(return_value=account),
+ )
+ access = SimpleNamespace(
+ execution=SimpleNamespace(
+ instance_uuid='instance-test',
+ placement_generation=1,
+ ),
+ workspace=SimpleNamespace(uuid='workspace-test'),
+ membership=SimpleNamespace(
+ uuid='membership-test',
+ role=role,
+ projection_revision=1,
+ ),
+ )
+
+ async def get_bots(_context, *, include_secret=False):
+ bot = {'uuid': 'bot-test', 'name': 'Test Bot'}
+ if include_secret:
+ bot['adapter_config'] = SECRET_CONFIG
+ return [bot]
+
+ async def get_runtime_bot_info(_context, _bot_uuid, *, include_secret=False):
+ bot = {'uuid': 'bot-test', 'name': 'Test Bot'}
+ if include_secret:
+ bot['adapter_config'] = SECRET_CONFIG
+ return bot
+
+ bot_service = SimpleNamespace(
+ get_bots=AsyncMock(side_effect=get_bots),
+ get_runtime_bot_info=AsyncMock(side_effect=get_runtime_bot_info),
+ update_bot=AsyncMock(),
+ )
+ application = SimpleNamespace(
+ user_service=user_service,
+ apikey_service=SimpleNamespace(
+ authenticate_api_key=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-test',
+ placement_generation=1,
+ api_key_uuid='api-key-test',
+ workspace_uuid='workspace-test',
+ permissions=frozenset({'resource.view'}),
+ )
+ )
+ ),
+ workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)),
+ bot_service=bot_service,
+ )
+ router = BotsRouterGroup(application, quart_app)
+ await router.initialize()
+ return quart_app.test_client(), bot_service
+
+
+async def test_viewer_list_and_detail_never_receive_adapter_credentials():
+ client, bot_service = await create_client(role='viewer')
+ headers = {'Authorization': 'Bearer test-token'}
+
+ list_response = await client.get('/api/v1/platform/bots', headers=headers)
+ detail_response = await client.get('/api/v1/platform/bots/bot-test', headers=headers)
+
+ assert list_response.status_code == 200
+ assert detail_response.status_code == 200
+ assert 'adapter_config' not in (await list_response.get_json())['data']['bots'][0]
+ assert 'adapter_config' not in (await detail_response.get_json())['data']['bot']
+ assert bot_service.get_bots.await_args.kwargs['include_secret'] is False
+ assert bot_service.get_runtime_bot_info.await_args.kwargs['include_secret'] is False
+
+
+async def test_resource_manager_can_read_adapter_credentials():
+ client, bot_service = await create_client(role='developer')
+ headers = {'Authorization': 'Bearer test-token'}
+
+ list_response = await client.get('/api/v1/platform/bots', headers=headers)
+ detail_response = await client.get('/api/v1/platform/bots/bot-test', headers=headers)
+
+ assert (await list_response.get_json())['data']['bots'][0]['adapter_config'] == SECRET_CONFIG
+ assert (await detail_response.get_json())['data']['bot']['adapter_config'] == SECRET_CONFIG
+ assert bot_service.get_bots.await_args.kwargs['include_secret'] is True
+ assert bot_service.get_runtime_bot_info.await_args.kwargs['include_secret'] is True
+
+
+async def test_viewer_cannot_write_adapter_credentials():
+ client, bot_service = await create_client(role='viewer')
+
+ response = await client.put(
+ '/api/v1/platform/bots/bot-test',
+ headers={'Authorization': 'Bearer test-token'},
+ json={'adapter_config': SECRET_CONFIG},
+ )
+
+ assert response.status_code == 403
+ assert (await response.get_json())['code'] == 'permission_denied'
+ bot_service.update_bot.assert_not_awaited()
diff --git a/tests/unit_tests/api/test_extensions_runtime_fence.py b/tests/unit_tests/api/test_extensions_runtime_fence.py
new file mode 100644
index 000000000..12f36917e
--- /dev/null
+++ b/tests/unit_tests/api/test_extensions_runtime_fence.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import quart
+import sqlalchemy
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.controller.groups.extensions import ExtensionsRouterGroup
+from langbot.pkg.persistence.mgr import PersistenceManager
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
+
+
+@pytest.mark.asyncio
+async def test_extensions_route_hides_runtime_bound_to_another_workspace():
+ account = SimpleNamespace(uuid='account-a', user='owner@example.com')
+ connector = SimpleNamespace(
+ is_enable_plugin=True,
+ require_workspace_context=AsyncMock(side_effect=WorkspaceNotFoundError('Plugin resource not found')),
+ list_plugins=AsyncMock(return_value=[]),
+ )
+ ap = SimpleNamespace(
+ user_service=SimpleNamespace(
+ get_authenticated_account=AsyncMock(return_value=account),
+ ),
+ workspace_collaboration_service=SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ workspace=SimpleNamespace(uuid='workspace-a'),
+ membership=SimpleNamespace(uuid='membership-a', role='owner', projection_revision=0),
+ execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=2),
+ )
+ )
+ ),
+ plugin_connector=connector,
+ mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])),
+ skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])),
+ )
+ quart_app = quart.Quart(__name__)
+ router = ExtensionsRouterGroup(ap, quart_app)
+ await router.initialize()
+
+ response = await quart_app.test_client().get(
+ '/api/v1/extensions',
+ headers={'Authorization': 'Bearer token'},
+ )
+
+ assert response.status_code == 404
+ connector.list_plugins.assert_not_awaited()
+ ap.mcp_service.get_mcp_servers.assert_not_awaited()
+ ap.skill_service.list_skills.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_extensions_route_redacts_plugin_secrets_without_mutating_runtime_data():
+ account = SimpleNamespace(uuid='account-a', user='viewer@example.com')
+ raw_plugin = {
+ 'plugin_config': {'apiKey': 'plugin-secret', 'nested': {'token': 'nested-secret'}},
+ 'debug': {'plugin_debug_key': 'debug-secret'},
+ }
+ connector = SimpleNamespace(
+ is_enable_plugin=True,
+ require_workspace_context=AsyncMock(),
+ list_plugins=AsyncMock(return_value=[raw_plugin]),
+ )
+ ap = SimpleNamespace(
+ user_service=SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account)),
+ workspace_collaboration_service=SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ workspace=SimpleNamespace(uuid='workspace-a'),
+ membership=SimpleNamespace(uuid='membership-a', role='viewer', projection_revision=0),
+ execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=2),
+ )
+ )
+ ),
+ plugin_connector=connector,
+ mcp_service=SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[])),
+ skill_service=SimpleNamespace(list_skills=AsyncMock(return_value=[])),
+ )
+ quart_app = quart.Quart(__name__)
+ router = ExtensionsRouterGroup(ap, quart_app)
+ await router.initialize()
+
+ response = await quart_app.test_client().get(
+ '/api/v1/extensions',
+ headers={'Authorization': 'Bearer token', 'X-Workspace-Id': 'workspace-a'},
+ )
+
+ assert response.status_code == 200
+ plugin = (await response.get_json())['data']['extensions'][0]['plugin']
+ assert plugin['plugin_config']['apiKey'] == '***'
+ assert plugin['plugin_config']['nested']['token'] == '***'
+ assert plugin['debug']['plugin_debug_key'] == '***'
+ assert raw_plugin['plugin_config']['apiKey'] == 'plugin-secret'
+
+
+@pytest.mark.asyncio
+async def test_extensions_parallel_reads_open_explicit_child_task_scopes():
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ account = SimpleNamespace(uuid='account-a', user='owner@example.com')
+ ap = SimpleNamespace()
+ ap.persistence_mgr = PersistenceManager(ap)
+ ap.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
+ ap.user_service = SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account))
+ ap.workspace_collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ workspace=SimpleNamespace(uuid='workspace-a'),
+ membership=SimpleNamespace(uuid='membership-a', role='owner', projection_revision=0),
+ execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=2),
+ )
+ )
+ )
+ ap.plugin_connector = SimpleNamespace(
+ is_enable_plugin=False,
+ list_plugins=AsyncMock(return_value=[]),
+ )
+
+ async def list_mcp_servers(_context, *, contain_runtime_info):
+ await ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.literal(1)))
+ assert contain_runtime_info is True
+ return [{'name': 'Scoped MCP'}]
+
+ ap.mcp_service = SimpleNamespace(get_mcp_servers=AsyncMock(side_effect=list_mcp_servers))
+ ap.skill_service = SimpleNamespace(list_skills=AsyncMock(return_value=[]))
+ quart_app = quart.Quart(__name__)
+ router = ExtensionsRouterGroup(ap, quart_app)
+ await router.initialize()
+
+ try:
+ response = await quart_app.test_client().get(
+ '/api/v1/extensions',
+ headers={'Authorization': 'Bearer token', 'X-Workspace-Id': 'workspace-a'},
+ )
+ finally:
+ await engine.dispose()
+
+ assert response.status_code == 200
+ assert (await response.get_json())['data']['extensions'] == [{'type': 'mcp', 'server': {'name': 'Scoped MCP'}}]
diff --git a/tests/unit_tests/api/test_file_upload_scoping.py b/tests/unit_tests/api/test_file_upload_scoping.py
new file mode 100644
index 000000000..0463a4c84
--- /dev/null
+++ b/tests/unit_tests/api/test_file_upload_scoping.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import io
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import quart
+from quart.datastructures import FileStorage
+
+from langbot.pkg.api.http.controller.groups.files import FilesRouterGroup
+
+
+pytestmark = pytest.mark.asyncio
+
+
+async def test_document_upload_uses_dedicated_scoped_owner_type():
+ quart_app = quart.Quart(__name__)
+ account = SimpleNamespace(uuid='account-test', user='test@example.com')
+ access = SimpleNamespace(
+ execution=SimpleNamespace(
+ instance_uuid='instance-test',
+ placement_generation=3,
+ ),
+ workspace=SimpleNamespace(uuid='00000000-0000-0000-0000-00000000000a'),
+ membership=SimpleNamespace(
+ uuid='membership-test',
+ role='developer',
+ projection_revision=1,
+ ),
+ )
+ storage_mgr = SimpleNamespace(save_scoped=AsyncMock(return_value='scoped-document-key'))
+ application = SimpleNamespace(
+ user_service=SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account)),
+ workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)),
+ storage_mgr=storage_mgr,
+ )
+ router = FilesRouterGroup(application, quart_app)
+ await router.initialize()
+ client = quart_app.test_client()
+
+ response = await client.post(
+ '/api/v1/files/documents',
+ headers={'Authorization': 'Bearer test-token'},
+ files={
+ 'file': FileStorage(
+ stream=io.BytesIO(b'document bytes'),
+ filename='report.pdf',
+ )
+ },
+ )
+
+ assert response.status_code == 200
+ assert (await response.get_json())['data']['file_id'] == 'scoped-document-key'
+ kwargs = storage_mgr.save_scoped.await_args.kwargs
+ assert kwargs['owner_type'] == 'upload_document'
+ assert kwargs['owner'] == 'account:account-test'
+ assert kwargs['key'].endswith('.pdf')
+ assert kwargs['value'] == b'document bytes'
diff --git a/tests/unit_tests/api/test_knowledge_migration_runtime_fence.py b/tests/unit_tests/api/test_knowledge_migration_runtime_fence.py
new file mode 100644
index 000000000..17bc67001
--- /dev/null
+++ b/tests/unit_tests/api/test_knowledge_migration_runtime_fence.py
@@ -0,0 +1,293 @@
+from __future__ import annotations
+
+from datetime import datetime
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import sqlalchemy
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.api.http.controller.groups.knowledge.migration import KnowledgeMigrationRouterGroup
+from langbot.pkg.persistence.tenant_uow import _validate_scoped_statement_call
+from langbot.pkg.workspace.errors import WorkspaceInvariantError, WorkspaceNotFoundError
+
+
+CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+)
+
+
+@pytest.mark.asyncio
+async def test_background_migration_propagates_generation_change_before_runtime_call():
+ connector = SimpleNamespace(
+ require_workspace_context=AsyncMock(side_effect=[CONTEXT, WorkspaceNotFoundError('Plugin resource not found')]),
+ list_knowledge_engines=AsyncMock(return_value=[]),
+ )
+ router = object.__new__(KnowledgeMigrationRouterGroup)
+ router.ap = SimpleNamespace(
+ plugin_connector=connector,
+ workspace_service=SimpleNamespace(
+ get_local_execution_binding=AsyncMock(return_value=CONTEXT),
+ ),
+ logger=Mock(),
+ )
+ router._table_exists = AsyncMock(return_value=False)
+ router._set_migration_flag = AsyncMock()
+ task_context = SimpleNamespace(trace=Mock())
+
+ with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
+ await router._execute_rag_migration(
+ CONTEXT,
+ task_context,
+ install_plugin=False,
+ )
+
+ assert connector.require_workspace_context.await_count == 2
+ connector.list_knowledge_engines.assert_not_awaited()
+ router._set_migration_flag.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_cloud_migration_is_rejected_before_legacy_table_access():
+ router = object.__new__(KnowledgeMigrationRouterGroup)
+ router.ap = SimpleNamespace(
+ workspace_service=SimpleNamespace(
+ get_local_execution_binding=AsyncMock(side_effect=WorkspaceInvariantError('not an OSS local workspace')),
+ ),
+ plugin_connector=SimpleNamespace(require_workspace_context=AsyncMock()),
+ logger=Mock(),
+ )
+ router._table_exists = AsyncMock()
+ router._set_migration_flag = AsyncMock()
+ task_context = SimpleNamespace(trace=Mock())
+
+ with pytest.raises(WorkspaceNotFoundError, match='migration is unavailable'):
+ await router._execute_rag_migration(CONTEXT, task_context, install_plugin=False)
+
+ router._table_exists.assert_not_awaited()
+ router.ap.plugin_connector.require_workspace_context.assert_not_awaited()
+ router._set_migration_flag.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('database_name, exists', [('postgresql', True), ('sqlite', False)])
+async def test_legacy_table_discovery_uses_scoped_structured_queries(database_name: str, exists: bool):
+ result = Mock()
+ result.first.return_value = ('knowledge_bases_backup',) if exists else None
+ execute_async = AsyncMock(return_value=result)
+ router = object.__new__(KnowledgeMigrationRouterGroup)
+ router.ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(
+ db=SimpleNamespace(name=database_name),
+ execute_async=execute_async,
+ )
+ )
+
+ assert await router._table_exists('knowledge_bases_backup') is exists
+
+ statement = execute_async.await_args.args[0]
+ assert isinstance(statement, sqlalchemy.sql.selectable.SelectBase)
+ _validate_scoped_statement_call((statement,), {})
+
+
+@pytest.mark.asyncio
+async def test_legacy_restore_emits_only_scoped_structured_statements():
+ missing_table_result = Mock()
+ missing_table_result.first.return_value = None
+ existing_table_result = Mock()
+ existing_table_result.first.return_value = ('knowledge_bases_backup',)
+ backup_result = Mock()
+ backup_result.keys.return_value = [
+ 'uuid',
+ 'name',
+ 'description',
+ 'emoji',
+ 'embedding_model_uuid',
+ 'top_k',
+ 'created_at',
+ 'updated_at',
+ ]
+ now = datetime.now()
+ backup_result.fetchall.return_value = [
+ ('kb-legacy', 'Legacy KB', 'Description', 'U0001f4da', 'embedding-model', 7, now, now)
+ ]
+ execute_async = AsyncMock(
+ side_effect=[
+ missing_table_result,
+ existing_table_result,
+ backup_result,
+ Mock(),
+ Mock(),
+ ]
+ )
+ connector = SimpleNamespace(
+ require_workspace_context=AsyncMock(return_value=CONTEXT),
+ list_knowledge_engines=AsyncMock(return_value=[]),
+ rag_on_kb_create=AsyncMock(),
+ )
+ router = object.__new__(KnowledgeMigrationRouterGroup)
+ router.ap = SimpleNamespace(
+ workspace_service=SimpleNamespace(
+ get_local_execution_binding=AsyncMock(return_value=CONTEXT),
+ ),
+ plugin_connector=connector,
+ persistence_mgr=SimpleNamespace(
+ db=SimpleNamespace(name='sqlite'),
+ execute_async=execute_async,
+ ),
+ rag_mgr=SimpleNamespace(load_knowledge_bases_from_db=AsyncMock()),
+ logger=Mock(),
+ )
+ task_context = SimpleNamespace(trace=Mock())
+
+ await router._execute_rag_migration(CONTEXT, task_context, install_plugin=False)
+
+ statements = [call.args[0] for call in execute_async.await_args_list]
+ assert any(isinstance(statement, sqlalchemy.sql.dml.Insert) for statement in statements)
+ assert any(isinstance(statement, sqlalchemy.sql.dml.Update) for statement in statements)
+ for statement in statements:
+ assert not isinstance(statement, sqlalchemy.sql.elements.TextClause)
+ _validate_scoped_statement_call((statement,), {})
+ connector.rag_on_kb_create.assert_awaited_once_with(
+ 'langbot-team/LangRAG',
+ 'kb-legacy',
+ {'embedding_model_uuid': 'embedding-model'},
+ )
+
+
+@pytest.mark.asyncio
+async def test_legacy_restore_accepts_sqlite_string_dates_and_text_json_columns():
+ engine = sqlalchemy.ext.asyncio.create_async_engine('sqlite+aiosqlite:///:memory:')
+ try:
+ async with engine.begin() as connection:
+ await connection.exec_driver_sql(
+ """
+ CREATE TABLE knowledge_bases_backup (
+ uuid TEXT PRIMARY KEY,
+ name TEXT,
+ description TEXT,
+ emoji TEXT,
+ embedding_model_uuid TEXT,
+ top_k INTEGER,
+ created_at DATETIME,
+ updated_at DATETIME
+ )
+ """
+ )
+ await connection.exec_driver_sql(
+ """
+ CREATE TABLE external_knowledge_bases (
+ uuid TEXT PRIMARY KEY,
+ name TEXT,
+ description TEXT,
+ emoji TEXT,
+ plugin_author TEXT,
+ plugin_name TEXT,
+ retriever_config TEXT,
+ created_at DATETIME
+ )
+ """
+ )
+ await connection.exec_driver_sql(
+ """
+ CREATE TABLE knowledge_bases (
+ uuid TEXT PRIMARY KEY,
+ workspace_uuid TEXT NOT NULL,
+ name TEXT,
+ description TEXT,
+ emoji TEXT,
+ created_at DATETIME,
+ updated_at DATETIME,
+ knowledge_engine_plugin_id TEXT,
+ collection_id TEXT,
+ creation_settings TEXT,
+ retrieval_settings TEXT
+ )
+ """
+ )
+ await connection.exec_driver_sql(
+ 'CREATE TABLE workspace_metadata (workspace_uuid TEXT, key TEXT, value TEXT)'
+ )
+ legacy_timestamp = '2026-07-20 03:00:00.123456'
+ await connection.exec_driver_sql(
+ """
+ INSERT INTO knowledge_bases_backup
+ (uuid, name, description, emoji, embedding_model_uuid, top_k, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ 'kb-internal',
+ 'Internal',
+ 'Internal legacy KB',
+ 'U0001f4da',
+ 'embedding-model',
+ 5,
+ legacy_timestamp,
+ legacy_timestamp,
+ ),
+ )
+ await connection.exec_driver_sql(
+ """
+ INSERT INTO external_knowledge_bases
+ (uuid, name, description, emoji, plugin_author, plugin_name, retriever_config, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ 'kb-external',
+ 'External',
+ 'External legacy KB',
+ 'U0001f517',
+ 'langbot-team',
+ 'DifyDatasetsRetriever',
+ json.dumps({'api_base_url': 'https://example.invalid', 'top_k': 8}),
+ legacy_timestamp,
+ ),
+ )
+
+ connector = SimpleNamespace(
+ require_workspace_context=AsyncMock(return_value=CONTEXT),
+ list_knowledge_engines=AsyncMock(return_value=[]),
+ rag_on_kb_create=AsyncMock(),
+ )
+ router = object.__new__(KnowledgeMigrationRouterGroup)
+ router.ap = SimpleNamespace(
+ workspace_service=SimpleNamespace(
+ get_local_execution_binding=AsyncMock(return_value=CONTEXT),
+ ),
+ plugin_connector=connector,
+ persistence_mgr=SimpleNamespace(
+ db=SimpleNamespace(name='sqlite'),
+ execute_async=connection.execute,
+ ),
+ rag_mgr=SimpleNamespace(load_knowledge_bases_from_db=AsyncMock()),
+ logger=Mock(),
+ )
+
+ await router._execute_rag_migration(
+ CONTEXT,
+ SimpleNamespace(trace=Mock()),
+ install_plugin=False,
+ )
+
+ restored = (
+ await connection.exec_driver_sql(
+ """
+ SELECT uuid, created_at, updated_at, creation_settings, retrieval_settings
+ FROM knowledge_bases
+ ORDER BY uuid
+ """
+ )
+ ).all()
+ assert [row.uuid for row in restored] == ['kb-external', 'kb-internal']
+ assert all(row.created_at == legacy_timestamp for row in restored)
+ assert all(row.updated_at == legacy_timestamp for row in restored)
+ assert json.loads(restored[0].creation_settings)['api_base_url'] == 'https://example.invalid'
+ assert json.loads(restored[0].retrieval_settings) == {'top_k': 8}
+ assert json.loads(restored[1].creation_settings) == {'embedding_model_uuid': 'embedding-model'}
+ assert json.loads(restored[1].retrieval_settings) == {'top_k': 5}
+ finally:
+ await engine.dispose()
diff --git a/tests/unit_tests/api/test_mcp_controller.py b/tests/unit_tests/api/test_mcp_controller.py
index 47f2247f2..d5e9f6da1 100644
--- a/tests/unit_tests/api/test_mcp_controller.py
+++ b/tests/unit_tests/api/test_mcp_controller.py
@@ -17,19 +17,63 @@ sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
pytestmark = pytest.mark.asyncio
-async def _create_test_client(mcp_service: SimpleNamespace):
+async def _create_test_client(mcp_service: SimpleNamespace, *, role: str = 'owner'):
app = quart.Quart(__name__)
user_service = SimpleNamespace(
verify_jwt_token=AsyncMock(return_value='test@example.com'),
- get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
+ get_user_by_email=AsyncMock(
+ return_value=SimpleNamespace(
+ user='test@example.com',
+ uuid='account-a',
+ )
+ ),
+ )
+ workspace_collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(
+ return_value=SimpleNamespace(
+ execution=SimpleNamespace(
+ instance_uuid='instance-a',
+ placement_generation=1,
+ ),
+ workspace=SimpleNamespace(uuid='workspace-a'),
+ membership=SimpleNamespace(
+ uuid='membership-a',
+ role=role,
+ projection_revision=1,
+ ),
+ )
+ )
+ )
+ ap = SimpleNamespace(
+ mcp_service=mcp_service,
+ user_service=user_service,
+ workspace_collaboration_service=workspace_collaboration_service,
)
- ap = SimpleNamespace(mcp_service=mcp_service, user_service=user_service)
MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup
group = MCPRouterGroup(ap, app)
await group.initialize()
return app.test_client()
+async def test_viewer_cannot_read_mcp_runtime_logs():
+ mcp_service = SimpleNamespace(
+ get_mcp_server_logs=AsyncMock(return_value=['private runtime line']),
+ )
+ client = await _create_test_client(mcp_service, role='viewer')
+
+ response = await client.get(
+ '/api/v1/mcp/servers/example/logs',
+ headers={
+ 'Authorization': 'Bearer test-token',
+ 'X-Workspace-Id': 'workspace-a',
+ },
+ )
+
+ assert response.status_code == 403
+ assert (await response.get_json())['code'] == 'permission_denied'
+ mcp_service.get_mcp_server_logs.assert_not_awaited()
+
+
async def test_mcp_server_route_accepts_encoded_slash_name():
mcp_service = SimpleNamespace(
get_mcp_server_by_name=AsyncMock(
@@ -46,11 +90,17 @@ async def test_mcp_server_route_accepts_encoded_slash_name():
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus',
- headers={'Authorization': 'Bearer test-token'},
+ headers={
+ 'Authorization': 'Bearer test-token',
+ 'X-Workspace-Id': 'workspace-a',
+ },
)
assert response.status_code == 200
- mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus')
+ mcp_service.get_mcp_server_by_name.assert_awaited_once()
+ context, server_name = mcp_service.get_mcp_server_by_name.await_args.args
+ assert context.workspace_uuid == 'workspace-a'
+ assert server_name == 'pab1it0/prometheus'
payload = await response.get_json()
assert payload['data']['server']['name'] == 'pab1it0/prometheus'
@@ -66,11 +116,17 @@ async def test_mcp_resource_route_accepts_encoded_slash_name():
response = await client.get(
'/api/v1/mcp/servers/pab1it0%2Fprometheus/resources',
- headers={'Authorization': 'Bearer test-token'},
+ headers={
+ 'Authorization': 'Bearer test-token',
+ 'X-Workspace-Id': 'workspace-a',
+ },
)
assert response.status_code == 200
mcp_service.get_mcp_server_by_name.assert_not_awaited()
- mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus')
+ mcp_service.get_mcp_server_resources.assert_awaited_once()
+ context, server_name = mcp_service.get_mcp_server_resources.await_args.args
+ assert context.workspace_uuid == 'workspace-a'
+ assert server_name == 'pab1it0/prometheus'
payload = await response.get_json()
assert payload['data']['resource_capabilities'] == {'subscribe': False}
diff --git a/tests/unit_tests/api/test_mcp_mount_tenant_scope.py b/tests/unit_tests/api/test_mcp_mount_tenant_scope.py
new file mode 100644
index 000000000..668c21804
--- /dev/null
+++ b/tests/unit_tests/api/test_mcp_mount_tenant_scope.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.api.http.service.apikey import ApiKeyIdentity
+from langbot.pkg.api.mcp.context import get_request_context
+from langbot.pkg.api.mcp.mount import MCPMount
+from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
+from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
+
+
+@pytest.mark.asyncio
+async def test_mcp_mount_keeps_request_context_but_no_session_during_stream_wait(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "mcp-short-scope.db"}')
+ table = sa.Table('mcp_scope_probe', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ checked_out = 0
+
+ def on_checkout(*_args):
+ nonlocal checked_out
+ checked_out += 1
+
+ def on_checkin(*_args):
+ nonlocal checked_out
+ checked_out -= 1
+
+ sa.event.listen(engine.sync_engine, 'checkout', on_checkout)
+ sa.event.listen(engine.sync_engine, 'checkin', on_checkin)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ identity = ApiKeyIdentity(
+ instance_uuid='instance-1',
+ workspace_uuid='workspace-1',
+ placement_generation=7,
+ api_key_uuid='key-1',
+ permissions=frozenset({'pipelines:read'}),
+ )
+ app = SimpleNamespace(
+ apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=identity)),
+ persistence_mgr=manager,
+ deployment_admission=None,
+ deployment=None,
+ )
+ stream_waiting = asyncio.Event()
+ release_stream = asyncio.Event()
+ observations: list[tuple[str, str, bool]] = []
+
+ async def fake_mcp_asgi(scope, receive, send):
+ del scope, receive
+ context = get_request_context()
+ assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
+ assert manager.current_session() is None
+ await manager.execute_async(sa.select(table.c.id))
+ assert manager.current_session() is None
+ observations.append((context.request_id, context.workspace_uuid, manager.current_session() is None))
+ stream_waiting.set()
+ await release_stream.wait()
+ preserved_context = get_request_context()
+ observations.append(
+ (
+ preserved_context.request_id,
+ preserved_context.workspace_uuid,
+ manager.current_session() is None,
+ )
+ )
+ await manager.execute_async(sa.select(table.c.id))
+ assert manager.current_session() is None
+ await send({'type': 'http.response.start', 'status': 200, 'headers': []})
+ await send({'type': 'http.response.body', 'body': b'{}'})
+
+ async def unused_quart_asgi(scope, receive, send):
+ del scope, receive, send
+ raise AssertionError('MCP request was routed to Quart')
+
+ mount = MCPMount.__new__(MCPMount)
+ mount.ap = app
+ mount._mcp_asgi = fake_mcp_asgi
+ sent_messages: list[dict] = []
+
+ async def receive():
+ return {'type': 'http.request', 'body': b'', 'more_body': False}
+
+ async def send(message):
+ sent_messages.append(message)
+
+ async def release_after_observation() -> None:
+ await asyncio.wait_for(stream_waiting.wait(), timeout=2)
+ assert checked_out == 0
+ release_stream.set()
+
+ release_task = asyncio.create_task(release_after_observation())
+ await mount.wrap(unused_quart_asgi)(
+ {
+ 'type': 'http',
+ 'path': '/mcp',
+ 'headers': [(b'x-api-key', b'secret')],
+ },
+ receive,
+ send,
+ )
+ await release_task
+
+ assert sent_messages[0]['status'] == 200
+ assert len(observations) == 2
+ assert observations[0] == observations[1]
+ assert observations[0][1:] == ('workspace-1', True)
+ assert checked_out == 0
+ assert manager.current_scope() is None
+ with pytest.raises(RuntimeError, match='context is unavailable'):
+ get_request_context()
+ finally:
+ await engine.dispose()
diff --git a/tests/unit_tests/api/test_plugin_runtime_route_fence.py b/tests/unit_tests/api/test_plugin_runtime_route_fence.py
new file mode 100644
index 000000000..0bc324e83
--- /dev/null
+++ b/tests/unit_tests/api/test_plugin_runtime_route_fence.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
+
+
+CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=4,
+)
+
+
+@pytest.fixture(scope='module')
+def plugin_router_cls():
+ from tests.utils.import_isolation import MockLifecycleControlScope, isolated_sys_modules
+
+ class FakeMinimalApplication:
+ pass
+
+ mock_app = Mock(Application=FakeMinimalApplication)
+ mock_entities = Mock(LifecycleControlScope=MockLifecycleControlScope)
+ clear = [
+ 'langbot.pkg.core.taskmgr',
+ 'langbot.pkg.api.http.controller.group',
+ 'langbot.pkg.api.http.controller.groups',
+ 'langbot.pkg.api.http.controller.groups.plugins',
+ 'langbot.pkg.api.http.controller.main',
+ ]
+ with isolated_sys_modules(
+ mocks={
+ 'langbot.pkg.core.app': mock_app,
+ 'langbot.pkg.core.entities': mock_entities,
+ },
+ clear=clear,
+ ):
+ from langbot.pkg.api.http.controller.groups.plugins import PluginsRouterGroup
+
+ yield PluginsRouterGroup
+
+
+@pytest.mark.asyncio
+async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls):
+ connector = SimpleNamespace(
+ get_plugin_icon=AsyncMock(),
+ require_workspace_context=AsyncMock(),
+ )
+ ap = SimpleNamespace(
+ plugin_connector=connector,
+ workspace_service=SimpleNamespace(
+ policy=SimpleNamespace(multi_workspace_enabled=True),
+ ),
+ )
+ quart_app = quart.Quart(__name__)
+ router = plugin_router_cls(ap, quart_app)
+ await router.initialize()
+
+ response = await quart_app.test_client().get('/api/v1/plugins/author/plugin/icon')
+
+ assert response.status_code == 404
+ connector.require_workspace_context.assert_not_awaited()
+ connector.get_plugin_icon.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_public_plugin_asset_uses_trusted_oss_singleton_binding(plugin_router_cls):
+ connector = SimpleNamespace(
+ require_workspace_context=AsyncMock(side_effect=lambda context: context),
+ )
+ binding = SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=4,
+ )
+ router = object.__new__(plugin_router_cls)
+ router.ap = SimpleNamespace(
+ plugin_connector=connector,
+ workspace_service=SimpleNamespace(
+ policy=SimpleNamespace(multi_workspace_enabled=False),
+ get_local_execution_binding=AsyncMock(return_value=binding),
+ ),
+ )
+
+ result = await router._require_public_plugin_runtime_context()
+
+ assert result == CONTEXT
+ connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
+
+
+@pytest.mark.asyncio
+async def test_background_plugin_operation_refences_captured_generation(plugin_router_cls):
+ operation = AsyncMock()
+ connector = SimpleNamespace(
+ require_workspace_context=AsyncMock(side_effect=WorkspaceNotFoundError('Plugin resource not found')),
+ )
+ router = object.__new__(plugin_router_cls)
+ router.ap = SimpleNamespace(plugin_connector=connector)
+
+ with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
+ await router._run_fenced_plugin_operation(CONTEXT, operation)
+
+ operation.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_background_plugin_operation_revalidates_inside_short_tenant_uow(plugin_router_cls):
+ scopes = []
+
+ @asynccontextmanager
+ async def tenant_uow(workspace_uuid):
+ scopes.append(workspace_uuid)
+ yield
+
+ connector = SimpleNamespace(
+ require_workspace_context=AsyncMock(side_effect=lambda context: context),
+ )
+ operation = AsyncMock(return_value='done')
+ router = object.__new__(plugin_router_cls)
+ router.ap = SimpleNamespace(
+ plugin_connector=connector,
+ persistence_mgr=SimpleNamespace(
+ mode=SimpleNamespace(value='cloud_runtime'),
+ tenant_uow=tenant_uow,
+ ),
+ )
+
+ result = await router._run_fenced_plugin_operation(CONTEXT, operation)
+
+ assert result == 'done'
+ assert scopes == [CONTEXT.workspace_uuid]
+ connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
+ operation.assert_awaited_once()
diff --git a/tests/unit_tests/api/test_resource_secret_permissions.py b/tests/unit_tests/api/test_resource_secret_permissions.py
new file mode 100644
index 000000000..9e4e31d98
--- /dev/null
+++ b/tests/unit_tests/api/test_resource_secret_permissions.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+import copy
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller.groups.knowledge.base import KnowledgeBaseRouterGroup
+from langbot.pkg.api.http.controller.groups.pipelines.pipelines import PipelinesRouterGroup
+from langbot.pkg.api.http.controller.groups.provider.models import LLMModelsRouterGroup
+from langbot.pkg.api.http.controller.groups.provider.providers import ModelProvidersRouterGroup
+from langbot.pkg.api.http.controller.groups.resources.mcp import MCPRouterGroup
+from langbot.pkg.api.http.controller.groups.webhook_mgmt import WebhookManagementRouterGroup
+from langbot.pkg.api.http.service.secrets import mask_secret_value, redact_secrets
+
+
+pytestmark = pytest.mark.asyncio
+WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
+
+RAW_PIPELINE = {
+ 'uuid': 'pipeline-test',
+ 'config': {'ai': {'n8n': {'webhook-url': 'https://hook.invalid/bearer-secret'}}},
+}
+RAW_MODEL = {
+ 'uuid': 'model-test',
+ 'provider_uuid': 'provider-test',
+ 'extra_args': {'headers': {'Authorization': 'Bearer model-secret'}},
+}
+RAW_PROVIDER = {
+ 'uuid': 'provider-test',
+ 'base_url': 'https://provider-user:provider-password@provider.invalid/v1?token=url-secret®ion=sg',
+ 'api_keys': ['provider-secret'],
+}
+RAW_MCP_SERVER = {
+ 'uuid': 'mcp-test',
+ 'name': 'MCP Test',
+ 'extra_args': {'url': 'https://mcp-user:mcp-password@mcp.invalid/connect?api_key=url-secret&transport=http'},
+}
+RAW_KNOWLEDGE_BASE = {
+ 'uuid': 'kb-test',
+ 'creation_settings': {'dify_apikey': 'knowledge-secret'},
+}
+RAW_WEBHOOK = {'id': 1, 'url': 'https://hook.invalid/path?token=webhook-secret'}
+
+
+def _access(role: str):
+ return SimpleNamespace(
+ workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
+ membership=SimpleNamespace(uuid='membership-test', role=role, projection_revision=1),
+ execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
+ )
+
+
+async def _create_client(role: str):
+ application = SimpleNamespace()
+ account = SimpleNamespace(uuid='account-test', user='test@example.com')
+ application.user_service = SimpleNamespace(get_authenticated_account=AsyncMock(return_value=account))
+ application.apikey_service = SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None))
+ application.workspace_collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(return_value=_access(role))
+ )
+
+ async def get_pipelines(_context, *_args, include_secret=False):
+ value = copy.deepcopy(RAW_PIPELINE)
+ return [value] if include_secret else [redact_secrets(value)]
+
+ async def get_pipeline(_context, _uuid, *, include_secret=False):
+ value = copy.deepcopy(RAW_PIPELINE)
+ return value if include_secret else redact_secrets(value)
+
+ application.pipeline_service = SimpleNamespace(
+ get_pipelines=AsyncMock(side_effect=get_pipelines),
+ get_pipeline=AsyncMock(side_effect=get_pipeline),
+ )
+ application.plugin_connector = SimpleNamespace(list_plugins=AsyncMock(return_value=[]))
+ application.mcp_service = SimpleNamespace(
+ get_mcp_servers=AsyncMock(return_value=[redact_secrets(copy.deepcopy(RAW_MCP_SERVER))])
+ )
+ application.skill_service = SimpleNamespace(list_skills=AsyncMock(return_value=[]))
+
+ async def get_models_by_provider(_context, _provider_uuid, *, include_secret=False):
+ value = copy.deepcopy(RAW_MODEL)
+ return [value] if include_secret else [redact_secrets(value)]
+
+ application.llm_model_service = SimpleNamespace(
+ get_llm_models_by_provider=AsyncMock(side_effect=get_models_by_provider)
+ )
+
+ async def get_providers(_context, *, include_secret=False):
+ value = copy.deepcopy(RAW_PROVIDER)
+ return [value] if include_secret else [redact_secrets(value)]
+
+ application.provider_service = SimpleNamespace(
+ get_providers=AsyncMock(side_effect=get_providers),
+ get_provider_model_counts=AsyncMock(return_value={'llm_count': 0, 'embedding_count': 0, 'rerank_count': 0}),
+ )
+
+ async def get_knowledge_bases(_context, *, include_secret=False):
+ value = copy.deepcopy(RAW_KNOWLEDGE_BASE)
+ return [value] if include_secret else [redact_secrets(value)]
+
+ application.knowledge_service = SimpleNamespace(get_knowledge_bases=AsyncMock(side_effect=get_knowledge_bases))
+
+ async def get_webhooks(_context, *, include_secret=False):
+ value = copy.deepcopy(RAW_WEBHOOK)
+ if not include_secret:
+ value['url'] = mask_secret_value(value['url'])
+ return [value]
+
+ application.webhook_service = SimpleNamespace(get_webhooks=AsyncMock(side_effect=get_webhooks))
+
+ quart_app = quart.Quart(__name__)
+ for router_type in (
+ PipelinesRouterGroup,
+ LLMModelsRouterGroup,
+ ModelProvidersRouterGroup,
+ MCPRouterGroup,
+ KnowledgeBaseRouterGroup,
+ WebhookManagementRouterGroup,
+ ):
+ await router_type(application, quart_app).initialize()
+ return application, quart_app.test_client()
+
+
+def _headers() -> dict[str, str]:
+ return {'Authorization': 'Bearer test-token', 'X-Workspace-Id': WORKSPACE_UUID}
+
+
+@pytest.mark.parametrize('role', ['viewer', 'operator'])
+async def test_viewer_and_operator_resource_reads_are_redacted(role: str):
+ application, client = await _create_client(role)
+
+ pipeline = (await (await client.get('/api/v1/pipelines', headers=_headers())).get_json())['data']['pipelines'][0]
+ model = (
+ await (
+ await client.get(
+ '/api/v1/provider/models/llm?provider_uuid=provider-test',
+ headers=_headers(),
+ )
+ ).get_json()
+ )['data']['models'][0]
+ provider = (await (await client.get('/api/v1/provider/providers', headers=_headers())).get_json())['data'][
+ 'providers'
+ ][0]
+ mcp_server = (await (await client.get('/api/v1/mcp/servers', headers=_headers())).get_json())['data']['servers'][0]
+ knowledge_base = (await (await client.get('/api/v1/knowledge/bases', headers=_headers())).get_json())['data'][
+ 'bases'
+ ][0]
+ webhook = (await (await client.get('/api/v1/webhooks', headers=_headers())).get_json())['data']['webhooks'][0]
+
+ assert pipeline['config']['ai']['n8n']['webhook-url'] == '***'
+ assert model['extra_args']['headers']['Authorization'] == '***'
+ assert provider['api_keys'] == ['***']
+ assert provider['base_url'] == 'https://***@provider.invalid/v1?token=***®ion=sg'
+ assert mcp_server['extra_args']['url'] == 'https://***@mcp.invalid/connect?api_key=***&transport=http'
+ assert knowledge_base['creation_settings']['dify_apikey'] == '***'
+ assert webhook['url'] == '***'
+ assert application.pipeline_service.get_pipelines.await_args.kwargs['include_secret'] is False
+ assert application.llm_model_service.get_llm_models_by_provider.await_args.kwargs['include_secret'] is False
+ assert application.provider_service.get_providers.await_args.kwargs['include_secret'] is False
+ assert application.knowledge_service.get_knowledge_bases.await_args.kwargs['include_secret'] is False
+ assert application.webhook_service.get_webhooks.await_args.kwargs['include_secret'] is False
+
+
+async def test_resource_manager_receives_credentials_needed_for_management():
+ application, client = await _create_client('developer')
+
+ pipeline = (await (await client.get('/api/v1/pipelines', headers=_headers())).get_json())['data']['pipelines'][0]
+ model = (
+ await (
+ await client.get(
+ '/api/v1/provider/models/llm?provider_uuid=provider-test',
+ headers=_headers(),
+ )
+ ).get_json()
+ )['data']['models'][0]
+ provider = (await (await client.get('/api/v1/provider/providers', headers=_headers())).get_json())['data'][
+ 'providers'
+ ][0]
+ knowledge_base = (await (await client.get('/api/v1/knowledge/bases', headers=_headers())).get_json())['data'][
+ 'bases'
+ ][0]
+ webhook = (await (await client.get('/api/v1/webhooks', headers=_headers())).get_json())['data']['webhooks'][0]
+
+ assert pipeline == RAW_PIPELINE
+ assert model == RAW_MODEL
+ assert provider['api_keys'] == ['provider-secret']
+ assert knowledge_base == RAW_KNOWLEDGE_BASE
+ assert webhook == RAW_WEBHOOK
+ assert application.pipeline_service.get_pipelines.await_args.kwargs['include_secret'] is True
+ assert application.llm_model_service.get_llm_models_by_provider.await_args.kwargs['include_secret'] is True
+ assert application.provider_service.get_providers.await_args.kwargs['include_secret'] is True
+ assert application.knowledge_service.get_knowledge_bases.await_args.kwargs['include_secret'] is True
+ assert application.webhook_service.get_webhooks.await_args.kwargs['include_secret'] is True
diff --git a/tests/unit_tests/api/test_stats_controller.py b/tests/unit_tests/api/test_stats_controller.py
new file mode 100644
index 000000000..2fdfcf731
--- /dev/null
+++ b/tests/unit_tests/api/test_stats_controller.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import quart
+
+from langbot.pkg.api.http.controller.groups.stats import StatsRouterGroup
+
+
+pytestmark = pytest.mark.asyncio
+
+
+def session(
+ workspace_uuid: str,
+ *,
+ placement_generation: int = 1,
+ conversation_count: int = 0,
+):
+ return SimpleNamespace(
+ instance_uuid='instance-test',
+ workspace_uuid=workspace_uuid,
+ placement_generation=placement_generation,
+ conversations=[object() for _ in range(conversation_count)],
+ )
+
+
+async def create_client(*, role='viewer'):
+ quart_app = quart.Quart(__name__)
+ account = SimpleNamespace(uuid='account-test', user='test@example.com')
+ user_service = SimpleNamespace(
+ get_authenticated_account=AsyncMock(return_value=account),
+ )
+ access = SimpleNamespace(
+ execution=SimpleNamespace(
+ instance_uuid='instance-test',
+ placement_generation=1,
+ ),
+ workspace=SimpleNamespace(uuid='workspace-a'),
+ membership=SimpleNamespace(
+ uuid='membership-test',
+ role=role,
+ projection_revision=1,
+ ),
+ )
+ collaboration_service = SimpleNamespace(
+ resolve_account_workspace=AsyncMock(return_value=access),
+ )
+
+ def get_query_count(context):
+ assert context.instance_uuid == 'instance-test'
+ assert context.workspace_uuid == 'workspace-a'
+ assert context.placement_generation == 1
+ return 7
+
+ ap = SimpleNamespace(
+ user_service=user_service,
+ workspace_collaboration_service=collaboration_service,
+ sess_mgr=SimpleNamespace(
+ session_list=[
+ session('workspace-a', conversation_count=2),
+ session('workspace-b', conversation_count=5),
+ session(
+ 'workspace-a',
+ placement_generation=2,
+ conversation_count=3,
+ ),
+ SimpleNamespace(conversations=[object()] * 11),
+ ]
+ ),
+ query_pool=SimpleNamespace(get_query_count=get_query_count),
+ )
+ router = StatsRouterGroup(ap, quart_app)
+ await router.initialize()
+ return quart_app.test_client(), collaboration_service
+
+
+async def test_basic_stats_are_scoped_to_selected_workspace_placement():
+ client, collaboration_service = await create_client()
+
+ response = await client.get(
+ '/api/v1/stats/basic',
+ headers={
+ 'Authorization': 'Bearer test-token',
+ 'X-Workspace-Id': 'workspace-a',
+ },
+ )
+
+ assert response.status_code == 200
+ payload = await response.get_json()
+ assert payload['data'] == {
+ 'active_session_count': 1,
+ 'conversation_count': 2,
+ 'query_count': 7,
+ }
+ collaboration_service.resolve_account_workspace.assert_awaited_once_with('account-test', 'workspace-a')
+
+
+async def test_basic_stats_requires_resource_view_permission():
+ client, _ = await create_client(role='unknown-role')
+
+ response = await client.get(
+ '/api/v1/stats/basic',
+ headers={'Authorization': 'Bearer test-token'},
+ )
+
+ assert response.status_code == 403
+ payload = await response.get_json()
+ assert payload['code'] == 'permission_denied'
diff --git a/tests/unit_tests/api/test_websocket_chat_uow.py b/tests/unit_tests/api/test_websocket_chat_uow.py
new file mode 100644
index 000000000..5f08563d2
--- /dev/null
+++ b/tests/unit_tests/api/test_websocket_chat_uow.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from langbot.pkg.api.http.context import (
+ PrincipalContext,
+ PrincipalType,
+ RequestContext,
+ WorkspaceContext,
+)
+from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
+from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import (
+ create_scoped_duplex_tasks,
+)
+from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import wait_for_duplex_tasks
+from langbot.pkg.utils.bounded_executor import current_blocking_work_scope
+
+
+@pytest.mark.asyncio
+async def test_websocket_pipeline_lookup_opens_workspace_uow_after_auth_scope_closed() -> None:
+ workspace_uuid = 'workspace-a'
+ scopes: list[str] = []
+ in_scope = False
+
+ @asynccontextmanager
+ async def tenant_uow(selected_workspace_uuid: str):
+ nonlocal in_scope
+ assert not in_scope
+ in_scope = True
+ scopes.append(selected_workspace_uuid)
+ try:
+ yield
+ finally:
+ in_scope = False
+
+ async def get_pipeline(_context, _pipeline_uuid):
+ assert in_scope
+ return {'uuid': 'pipeline-a'}
+
+ adapter = Mock()
+ router = object.__new__(WebSocketChatRouterGroup)
+ router.ap = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(
+ mode=SimpleNamespace(value='cloud_runtime'),
+ tenant_uow=tenant_uow,
+ ),
+ pipeline_service=SimpleNamespace(get_pipeline=AsyncMock(side_effect=get_pipeline)),
+ platform_mgr=SimpleNamespace(get_websocket_proxy_bot=AsyncMock(return_value=SimpleNamespace(adapter=adapter))),
+ )
+ request_context = RequestContext(
+ instance_uuid='instance-a',
+ placement_generation=1,
+ request_id='request-a',
+ auth_type='user_token',
+ principal=PrincipalContext(
+ principal_type=PrincipalType.ACCOUNT,
+ account_uuid='account-a',
+ ),
+ workspace=WorkspaceContext(
+ workspace_uuid=workspace_uuid,
+ membership_uuid='membership-a',
+ role='owner',
+ permissions=frozenset(),
+ ),
+ )
+
+ result = await router._get_scoped_adapter(request_context, 'pipeline-a')
+
+ assert result is adapter
+ assert scopes == [workspace_uuid]
+
+
+@pytest.mark.asyncio
+async def test_duplex_websocket_tasks_cancel_blocked_peer_when_one_direction_ends() -> None:
+ blocked = asyncio.Event()
+
+ async def receive_forever() -> None:
+ blocked.set()
+ await asyncio.Future()
+
+ async def send_finishes() -> None:
+ await blocked.wait()
+
+ receive_task = asyncio.create_task(receive_forever())
+ send_task = asyncio.create_task(send_finishes())
+
+ await asyncio.wait_for(
+ wait_for_duplex_tasks(receive_task, send_task),
+ timeout=1,
+ )
+
+ assert receive_task.cancelled()
+ assert send_task.done()
+
+
+@pytest.mark.asyncio
+async def test_duplex_websocket_tasks_allow_terminal_send_to_drain() -> None:
+ receive_finished = asyncio.Event()
+ send_drained = asyncio.Event()
+
+ async def receive_finishes() -> None:
+ receive_finished.set()
+
+ async def send_terminal_frame() -> None:
+ await receive_finished.wait()
+ await asyncio.sleep(0)
+ send_drained.set()
+
+ receive_task = asyncio.create_task(receive_finishes())
+ send_task = asyncio.create_task(send_terminal_frame())
+
+ await wait_for_duplex_tasks(receive_task, send_task)
+
+ assert send_drained.is_set()
+ assert send_task.done()
+ assert not send_task.cancelled()
+
+
+@pytest.mark.asyncio
+async def test_duplex_websocket_tasks_share_trusted_workspace_budget() -> None:
+ observed: list[tuple[str, str | None]] = []
+
+ async def observe(direction: str) -> None:
+ await asyncio.sleep(0)
+ observed.append((direction, current_blocking_work_scope()))
+
+ receive_task, send_task = create_scoped_duplex_tasks(
+ observe('receive'),
+ observe('send'),
+ 'workspace-a',
+ )
+
+ await asyncio.gather(receive_task, send_task)
+
+ assert sorted(observed) == [
+ ('receive', 'workspace-a'),
+ ('send', 'workspace-a'),
+ ]
+ assert current_blocking_work_scope() is None
diff --git a/tests/unit_tests/box/test_box_admission.py b/tests/unit_tests/box/test_box_admission.py
new file mode 100644
index 000000000..bdb4c4a32
--- /dev/null
+++ b/tests/unit_tests/box/test_box_admission.py
@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+import datetime as dt
+import hashlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from langbot_plugin.box.models import SandboxAdmissionPolicy
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.box.admission import (
+ BoxAdmissionError,
+ SandboxAdmissionController,
+ require_cloud_admission_policy,
+)
+from langbot.pkg.box.service import BoxService
+from langbot.pkg.cloud.entitlements import (
+ EntitlementResolver,
+ EntitlementSnapshot,
+ EntitlementUnavailableError,
+)
+
+
+_UTC = dt.timezone.utc
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+ entitlement_revision=7,
+)
+
+
+def _snapshot(
+ *,
+ revision: int = 7,
+ managed: bool = True,
+ sessions: int = 1,
+ expires_at: int = 2_000,
+) -> EntitlementSnapshot:
+ return EntitlementSnapshot(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ entitlement_revision=revision,
+ status='active',
+ not_before=1,
+ expires_at=expires_at,
+ features={'managed_sandbox': managed},
+ limits={'managed_sandbox_sessions': sessions},
+ )
+
+
+def _controller(snapshot: EntitlementSnapshot, *, now: float = 1_000.25):
+ provider = SimpleNamespace(get_workspace_entitlement=AsyncMock(return_value=snapshot))
+ resolver = EntitlementResolver('instance-a', provider)
+ client = SimpleNamespace(
+ upsert_sandbox_admission_grant=AsyncMock(
+ side_effect=lambda grant: {
+ 'installed': True,
+ 'workspace_uuid': grant.workspace_uuid,
+ 'execution_generation': grant.execution_generation,
+ 'entitlement_revision': grant.entitlement_revision,
+ 'max_sessions': grant.max_sessions,
+ 'max_managed_processes': grant.max_managed_processes,
+ }
+ ),
+ revoke_sandbox_admission_grant=AsyncMock(
+ side_effect=lambda revocation: {
+ 'revoked': True,
+ 'workspace_uuid': revocation.workspace_uuid,
+ 'entitlement_revision': revocation.entitlement_revision,
+ }
+ ),
+ )
+ app = SimpleNamespace(entitlement_resolver=resolver, logger=Mock())
+ controller = SandboxAdmissionController(
+ app,
+ client,
+ policy=SandboxAdmissionPolicy(required=True, max_grant_ttl_sec=300),
+ wall_time=lambda: now,
+ )
+ return controller, client, provider
+
+
+def test_cloud_admission_policy_requires_positive_workspace_quota():
+ with pytest.raises(BoxAdmissionError, match='workspace quota must be a positive integer'):
+ require_cloud_admission_policy(
+ {
+ 'required': True,
+ 'workspace_quota_mb': 0,
+ }
+ )
+
+ policy = require_cloud_admission_policy(
+ {
+ 'required': True,
+ 'workspace_quota_mb': 32,
+ }
+ )
+ assert policy.workspace_quota_mb == 32
+
+
+@pytest.mark.asyncio
+async def test_active_generic_entitlement_installs_short_lived_numeric_grant():
+ controller, client, provider = _controller(_snapshot())
+
+ grant = await controller.require(_CONTEXT)
+
+ assert grant.instance_uuid == _CONTEXT.instance_uuid
+ assert grant.workspace_uuid == _CONTEXT.workspace_uuid
+ assert grant.execution_generation == _CONTEXT.placement_generation
+ assert grant.entitlement_revision == 7
+ assert grant.max_sessions == 1
+ assert grant.max_managed_processes == 0
+ assert grant.expires_at == dt.datetime.fromtimestamp(1_300, tz=_UTC)
+ assert (grant.expires_at - dt.datetime.fromtimestamp(1_000.25, tz=_UTC)).total_seconds() < 300
+ provider.get_workspace_entitlement.assert_awaited_once_with('workspace-a')
+ client.upsert_sandbox_admission_grant.assert_awaited_once_with(grant)
+ client.revoke_sandbox_admission_grant.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'snapshot',
+ [
+ _snapshot(managed=False),
+ _snapshot(sessions=0),
+ _snapshot(sessions=2),
+ ],
+)
+async def test_non_eligible_entitlement_revokes_and_fails_closed(snapshot):
+ controller, client, _provider = _controller(snapshot)
+
+ with pytest.raises(EntitlementUnavailableError):
+ await controller.require(_CONTEXT)
+
+ client.upsert_sandbox_admission_grant.assert_not_awaited()
+ revocation = client.revoke_sandbox_admission_grant.await_args.args[0]
+ assert revocation.entitlement_revision == snapshot.entitlement_revision
+
+
+@pytest.mark.asyncio
+async def test_transient_entitlement_failure_does_not_tombstone_valid_revision():
+ controller, client, provider = _controller(_snapshot())
+ await controller.require(_CONTEXT)
+ provider.get_workspace_entitlement.side_effect = RuntimeError('control plane unavailable')
+
+ with pytest.raises(RuntimeError, match='control plane unavailable'):
+ await controller.require(_CONTEXT)
+
+ client.revoke_sandbox_admission_grant.assert_not_awaited()
+
+ provider.get_workspace_entitlement.side_effect = None
+ provider.get_workspace_entitlement.return_value = _snapshot()
+ recovered = await controller.require(_CONTEXT)
+ assert recovered.entitlement_revision == 7
+
+
+@pytest.mark.asyncio
+async def test_runtime_receipt_mismatch_is_revoked_and_never_admitted():
+ controller, client, _provider = _controller(_snapshot())
+ client.upsert_sandbox_admission_grant.return_value = {'installed': True, 'workspace_uuid': 'other'}
+ client.upsert_sandbox_admission_grant.side_effect = None
+
+ with pytest.raises(Exception, match='invalid sandbox admission receipt'):
+ await controller.require(_CONTEXT)
+
+ client.revoke_sandbox_admission_grant.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_authoritative_cancelled_revision_is_revoked():
+ cancelled = _snapshot(revision=8).model_copy(update={'status': 'cancelled'})
+ controller, client, _provider = _controller(cancelled)
+
+ with pytest.raises(EntitlementUnavailableError, match='not active'):
+ await controller.require(_CONTEXT)
+
+ revocation = client.revoke_sandbox_admission_grant.await_args.args[0]
+ assert revocation.entitlement_revision == 8
+
+
+@pytest.mark.asyncio
+async def test_cloud_box_readiness_failure_aborts_service_initialization(tmp_path):
+ workspace_root = tmp_path / 'box' / 'workspaces'
+ workspace_root.mkdir(parents=True)
+ box_config = {
+ 'enabled': True,
+ 'backend': 'nsjail',
+ 'runtime': {'endpoint': 'ws://box:5410'},
+ 'local': {
+ 'host_root': str(tmp_path / 'box'),
+ 'default_workspace': str(workspace_root),
+ 'allowed_mount_roots': [str(tmp_path / 'box')],
+ },
+ 'admission': {
+ 'required': True,
+ 'logical_session_id': 'global',
+ 'required_backend': 'nsjail',
+ 'max_sessions': 1,
+ 'max_managed_processes': 0,
+ 'max_grant_ttl_sec': 300,
+ 'workspace_quota_mb': 32,
+ },
+ }
+ client = SimpleNamespace(
+ initialize=AsyncMock(),
+ verify_shared_workspace=AsyncMock(
+ side_effect=lambda marker_name: {
+ 'marker_name': marker_name,
+ 'size': (workspace_root / marker_name).stat().st_size,
+ 'sha256': hashlib.sha256((workspace_root / marker_name).read_bytes()).hexdigest(),
+ }
+ ),
+ get_backend_info=AsyncMock(return_value={'name': 'docker', 'available': True}),
+ )
+ app = SimpleNamespace(
+ logger=Mock(),
+ deployment=SimpleNamespace(multi_workspace_enabled=True),
+ entitlement_resolver=Mock(),
+ workspace_service=SimpleNamespace(instance_uuid='instance-a'),
+ instance_config=SimpleNamespace(data={'box': box_config}),
+ )
+ service = BoxService(app, client=client)
+
+ with pytest.raises(Exception, match='nsjail isolation readiness failed'):
+ await service.initialize()
+
+ assert service.available is False
diff --git a/tests/unit_tests/box/test_box_connector.py b/tests/unit_tests/box/test_box_connector.py
index 91016291d..367b2e362 100644
--- a/tests/unit_tests/box/test_box_connector.py
+++ b/tests/unit_tests/box/test_box_connector.py
@@ -6,14 +6,28 @@ from unittest.mock import AsyncMock, Mock
import pytest
-from langbot_plugin.box.client import ActionRPCBoxClient
from langbot.pkg.box import connector as connector_module
+from langbot_plugin.box.client import ActionRPCBoxClient
+from langbot_plugin.box.errors import BoxRuntimeUnavailableError
+from langbot_plugin.box.security import (
+ BOX_CONTROL_TOKEN_ENV,
+ BOX_CONTROL_TOKEN_HEADER,
+ BOX_INSTANCE_HEADER,
+ BOX_PLACEMENT_GENERATION_HEADER,
+ BOX_TRUSTED_INSTANCE_ENV,
+ BOX_WORKSPACE_HEADER,
+)
+from langbot_plugin.entities.io.context import ActionContext
from langbot.pkg.box.connector import BoxRuntimeConnector
+_CONTROL_TOKEN = 'box-control-token-that-is-longer-than-32-bytes'
+
+
def make_app(logger: Mock, runtime_endpoint: str = ''):
return SimpleNamespace(
logger=logger,
+ workspace_service=SimpleNamespace(instance_uuid='instance-a'),
instance_config=SimpleNamespace(
data={
'box': {
@@ -239,3 +253,130 @@ async def test_box_disconnect_notifies_once_and_clears_handler(
disconnect.assert_awaited_once_with(connector)
assert connector._handler is None
await connector.aclose()
+
+
+def test_box_runtime_connector_builds_host_control_headers(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, _CONTROL_TOKEN)
+ connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+
+ headers = connector.get_control_headers()
+
+ assert headers == {
+ BOX_CONTROL_TOKEN_HEADER: _CONTROL_TOKEN,
+ BOX_INSTANCE_HEADER: 'instance-a',
+ }
+ assert _CONTROL_TOKEN not in connector._resolve_rpc_ws_url()
+
+
+def test_box_runtime_connector_builds_placement_scoped_relay_headers(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, _CONTROL_TOKEN)
+ connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+
+ headers = connector.get_relay_headers(
+ ActionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=7,
+ )
+ )
+
+ assert headers == {
+ BOX_CONTROL_TOKEN_HEADER: _CONTROL_TOKEN,
+ BOX_INSTANCE_HEADER: 'instance-a',
+ BOX_WORKSPACE_HEADER: 'workspace-a',
+ BOX_PLACEMENT_GENERATION_HEADER: '7',
+ }
+
+
+def test_box_runtime_connector_rejects_relay_context_from_other_instance(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, _CONTROL_TOKEN)
+ connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+
+ with pytest.raises(BoxRuntimeUnavailableError, match='another LangBot instance'):
+ connector.get_relay_headers(
+ ActionContext(
+ instance_uuid='instance-b',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ )
+
+
+def test_external_box_runtime_fails_closed_without_control_token(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
+ connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+
+ with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
+ connector.get_control_headers()
+
+
+async def test_local_stdio_injects_generated_token_and_trusted_instance(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
+ captured = {}
+
+ class FakeStdioClientController:
+ def __init__(self, **kwargs):
+ captured.update(kwargs)
+ self.process = Mock()
+
+ async def run(self, callback):
+ await callback(None)
+
+ monkeypatch.setattr(
+ 'langbot_plugin.runtime.io.controllers.stdio.client.StdioClientController',
+ FakeStdioClientController,
+ )
+ connector = BoxRuntimeConnector(make_app(Mock()))
+
+ def fake_callback(_transport_name, connected, _connect_error, _generation):
+ async def callback(_connection):
+ connected.set()
+
+ return callback
+
+ monkeypatch.setattr(connector, '_make_connection_callback', fake_callback)
+
+ await connector._start_local_stdio()
+
+ assert len(captured['env'][BOX_CONTROL_TOKEN_ENV]) >= 32
+ assert captured['env'][BOX_TRUSTED_INSTANCE_ENV] == 'instance-a'
+
+
+async def test_websocket_controller_receives_control_headers(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, _CONTROL_TOKEN)
+ captured = {}
+
+ class FakeWebSocketClientController:
+ def __init__(self, **kwargs):
+ captured.update(kwargs)
+
+ async def run(self, callback):
+ await callback(None)
+
+ monkeypatch.setattr(
+ 'langbot_plugin.runtime.io.controllers.ws.client.WebSocketClientController',
+ FakeWebSocketClientController,
+ )
+ connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+
+ def fake_callback(_transport_name, connected, _connect_error, _generation):
+ async def callback(_connection):
+ connected.set()
+
+ return callback
+
+ monkeypatch.setattr(connector, '_make_connection_callback', fake_callback)
+
+ await connector._connect_ws('ws://box-runtime:5410/rpc/ws', 'WebSocket')
+
+ assert captured['additional_headers'] == {
+ BOX_CONTROL_TOKEN_HEADER: _CONTROL_TOKEN,
+ BOX_INSTANCE_HEADER: 'instance-a',
+ }
+ assert _CONTROL_TOKEN not in captured['ws_url']
diff --git a/tests/unit_tests/box/test_box_deployment_config.py b/tests/unit_tests/box/test_box_deployment_config.py
new file mode 100644
index 000000000..f4b1d4d3c
--- /dev/null
+++ b/tests/unit_tests/box/test_box_deployment_config.py
@@ -0,0 +1,65 @@
+from pathlib import Path
+
+
+_REPO_ROOT = Path(__file__).resolve().parents[3]
+
+
+def test_compose_injects_the_same_box_control_token_into_host_and_runtime():
+ compose = (_REPO_ROOT / 'docker' / 'docker-compose.yaml').read_text(encoding='utf-8')
+ box_service = compose.split(' langbot_box:', 1)[1].split(' langbot:', 1)[0]
+ langbot_service = compose.split(' langbot:', 1)[1]
+ token_env = 'LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}'
+
+ assert token_env in box_service
+ assert token_env in langbot_service
+
+
+def test_kubernetes_uses_one_secret_for_box_runtime_and_langbot():
+ manifest = (_REPO_ROOT / 'docker' / 'kubernetes.yaml').read_text(encoding='utf-8')
+ box_deployment = manifest.split('name: langbot-box', 1)[1].split('# Service for LangBot Box runtime', 1)[0]
+ langbot_deployment = manifest.split('# Deployment for LangBot\n', 1)[1]
+ secret_reference = '\n'.join(
+ [
+ '- name: LANGBOT_BOX_CONTROL_TOKEN',
+ ' valueFrom:',
+ ' secretKeyRef:',
+ ' name: langbot-box-control',
+ ' key: token',
+ ]
+ )
+
+ assert secret_reference in box_deployment
+ assert secret_reference in langbot_deployment
+ assert '--from-literal=token="$(openssl rand -hex 32)"' in manifest
+
+
+def test_compose_injects_same_plugin_runtime_control_token_into_both_services():
+ compose = (_REPO_ROOT / 'docker' / 'docker-compose.yaml').read_text(encoding='utf-8')
+ runtime_service = compose.split(' langbot_plugin_runtime:', 1)[1].split(' langbot_box:', 1)[0]
+ langbot_service = compose.split(' langbot:', 1)[1]
+ token_env = 'LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}'
+
+ assert token_env in runtime_service
+ assert token_env in langbot_service
+
+
+def test_kubernetes_uses_one_secret_for_plugin_runtime_and_langbot():
+ manifest = (_REPO_ROOT / 'docker' / 'kubernetes.yaml').read_text(encoding='utf-8')
+ runtime_deployment = manifest.split('# Deployment for LangBot Plugin Runtime', 1)[1].split(
+ '# Service for LangBot Plugin Runtime',
+ 1,
+ )[0]
+ langbot_deployment = manifest.split('# Deployment for LangBot\n', 1)[1]
+ secret_reference = '\n'.join(
+ [
+ '- name: LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN',
+ ' valueFrom:',
+ ' secretKeyRef:',
+ ' name: langbot-plugin-runtime-control',
+ ' key: token',
+ ]
+ )
+
+ assert secret_reference in runtime_deployment
+ assert secret_reference in langbot_deployment
+ assert 'create secret generic langbot-plugin-runtime-control' in manifest
diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py
index 4737dab15..9c78f771c 100644
--- a/tests/unit_tests/box/test_box_service.py
+++ b/tests/unit_tests/box/test_box_service.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import datetime as dt
import os
+import pathlib
import tempfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -15,6 +16,7 @@ from langbot_plugin.box.backend import BaseSandboxBackend
from langbot_plugin.box.client import BoxRuntimeClient, ActionRPCBoxClient
from langbot_plugin.box.errors import (
BoxBackendUnavailableError,
+ BoxError,
BoxSessionConflictError,
BoxSessionNotFoundError,
BoxValidationError,
@@ -30,9 +32,27 @@ from langbot_plugin.box.models import (
BoxSpec,
)
from langbot_plugin.box.runtime import BoxRuntime
+from langbot_plugin.box.security import (
+ BOX_CONTROL_TOKEN_HEADER,
+ BOX_INSTANCE_HEADER,
+ BOX_PLACEMENT_GENERATION_HEADER,
+ BOX_WORKSPACE_HEADER,
+)
+from langbot_plugin.entities.io.context import ActionContext
+from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.box.service import BoxService
_UTC = dt.timezone.utc
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+_ACTION_CONTEXT = ActionContext(
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=_CONTEXT.placement_generation,
+)
class _InProcessBoxRuntimeClient(BoxRuntimeClient):
@@ -44,42 +64,63 @@ class _InProcessBoxRuntimeClient(BoxRuntimeClient):
async def initialize(self):
await self._runtime.initialize()
- async def execute(self, spec):
+ async def execute(self, spec, *, action_context=None):
return await self._runtime.execute(spec)
async def shutdown(self):
await self._runtime.shutdown()
- async def get_status(self):
+ async def get_status(self, *, action_context=None):
return await self._runtime.get_status()
- async def get_sessions(self):
+ async def get_sessions(self, *, action_context=None):
return self._runtime.get_sessions()
async def get_backend_info(self):
return await self._runtime.get_backend_info()
- async def delete_session(self, session_id):
+ async def delete_session(self, session_id, *, action_context=None):
await self._runtime.delete_session(session_id)
- async def create_session(self, spec):
+ async def create_session(self, spec, *, action_context=None):
return await self._runtime.create_session(spec)
- async def start_managed_process(self, session_id: str, spec: BoxManagedProcessSpec):
+ async def start_managed_process(
+ self,
+ session_id: str,
+ spec: BoxManagedProcessSpec,
+ *,
+ action_context=None,
+ ):
return await self._runtime.start_managed_process(session_id, spec)
- async def get_managed_process(self, session_id: str, process_id: str = 'default'):
+ async def get_managed_process(
+ self,
+ session_id: str,
+ process_id: str = 'default',
+ *,
+ action_context=None,
+ ):
return self._runtime.get_managed_process(session_id, process_id)
- async def stop_managed_process(self, session_id: str, process_id: str = 'default'):
+ async def stop_managed_process(
+ self,
+ session_id: str,
+ process_id: str = 'default',
+ *,
+ action_context=None,
+ ):
await self._runtime.stop_managed_process(session_id, process_id)
- async def get_session(self, session_id: str):
+ async def get_session(self, session_id: str, *, action_context=None):
return self._runtime.get_session(session_id)
async def init(self, config: dict) -> None:
self._runtime.init(config)
+ async def verify_shared_workspace(self, marker_name: str) -> dict:
+ return self._runtime.verify_shared_workspace(marker_name)
+
class FakeBackend(BaseSandboxBackend):
def __init__(self, logger: Mock, available: bool = True):
@@ -134,6 +175,12 @@ class FakeBackend(BaseSandboxBackend):
def make_query(query_id: int = 42) -> pipeline_query.Query:
return pipeline_query.Query.model_construct(
query_id=query_id,
+ query_uuid=f'query-{query_id}',
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=_CONTEXT.placement_generation,
+ bot_uuid='bot-a',
+ pipeline_uuid='pipeline-a',
launcher_type='person',
launcher_id='test_user',
sender_id='test_user',
@@ -170,8 +217,19 @@ def make_app(
if workspace_quota_mb is not None:
box_config['local']['workspace_quota_mb'] = workspace_quota_mb
+ workspace_service = SimpleNamespace(
+ instance_uuid=_CONTEXT.instance_uuid,
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=_CONTEXT.placement_generation,
+ )
+ ),
+ )
return SimpleNamespace(
logger=logger,
+ workspace_service=workspace_service,
instance_config=SimpleNamespace(
data={
'box': box_config,
@@ -196,6 +254,46 @@ async def test_box_service_without_explicit_client_initializes_internal_connecto
connector.initialize.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_cloud_initialize_validation_failure_closes_connector_and_cancels_reconnect(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ logger = Mock()
+ app = make_app(logger)
+ app.deployment = SimpleNamespace(multi_workspace_enabled=True)
+ app.instance_config.data['box'].update(
+ {
+ 'backend': 'nsjail',
+ 'admission': {'required': True, 'workspace_quota_mb': 32},
+ }
+ )
+ connector = Mock()
+ connector.client = Mock(spec=BoxRuntimeClient)
+ connector.initialize = AsyncMock()
+ connector.aclose = AsyncMock()
+ connector.runtime_disconnect_callback = Mock()
+ monkeypatch.setattr('langbot.pkg.box.service.BoxRuntimeConnector', Mock(return_value=connector))
+ service = BoxService(app)
+ service._ensure_default_workspace = Mock()
+ readiness_error = BoxValidationError('Cloud Box nsjail isolation readiness failed')
+ service._verify_cloud_runtime = AsyncMock(side_effect=readiness_error)
+ reconnect_task = asyncio.create_task(asyncio.Event().wait())
+ service._reconnect_task = reconnect_task
+ service._reconnecting = True
+
+ with pytest.raises(BoxValidationError) as exc_info:
+ await service.initialize()
+
+ assert exc_info.value is readiness_error
+ assert service.available is False
+ assert service._closing is True
+ assert service._reconnecting is False
+ assert service._reconnect_task is None
+ assert reconnect_task.cancelled()
+ assert connector.runtime_disconnect_callback is None
+ connector.aclose.assert_awaited_once()
+
+
class TestSharesFilesystemWithBox:
"""``shares_filesystem_with_box`` must reflect the real LangBot<->Box
filesystem topology, which is derived from the connector transport:
@@ -268,6 +366,43 @@ def test_separated_box_runtime_does_not_create_default_workspace_in_langbot(tmp_
assert not (host_root / 'default').exists()
+@pytest.mark.asyncio
+async def test_cloud_initialize_fails_when_core_and_runtime_volumes_are_separated(tmp_path):
+ logger = Mock()
+ core_root = tmp_path / 'core-box'
+ runtime_root = tmp_path / 'runtime-box'
+ (core_root / 'default').mkdir(parents=True)
+ runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
+ runtime.init(
+ {
+ 'local': {
+ 'host_root': str(runtime_root),
+ 'default_workspace': 'default',
+ 'allowed_mount_roots': [str(runtime_root)],
+ }
+ }
+ )
+ app = make_app(logger, host_root=str(core_root))
+ app.deployment = SimpleNamespace(multi_workspace_enabled=True)
+ app.instance_config.data['box'].update(
+ {
+ 'backend': 'nsjail',
+ 'admission': {'required': True, 'workspace_quota_mb': 32},
+ }
+ )
+ service = BoxService(
+ app,
+ client=_InProcessBoxRuntimeClient(logger, runtime),
+ )
+
+ with pytest.raises(BoxValidationError, match='shared durable Workspace volume'):
+ await service.initialize()
+
+ assert service.available is False
+ assert list((core_root / 'default').glob('.langbot-box-volume-probe-*')) == []
+ await runtime.shutdown()
+
+
def test_separated_box_runtime_allows_box_owned_missing_host_path(tmp_path):
logger = Mock()
runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300)
@@ -289,12 +424,77 @@ async def test_box_service_get_sessions_delegates_to_client():
service = BoxService(make_app(Mock()), client=client)
service._available = True
- sessions = await service.get_sessions()
+ sessions = await service.get_sessions(_CONTEXT)
assert sessions == [{'session_id': 'test-session'}]
client.get_sessions.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_box_service_relay_connection_is_binding_checked_and_scoped():
+ app = make_app(Mock())
+ client = Mock()
+ client.get_managed_process_websocket_url = Mock(
+ return_value='ws://box/v1/sessions/physical/managed-process/server-a/ws'
+ )
+ connector = Mock()
+ connector.ws_relay_base_url = 'http://box:5410'
+ connector.get_relay_headers = Mock(
+ return_value={
+ BOX_CONTROL_TOKEN_HEADER: 'secret',
+ BOX_INSTANCE_HEADER: _CONTEXT.instance_uuid,
+ BOX_WORKSPACE_HEADER: _CONTEXT.workspace_uuid,
+ BOX_PLACEMENT_GENERATION_HEADER: '1',
+ }
+ )
+ service = BoxService(app, client=client)
+ service._runtime_connector = connector
+
+ url, headers = await service.get_managed_process_websocket_connection(
+ _CONTEXT,
+ 'mcp-shared',
+ 'server-a',
+ )
+
+ assert url == 'ws://box/v1/sessions/physical/managed-process/server-a/ws'
+ assert headers[BOX_WORKSPACE_HEADER] == _CONTEXT.workspace_uuid
+ assert headers[BOX_PLACEMENT_GENERATION_HEADER] == '1'
+ app.workspace_service.get_execution_binding.assert_awaited_once_with(
+ _CONTEXT.workspace_uuid,
+ expected_generation=_CONTEXT.placement_generation,
+ )
+ action_context = connector.get_relay_headers.call_args.args[0]
+ assert action_context == _ACTION_CONTEXT
+ client.get_managed_process_websocket_url.assert_called_once_with(
+ 'mcp-shared',
+ 'http://box:5410',
+ 'server-a',
+ action_context=_ACTION_CONTEXT,
+ )
+
+
+@pytest.mark.asyncio
+async def test_box_service_relay_connection_rejects_stale_binding():
+ app = make_app(Mock())
+ app.workspace_service.get_execution_binding.return_value = SimpleNamespace(
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=2,
+ )
+ client = Mock()
+ service = BoxService(app, client=client)
+ service._runtime_connector = Mock()
+
+ with pytest.raises(BoxValidationError, match='stale Workspace placement'):
+ await service.get_managed_process_websocket_connection(
+ _CONTEXT,
+ 'mcp-shared',
+ 'server-a',
+ )
+
+ service._runtime_connector.get_relay_headers.assert_not_called()
+
+
def test_box_service_dispose_delegates_to_internal_connector(monkeypatch: pytest.MonkeyPatch):
connector = Mock()
connector.client = Mock()
@@ -369,6 +569,28 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
assert service.available is True
+@pytest.mark.asyncio
+async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ app = make_app(Mock())
+ app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
+ service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
+ service._cloud_managed = True
+ connector = Mock()
+ connector.reconnect = AsyncMock()
+ service._ensure_default_workspace = Mock()
+ service._verify_cloud_runtime = AsyncMock()
+ monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock())
+
+ await service._reconnect_loop(connector)
+
+ connector.reconnect.assert_awaited_once()
+ service._verify_cloud_runtime.assert_awaited_once()
+ app.skill_mgr.reload_skills.assert_not_awaited()
+ assert service.available is True
+
+
@pytest.mark.asyncio
async def test_box_runtime_reuses_request_session():
logger = Mock()
@@ -409,7 +631,14 @@ async def test_box_service_session_id_uses_query_attributes_without_variables():
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
- query = pipeline_query.Query.model_construct(query_id=7, launcher_type='group', launcher_id='room-1')
+ query = pipeline_query.Query.model_construct(
+ query_id=7,
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=_CONTEXT.placement_generation,
+ launcher_type='group',
+ launcher_id='room-1',
+ )
result = await service.execute_tool({'command': 'pwd'}, query)
assert result['session_id'] == 'group_room-1'
@@ -425,7 +654,12 @@ async def test_box_service_session_id_falls_back_to_query_id_for_synthetic_queri
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
- query = pipeline_query.Query.model_construct(query_id=7)
+ query = pipeline_query.Query.model_construct(
+ query_id=7,
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=_CONTEXT.placement_generation,
+ )
result = await service.execute_tool({'command': 'pwd'}, query)
assert result['session_id'] == 'query_7'
@@ -447,8 +681,22 @@ async def test_box_service_forced_global_scope_overrides_pipeline_template():
await service.initialize()
# Two distinct callers that would otherwise get separate sandboxes.
- q1 = pipeline_query.Query.model_construct(query_id=1, launcher_type='group', launcher_id='room-1')
- q2 = pipeline_query.Query.model_construct(query_id=2, launcher_type='person', launcher_id='alice')
+ q1 = pipeline_query.Query.model_construct(
+ query_id=1,
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=_CONTEXT.placement_generation,
+ launcher_type='group',
+ launcher_id='room-1',
+ )
+ q2 = pipeline_query.Query.model_construct(
+ query_id=2,
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=_CONTEXT.placement_generation,
+ launcher_type='person',
+ launcher_id='alice',
+ )
r1 = await service.execute_tool({'command': 'pwd'}, q1)
r2 = await service.execute_tool({'command': 'pwd'}, q2)
@@ -551,7 +799,7 @@ async def test_box_service_uses_default_workspace_when_host_path_omitted(tmp_pat
assert result['ok'] is True
assert backend.start_calls == ['person_test_user']
assert backend.exec_calls == [('person_test_user', 'pwd')]
- assert backend.start_specs[0].host_path == os.path.realpath(host_dir)
+ assert backend.start_specs[0].host_path == service._tenant_workspace(_CONTEXT)
@pytest.mark.asyncio
@@ -994,11 +1242,16 @@ async def test_box_service_rejects_execution_when_workspace_already_exceeds_quot
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
host_dir = tmp_path / 'quota-workspace'
host_dir.mkdir()
- (host_dir / 'already-too-large.bin').write_bytes(b'x' * (2 * 1024 * 1024))
app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1)
app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
+ tenant_host_dir = service._tenant_workspace(_CONTEXT)
+ assert tenant_host_dir is not None
+ os.makedirs(tenant_host_dir, exist_ok=True)
+ with open(os.path.join(tenant_host_dir, 'already-too-large.bin'), 'wb') as handle:
+ handle.write(b'x' * (2 * 1024 * 1024))
+
await service.initialize()
with pytest.raises(BoxValidationError, match='workspace quota exceeded before execution'):
@@ -1027,6 +1280,45 @@ async def test_box_service_rejects_and_cleans_up_when_execution_exceeds_workspac
assert backend.stop_calls == ['person_test_user']
+@pytest.mark.asyncio
+async def test_box_service_rejects_workspace_inode_bomb_before_execution(tmp_path):
+ logger = Mock()
+ backend = FakeBackend(logger)
+ runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
+ host_dir = tmp_path / 'quota-workspace-entries'
+ host_dir.mkdir()
+ app = make_app(logger, [str(tmp_path)], workspace_quota_mb=1)
+ app.instance_config.data['box']['local']['default_workspace'] = str(host_dir)
+ app.instance_config.data['box']['limits'] = {'max_workspace_entries': 2}
+ service = BoxService(app, client=_InProcessBoxRuntimeClient(logger, runtime))
+
+ tenant_host_dir = service._tenant_workspace(_CONTEXT)
+ assert tenant_host_dir is not None
+ os.makedirs(tenant_host_dir, exist_ok=True)
+ for index in range(3):
+ pathlib.Path(tenant_host_dir, f'tiny-{index}').write_bytes(b'x')
+
+ await service.initialize()
+
+ with pytest.raises(BoxValidationError, match='workspace entry limit exceeded before execution'):
+ await service.execute_tool({'command': 'echo hi'}, make_query(46))
+
+ assert backend.start_calls == []
+
+
+def test_box_service_workspace_entry_limit_is_hard_clamped():
+ app = make_app(Mock())
+ app.instance_config.data['box']['limits'] = {'max_workspace_entries': 10_000_000}
+ service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
+ assert service._max_workspace_entries() == 1_000_000
+
+ app.instance_config.data['box']['limits']['max_workspace_entries'] = 0
+ assert service._max_workspace_entries() == 1
+
+ app.instance_config.data['box']['limits']['max_workspace_entries'] = 'invalid'
+ assert service._max_workspace_entries() == 100_000
+
+
@pytest.mark.asyncio
async def test_profile_offline_readonly_locks_read_only_rootfs():
"""offline_readonly locks read_only_rootfs so it cannot be overridden."""
@@ -1137,7 +1429,7 @@ async def test_service_records_errors_on_failure():
with pytest.raises(Exception):
await service.execute_tool({'command': 'echo hello'}, make_query(50))
- errors = service.get_recent_errors()
+ errors = service.get_recent_errors(_CONTEXT)
assert len(errors) == 1
assert errors[0]['type'] == 'BoxBackendUnavailableError'
assert errors[0]['query_id'] == '50'
@@ -1156,7 +1448,7 @@ async def test_service_error_ring_buffer_capped():
with pytest.raises(Exception):
await service.execute_tool({'command': 'fail'}, make_query(100 + i))
- errors = service.get_recent_errors()
+ errors = service.get_recent_errors(_CONTEXT)
assert len(errors) == 50
# Oldest should have been evicted, newest kept
assert errors[0]['query_id'] == '110'
@@ -1171,7 +1463,7 @@ async def test_service_get_status_aggregates_runtime_and_profile():
service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
- status = await service.get_status()
+ status = await service.get_status(_CONTEXT)
assert status['profile'] == 'default'
assert status['backend']['name'] == 'fake'
assert status['backend']['available'] is True
@@ -1215,7 +1507,12 @@ async def _make_rpc_pair(runtime: BoxRuntime):
client_conn, server_conn = _make_queue_connection_pair()
- server_handler = BoxServerHandler(server_conn, runtime)
+ server_handler = BoxServerHandler(
+ server_conn,
+ runtime,
+ host_control_authenticated=True,
+ trusted_instance_uuid=_CONTEXT.instance_uuid,
+ )
server_task = asyncio.create_task(server_handler.run())
client_handler = Handler.__new__(Handler)
@@ -1239,7 +1536,7 @@ async def test_rpc_client_execute():
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
spec = BoxSpec.model_validate({'cmd': 'echo remote', 'session_id': 'r-1'})
- result = await client.execute(spec)
+ result = await client.execute(spec, action_context=_ACTION_CONTEXT)
assert result.session_id == 'r-1'
assert result.status == BoxExecutionStatus.COMPLETED
@@ -1261,9 +1558,9 @@ async def test_rpc_client_get_sessions():
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-2'})
- await client.execute(spec)
+ await client.execute(spec, action_context=_ACTION_CONTEXT)
- sessions = await client.get_sessions()
+ sessions = await client.get_sessions(action_context=_ACTION_CONTEXT)
assert len(sessions) == 1
assert sessions[0]['session_id'] == 'r-2'
finally:
@@ -1272,6 +1569,34 @@ async def test_rpc_client_get_sessions():
await runtime.shutdown()
+@pytest.mark.asyncio
+async def test_rpc_generation_advance_retires_old_session_and_rejects_old_context():
+ logger = Mock()
+ backend = FakeBackend(logger)
+ runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
+ await runtime.initialize()
+ second_context = _ACTION_CONTEXT.model_copy(update={'placement_generation': 2})
+
+ client, server_task, client_task = await _make_rpc_pair(runtime)
+ try:
+ spec = BoxSpec.model_validate({'cmd': 'echo generation', 'session_id': 'shared'})
+ await client.execute(spec, action_context=_ACTION_CONTEXT)
+ await client.execute(spec, action_context=second_context)
+
+ assert len(backend.start_calls) == 2
+ assert backend.start_calls[0] != backend.start_calls[1]
+ assert backend.stop_calls == [backend.start_calls[0]]
+ assert [session['session_id'] for session in await client.get_sessions(action_context=second_context)] == [
+ 'shared'
+ ]
+ with pytest.raises(BoxError, match='Stale Box placement generation'):
+ await client.execute(spec, action_context=_ACTION_CONTEXT)
+ finally:
+ server_task.cancel()
+ client_task.cancel()
+ await runtime.shutdown()
+
+
@pytest.mark.asyncio
async def test_rpc_client_get_status():
logger = Mock()
@@ -1281,7 +1606,7 @@ async def test_rpc_client_get_status():
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
- status = await client.get_status()
+ status = await client.get_status(action_context=_ACTION_CONTEXT)
assert 'backend' in status
assert 'active_sessions' in status
@@ -1323,11 +1648,11 @@ async def test_rpc_client_delete_session():
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-del-1'})
- await client.execute(spec)
+ await client.execute(spec, action_context=_ACTION_CONTEXT)
- await client.delete_session('r-del-1')
+ await client.delete_session('r-del-1', action_context=_ACTION_CONTEXT)
- sessions = await client.get_sessions()
+ sessions = await client.get_sessions(action_context=_ACTION_CONTEXT)
assert len(sessions) == 0
finally:
server_task.cancel()
@@ -1345,7 +1670,7 @@ async def test_rpc_client_delete_session_raises_not_found():
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
with pytest.raises(BoxSessionNotFoundError):
- await client.delete_session('nonexistent')
+ await client.delete_session('nonexistent', action_context=_ACTION_CONTEXT)
finally:
server_task.cancel()
client_task.cancel()
@@ -1362,11 +1687,11 @@ async def test_rpc_client_create_session():
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
spec = BoxSpec.model_validate({'cmd': 'placeholder', 'session_id': 'r-create-1'})
- info = await client.create_session(spec)
+ info = await client.create_session(spec, action_context=_ACTION_CONTEXT)
assert info['session_id'] == 'r-create-1'
assert info['backend_name'] == 'fake'
- sessions = await client.get_sessions()
+ sessions = await client.get_sessions(action_context=_ACTION_CONTEXT)
assert len(sessions) == 1
finally:
server_task.cancel()
@@ -1384,11 +1709,11 @@ async def test_rpc_client_exec_raises_conflict_error():
client, server_task, client_task = await _make_rpc_pair(runtime)
try:
spec1 = BoxSpec.model_validate({'cmd': 'echo first', 'session_id': 'r-conflict-1', 'network': 'off'})
- await client.execute(spec1)
+ await client.execute(spec1, action_context=_ACTION_CONTEXT)
spec2 = BoxSpec.model_validate({'cmd': 'echo second', 'session_id': 'r-conflict-1', 'network': 'on'})
with pytest.raises(BoxSessionConflictError):
- await client.execute(spec2)
+ await client.execute(spec2, action_context=_ACTION_CONTEXT)
finally:
server_task.cancel()
client_task.cancel()
@@ -1487,7 +1812,7 @@ class TestBoxDisabledByConfig:
service = BoxService(make_app(logger, enabled=False), client=Mock(spec=BoxRuntimeClient))
await service.initialize()
- status = await service.get_status()
+ status = await service.get_status(_CONTEXT)
assert status['available'] is False
assert status['enabled'] is False
@@ -1502,7 +1827,7 @@ class TestBoxDisabledByConfig:
await service.initialize()
- status = await service.get_status()
+ status = await service.get_status(_CONTEXT)
assert status['available'] is False
assert status['enabled'] is True
assert 'docker daemon' in status['connector_error']
@@ -1526,7 +1851,7 @@ class TestBoxDisabledByConfig:
service = BoxService(make_app(logger, enabled=True), client=client)
await service.initialize()
- status = await service.get_status()
+ status = await service.get_status(_CONTEXT)
assert status['available'] is False
assert status['enabled'] is True
# The detailed backend object is preserved for the dialog
@@ -1547,7 +1872,7 @@ class TestBoxDisabledByConfig:
service = BoxService(make_app(logger, enabled=True), client=client)
await service.initialize()
- status = await service.get_status()
+ status = await service.get_status(_CONTEXT)
assert status['available'] is True
assert status['backend'] == {'name': 'docker', 'available': True}
# No spurious connector_error overlay when everything is healthy
@@ -1565,6 +1890,57 @@ class TestBoxDisabledByConfig:
assert service._reconnecting is False
+@pytest.mark.asyncio
+async def test_disconnect_callback_does_not_schedule_on_closing_event_loop(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
+ closed_loop = Mock()
+ closed_loop.is_closed.return_value = True
+ monkeypatch.setattr('langbot.pkg.box.service.asyncio.get_running_loop', Mock(return_value=closed_loop))
+
+ await service._on_runtime_disconnect(connector=Mock())
+
+ closed_loop.create_task.assert_not_called()
+ assert service._reconnect_task is None
+ assert service._reconnecting is False
+
+
+@pytest.mark.asyncio
+async def test_disconnect_callback_closes_reconnect_coroutine_when_task_creation_races_with_loop_close(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
+ loop = Mock()
+ loop.is_closed.return_value = False
+ loop.create_task.side_effect = RuntimeError('event loop is closed')
+ monkeypatch.setattr('langbot.pkg.box.service.asyncio.get_running_loop', Mock(return_value=loop))
+
+ async def reconnect():
+ await asyncio.Event().wait()
+
+ reconnect_coroutine = reconnect()
+ service._reconnect_loop = Mock(return_value=reconnect_coroutine)
+
+ await service._on_runtime_disconnect(connector=Mock())
+
+ loop.create_task.assert_called_once_with(reconnect_coroutine)
+ assert reconnect_coroutine.cr_frame is None
+ assert service._reconnect_task is None
+ assert service._reconnecting is False
+
+
+def test_disconnect_callback_does_not_schedule_without_running_event_loop():
+ service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
+ callback = service._on_runtime_disconnect(connector=Mock())
+
+ with pytest.raises(StopIteration):
+ callback.send(None)
+
+ assert service._reconnect_task is None
+ assert service._reconnecting is False
+
+
class TestBuildSkillExtraMounts:
"""Robustness of skill mount construction against a stale skill cache.
@@ -1577,7 +1953,7 @@ class TestBuildSkillExtraMounts:
def _make_service(self, logger, skills, *, shares_filesystem=True):
app = make_app(logger)
- app.skill_mgr = SimpleNamespace(skills=skills)
+ app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=skills))
client = Mock(spec=BoxRuntimeClient)
service = BoxService(app, client=client)
# Tests construct BoxService with an injected client (no connector), so
@@ -1714,6 +2090,17 @@ class TestAttachmentHelpers:
component = SimpleNamespace(base64=None, url=None, path=None)
assert await BoxService._component_to_bytes(component) is None
+ @pytest.mark.asyncio
+ async def test_component_to_bytes_rejects_oversized_base64(self, monkeypatch):
+ monkeypatch.setattr(BoxService, '_ATTACHMENT_MAX_BYTES', 4)
+ component = SimpleNamespace(
+ base64='data:application/octet-stream;base64,' + ('A' * 12),
+ url=None,
+ path=None,
+ )
+
+ assert await BoxService._component_to_bytes(component) is None
+
class TestInboundOutboundRoundTrip:
def _service(self) -> BoxService:
@@ -1745,7 +2132,7 @@ class TestInboundOutboundRoundTrip:
assert '/workspace/inbox/' in parameters['command']
return {
'ok': True,
- 'stdout': '["/workspace/inbox/42/image_1.png"]',
+ 'stdout': '["/workspace/inbox/query-42/image_1.png"]',
'stderr': '',
}
@@ -1755,7 +2142,7 @@ class TestInboundOutboundRoundTrip:
assert len(descriptors) == 1
d = descriptors[0]
assert d['type'] == 'Image'
- assert d['path'] == '/workspace/inbox/42/image_1.png'
+ assert d['path'] == '/workspace/inbox/query-42/image_1.png'
assert d['size'] == len(img_bytes)
@pytest.mark.asyncio
@@ -1778,7 +2165,7 @@ class TestInboundOutboundRoundTrip:
async def fake_execute_tool(parameters, q):
calls.append(parameters['command'])
- if 'os.walk' in parameters['command']:
+ if 'os.scandir' in parameters['command']:
return {
'ok': True,
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
@@ -1808,7 +2195,7 @@ class TestInboundOutboundRoundTrip:
async def fake_execute_tool(parameters, q):
calls.append(parameters['command'])
- if 'os.walk' in parameters['command']:
+ if 'os.scandir' in parameters['command']:
return {'ok': True, 'stdout': '[]', 'stderr': ''}
return {'ok': True, 'stdout': '', 'stderr': ''}
@@ -1835,14 +2222,17 @@ class TestAttachmentHostPath:
"""
def _service_with_workspace(self, tmp_path):
- ws = str(tmp_path / 'box' / 'default')
- os.makedirs(ws, exist_ok=True)
+ default_workspace = str(tmp_path / 'box' / 'default')
+ os.makedirs(default_workspace, exist_ok=True)
app = make_app(Mock(), allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box'))
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
service._available = True
# Force the default_workspace to our tmp dir so _host_query_dir resolves.
- service.default_workspace = ws
- return service, ws
+ service.default_workspace = default_workspace
+ tenant_workspace = service._tenant_workspace(_CONTEXT)
+ assert tenant_workspace is not None
+ os.makedirs(tenant_workspace, exist_ok=True)
+ return service, tenant_workspace
@pytest.mark.asyncio
async def test_inbound_writes_to_host_no_exec(self, tmp_path):
@@ -1865,9 +2255,9 @@ class TestAttachmentHostPath:
assert d['type'] == 'Image'
assert d['size'] == len(big)
# File actually landed on the host workspace.
- host_file = os.path.join(ws, 'inbox', str(query.query_id), d['name'])
+ host_file = os.path.join(ws, 'inbox', str(query.query_uuid), d['name'])
assert os.path.isfile(host_file)
- assert open(host_file, 'rb').read() == big
+ assert pathlib.Path(host_file).read_bytes() == big
@pytest.mark.asyncio
async def test_inbound_host_clears_stale_query_dir(self, tmp_path):
@@ -1877,9 +2267,9 @@ class TestAttachmentHostPath:
service, ws = self._service_with_workspace(tmp_path)
# Seed a stale file under the same query_id (simulates webchat id reuse).
- stale_dir = os.path.join(ws, 'inbox', '42')
+ stale_dir = os.path.join(ws, 'inbox', 'query-42')
os.makedirs(stale_dir, exist_ok=True)
- open(os.path.join(stale_dir, 'image_1.png'), 'wb').write(b'STALE-OLD-IMAGE')
+ pathlib.Path(stale_dir, 'image_1.png').write_bytes(b'STALE-OLD-IMAGE')
new = b'\x89PNG\r\n\x1a\n NEW'
b64 = 'data:image/png;base64,' + base64.b64encode(new).decode()
@@ -1889,20 +2279,50 @@ class TestAttachmentHostPath:
descriptors = await service.materialize_inbound_attachments(query)
# The new write recreated the dir; the stale file is gone, new bytes present.
host_file = os.path.join(stale_dir, descriptors[0]['name'])
- assert open(host_file, 'rb').read() == new
+ host_bytes = pathlib.Path(host_file).read_bytes()
+ assert host_bytes == new
# No leftover content from the stale image.
- assert b'STALE-OLD-IMAGE' not in open(host_file, 'rb').read()
+ assert b'STALE-OLD-IMAGE' not in host_bytes
+
+ @pytest.mark.asyncio
+ async def test_inbound_host_replaces_query_symlink_without_touching_other_workspace(self, tmp_path):
+ import base64
+
+ import langbot_plugin.api.entities.builtin.platform.message as platform_message
+
+ service, ws = self._service_with_workspace(tmp_path)
+ other_workspace = tmp_path / 'other-workspace'
+ other_workspace.mkdir()
+ protected = other_workspace / 'protected.txt'
+ protected.write_bytes(b'workspace-b-secret')
+ inbox = os.path.join(ws, 'inbox')
+ os.makedirs(inbox, exist_ok=True)
+ os.symlink(other_workspace, os.path.join(inbox, 'query-42'))
+
+ query = make_query()
+ payload = b'workspace-a-input'
+ query.message_chain = platform_message.MessageChain(
+ [platform_message.File(name='input.bin', base64=base64.b64encode(payload).decode())]
+ )
+ service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
+
+ descriptors = await service.materialize_inbound_attachments(query)
+
+ assert descriptors[0]['path'] == '/workspace/inbox/query-42/input.bin'
+ assert protected.read_bytes() == b'workspace-b-secret'
+ assert not os.path.islink(os.path.join(inbox, 'query-42'))
+ assert pathlib.Path(inbox, 'query-42', 'input.bin').read_bytes() == payload
@pytest.mark.asyncio
async def test_outbound_reads_host_and_clears(self, tmp_path):
service, ws = self._service_with_workspace(tmp_path)
query = make_query()
- outbox = os.path.join(ws, 'outbox', str(query.query_id))
+ outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
os.makedirs(outbox, exist_ok=True)
# A large file that would be truncated on the exec/stdout path:
big_png = b'\x89PNG\r\n\x1a\n' + b'y' * (400 * 1024)
- open(os.path.join(outbox, 'result.png'), 'wb').write(big_png)
- open(os.path.join(outbox, 'notes.txt'), 'wb').write(b'hello')
+ pathlib.Path(outbox, 'result.png').write_bytes(big_png)
+ pathlib.Path(outbox, 'notes.txt').write_bytes(b'hello')
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
attachments = await service.collect_outbound_attachments(query)
@@ -1917,20 +2337,76 @@ class TestAttachmentHostPath:
# Outbox cleared after collection.
assert os.listdir(outbox) == []
+ @pytest.mark.asyncio
+ async def test_outbound_host_never_follows_query_or_file_symlinks(self, tmp_path):
+ service, ws = self._service_with_workspace(tmp_path)
+ query = make_query()
+ other_workspace = tmp_path / 'other-workspace'
+ other_workspace.mkdir()
+ secret = other_workspace / 'secret.txt'
+ secret.write_bytes(b'workspace-b-secret')
+ outbox_root = os.path.join(ws, 'outbox')
+ os.makedirs(outbox_root, exist_ok=True)
+
+ # A hostile query-directory replacement is rejected rather than read.
+ query_dir = os.path.join(outbox_root, str(query.query_uuid))
+ os.symlink(other_workspace, query_dir)
+ with pytest.raises(BoxValidationError, match='symbolic link'):
+ await service.collect_outbound_attachments(query)
+ assert secret.read_bytes() == b'workspace-b-secret'
+
+ os.unlink(query_dir)
+ os.makedirs(query_dir)
+ os.symlink(secret, os.path.join(query_dir, 'leak.txt'))
+ service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
+ assert await service.collect_outbound_attachments(query) == []
+ assert secret.read_bytes() == b'workspace-b-secret'
+
+ @pytest.mark.asyncio
+ async def test_outbound_host_fails_closed_on_inode_bomb(self, tmp_path):
+ service, ws = self._service_with_workspace(tmp_path)
+ query = make_query()
+ outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
+ os.makedirs(outbox, exist_ok=True)
+ harmless_target = tmp_path / 'harmless-target'
+ harmless_target.write_bytes(b'x')
+ # Symlinks do not count toward the 20 returned files, so this proves
+ # traversal itself has a bounded entry budget.
+ for index in range(513):
+ os.symlink(harmless_target, os.path.join(outbox, f'entry-{index}'))
+
+ with pytest.raises(BoxValidationError, match='symbolic link'):
+ await service.collect_outbound_attachments(query)
+ assert harmless_target.read_bytes() == b'x'
+
+ def test_host_attachment_directories_use_query_uuid_not_process_local_id(self, tmp_path):
+ service, _ws = self._service_with_workspace(tmp_path)
+ first = make_query(query_id=7)
+ second = make_query(query_id=7)
+ object.__setattr__(first, 'query_uuid', 'replica-a-query')
+ object.__setattr__(second, 'query_uuid', 'replica-b-query')
+
+ first_path = service._host_query_dir(service.OUTBOX_SUBDIR, first)
+ second_path = service._host_query_dir(service.OUTBOX_SUBDIR, second)
+
+ assert first_path is not None and first_path.endswith('/outbox/replica-a-query')
+ assert second_path is not None and second_path.endswith('/outbox/replica-b-query')
+ assert first_path != second_path
+
@pytest.mark.asyncio
async def test_outbound_empty_clears_stale_host_dir(self, tmp_path):
# Reusing a query_id (counter resets on restart) must not re-send files
# a previous run left in the outbox: an empty collection still clears it.
service, ws = self._service_with_workspace(tmp_path)
query = make_query()
- outbox = os.path.join(ws, 'outbox', str(query.query_id))
+ outbox = os.path.join(ws, 'outbox', str(query.query_uuid))
os.makedirs(outbox, exist_ok=True)
# Stale file from a prior turn; the agent produced nothing this turn —
# but _read_outbox_host would still pick it up, so collection must drop
# it and then wipe the dir. Simulate "nothing produced this turn" by
# treating any present file as stale and asserting it is not re-sent
# across a second, genuinely-empty collection.
- open(os.path.join(outbox, 'stale.png'), 'wb').write(b'\x89PNG\r\n\x1a\n old')
+ pathlib.Path(outbox, 'stale.png').write_bytes(b'\x89PNG\r\n\x1a\n old')
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used on host path'))
# First collection drains + clears the dir.
@@ -1952,7 +2428,7 @@ class TestAttachmentHostPath:
for sub in ('inbox', 'outbox'):
d = os.path.join(ws, sub, '0')
os.makedirs(d, exist_ok=True)
- open(os.path.join(d, 'leftover.bin'), 'wb').write(b'from a previous process')
+ pathlib.Path(d, 'leftover.bin').write_bytes(b'from a previous process')
service.execute_tool = AsyncMock(side_effect=AssertionError('exec must not be used for host-owned files'))
await service._purge_attachment_dirs()
@@ -1963,37 +2439,37 @@ class TestAttachmentHostPath:
assert os.path.isdir(ws)
@pytest.mark.asyncio
- async def test_purge_attachment_dirs_falls_back_to_exec_for_root_owned(self, tmp_path, monkeypatch):
- # When the host delete cannot remove a dir (root-owned container output),
- # purge must fall back to deleting from inside the sandbox via exec.
+ async def test_purge_attachment_dirs_never_uses_unscoped_exec_for_root_owned(self, tmp_path, monkeypatch):
+ # Startup has no trusted Workspace context. If host deletion cannot
+ # remove root-owned output, cleanup must fail closed instead of issuing
+ # an unscoped Box exec that could cross a tenant boundary.
service, ws = self._service_with_workspace(tmp_path)
outbox = os.path.join(ws, 'outbox')
os.makedirs(os.path.join(outbox, '0'), exist_ok=True)
# Simulate a host delete that cannot remove the root-owned outbox.
- import shutil as _shutil
+ from langbot.pkg.box import secure_fs
- real_rmtree = _shutil.rmtree
+ real_purge = secure_fs.purge_subdirectory
- def fake_rmtree(path, *a, **k):
- if os.path.abspath(path) == os.path.abspath(outbox):
- return # "permission denied" — silently leaves the dir
- return real_rmtree(path, *a, **k)
+ def fake_purge(root, subdir):
+ if os.path.abspath(os.path.join(root, subdir)) == os.path.abspath(outbox):
+ raise PermissionError('root-owned')
+ return real_purge(root, subdir)
- monkeypatch.setattr(_shutil, 'rmtree', fake_rmtree)
+ monkeypatch.setattr(secure_fs, 'purge_subdirectory', fake_purge)
- executed = {}
- spec_obj = object()
- service.build_spec = Mock(return_value=spec_obj)
- service.client.execute = AsyncMock(side_effect=lambda s: executed.setdefault('spec', s))
+ service.build_spec = Mock()
+ service.client.execute = AsyncMock()
await service._purge_attachment_dirs()
- # build_spec was asked to rm the surviving outbox via exec.
- cmd = service.build_spec.call_args.args[0]['cmd']
- assert 'rm -rf' in cmd and '/workspace/outbox' in cmd
- assert '/workspace/inbox' not in cmd # inbox was host-deletable
- service.client.execute.assert_awaited_once_with(spec_obj)
+ assert os.path.isdir(outbox)
+ service.build_spec.assert_not_called()
+ service.client.execute.assert_not_awaited()
+ assert any(
+ 'no trusted Workspace context' in str(call.args[0]) for call in service.ap.logger.warning.call_args_list
+ )
@pytest.mark.asyncio
async def test_purge_attachment_dirs_noop_without_workspace(self):
diff --git a/tests/unit_tests/box/test_workspace.py b/tests/unit_tests/box/test_workspace.py
index e4620ad32..48720a48a 100644
--- a/tests/unit_tests/box/test_workspace.py
+++ b/tests/unit_tests/box/test_workspace.py
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
+from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.box.workspace import (
BoxWorkspaceSession,
classify_python_workspace,
@@ -16,6 +17,13 @@ from langbot.pkg.box.workspace import (
)
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+
+
def test_rewrite_mounted_path_translates_host_prefix():
result = rewrite_mounted_path('/tmp/demo/project/app.py', '/tmp/demo/project')
assert result == '/workspace/app.py'
@@ -57,6 +65,9 @@ def test_wrap_python_command_with_env_contains_bootstrap_and_command():
assert '_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"' in command
assert '"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"' in command
assert 'kill -0 "$_LB_LOCK_OWNER"' in command
+ assert 'max_manifest_bytes = 10 * 1024 * 1024' in command
+ assert 'handle.read(1024 * 1024)' in command
+ assert 'digest.update(handle.read())' not in command
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
assert command.rstrip().endswith('python script.py')
@@ -66,6 +77,7 @@ async def test_workspace_session_execute_for_query_uses_session_payload():
box_service = SimpleNamespace(execute_spec_payload=AsyncMock(return_value={'ok': True}))
workspace = BoxWorkspaceSession(
box_service,
+ _CONTEXT,
'skill-person_123-demo',
host_path='/tmp/project',
host_path_mode='rw',
@@ -94,6 +106,7 @@ async def test_workspace_session_start_managed_process_rewrites_command_and_args
box_service = SimpleNamespace(start_managed_process=AsyncMock(return_value={'status': 'running'}))
workspace = BoxWorkspaceSession(
box_service,
+ _CONTEXT,
'mcp-u1',
host_path='/tmp/project',
host_path_mode='ro',
@@ -106,8 +119,10 @@ async def test_workspace_session_start_managed_process_rewrites_command_and_args
)
assert result == {'status': 'running'}
- session_id = box_service.start_managed_process.await_args.args[0]
- payload = box_service.start_managed_process.await_args.args[1]
+ execution_context = box_service.start_managed_process.await_args.args[0]
+ session_id = box_service.start_managed_process.await_args.args[1]
+ payload = box_service.start_managed_process.await_args.args[2]
+ assert execution_context == _CONTEXT
assert session_id == 'mcp-u1'
assert payload == {
'command': 'python',
@@ -118,9 +133,39 @@ async def test_workspace_session_start_managed_process_rewrites_command_and_args
}
+@pytest.mark.asyncio
+async def test_workspace_session_relay_connection_keeps_execution_context():
+ box_service = SimpleNamespace(
+ get_managed_process_websocket_connection=AsyncMock(
+ return_value=(
+ 'ws://box/relay',
+ {'X-LangBot-Placement-Generation': '1'},
+ )
+ )
+ )
+ workspace = BoxWorkspaceSession(
+ box_service,
+ _CONTEXT,
+ 'mcp-shared',
+ )
+
+ connection = await workspace.get_managed_process_websocket_connection('server-a')
+
+ assert connection == (
+ 'ws://box/relay',
+ {'X-LangBot-Placement-Generation': '1'},
+ )
+ box_service.get_managed_process_websocket_connection.assert_awaited_once_with(
+ _CONTEXT,
+ 'mcp-shared',
+ 'server-a',
+ )
+
+
def test_workspace_session_build_session_payload_keeps_generic_workspace_shape():
workspace = BoxWorkspaceSession(
Mock(),
+ _CONTEXT,
'workspace-1',
host_path='/tmp/project',
host_path_mode='rw',
diff --git a/tests/unit_tests/cloud/test_bootstrap.py b/tests/unit_tests/cloud/test_bootstrap.py
new file mode 100644
index 000000000..77ccefa5d
--- /dev/null
+++ b/tests/unit_tests/cloud/test_bootstrap.py
@@ -0,0 +1,380 @@
+from __future__ import annotations
+
+import dataclasses
+from types import SimpleNamespace
+
+import pytest
+
+from langbot.pkg.cloud.bootstrap import (
+ CloudBootstrapError,
+ CloudManifestRefreshService,
+ CloudRuntimeUnavailableError,
+ DeploymentAdmissionGuard,
+ OpenSourceDeployment,
+ VerifiedCloudDeployment,
+ resolve_deployment,
+)
+from langbot.pkg.cloud.entitlements import EntitlementSnapshot
+
+
+pytestmark = pytest.mark.asyncio
+
+
+class _Entitlements:
+ async def get_workspace_entitlement(self, workspace_uuid: str) -> EntitlementSnapshot:
+ return EntitlementSnapshot(
+ instance_uuid='instance-a',
+ workspace_uuid=workspace_uuid,
+ entitlement_revision=1,
+ status='active',
+ not_before=1,
+ expires_at=4_000_000_000,
+ features={'managed_sandbox': True},
+ limits={'managed_sandbox_sessions': 1},
+ )
+
+
+class _Directory:
+ async def fetch_snapshot(self, instance_uuid: str):
+ del instance_uuid
+ raise AssertionError('not used by bootstrap contract tests')
+
+ async def fetch_events(self, instance_uuid: str, after_cursor: int, limit: int):
+ del instance_uuid, after_cursor, limit
+ raise AssertionError('not used by bootstrap contract tests')
+
+ async def fetch_workspaces(self, instance_uuid: str, workspace_uuids: tuple[str, ...]):
+ del instance_uuid, workspace_uuids
+ raise AssertionError('not used by bootstrap contract tests')
+
+
+class _Manifest:
+ def __init__(self):
+ self.candidate = None
+ self.closed = False
+
+ async def refresh_manifest(self):
+ if self.candidate is None:
+ raise AssertionError('no refreshed Manifest was configured')
+ return self.candidate
+
+ async def aclose(self) -> None:
+ self.closed = True
+
+
+class _Provider:
+ def __init__(self):
+ self.manifest_provider = _Manifest()
+
+ def bootstrap(self, *, instance_uuid: str, instance_config: dict):
+ del instance_config
+ return VerifiedCloudDeployment(
+ instance_uuid=instance_uuid,
+ manifest_jti='manifest-a',
+ manifest_generation=3,
+ expires_at=4_000_000_000,
+ release='cloud-v2',
+ capabilities=frozenset({'multi_workspace_v2'}),
+ tenant_isolation_version=2,
+ entitlement_provider=_Entitlements(),
+ directory_provider=_Directory(),
+ manifest_provider=self.manifest_provider,
+ verification_key_id='root-2026',
+ )
+
+
+class _EntryPoint:
+ def __init__(self, value):
+ self.value = value
+
+ def load(self):
+ return self.value
+
+
+class _EntryPoints(list):
+ def select(self, *, group: str):
+ return self if group == 'langbot.cloud_bootstrap' else []
+
+
+def _cloud_config() -> dict:
+ return {
+ 'database': {'use': 'postgresql'},
+ 'vdb': {
+ 'use': 'pgvector',
+ 'pgvector': {
+ 'use_business_database': True,
+ 'allowed_dimensions': [384, 768, 1536],
+ },
+ },
+ 'mcp': {'stdio': {'enabled': False}},
+ 'plugin': {'worker': {'require_hard_limits': True}},
+ 'box': {
+ 'enabled': True,
+ 'backend': 'nsjail',
+ 'runtime': {'endpoint': 'ws://langbot-box:5410'},
+ 'admission': {
+ 'required': True,
+ 'logical_session_id': 'global',
+ 'required_backend': 'nsjail',
+ 'max_sessions': 1,
+ 'max_managed_processes': 0,
+ 'max_grant_ttl_sec': 300,
+ 'workspace_quota_mb': 32,
+ },
+ 'local': {
+ 'host_root': '/var/lib/langbot/box',
+ 'default_workspace': '/var/lib/langbot/box/workspaces',
+ 'allowed_mount_roots': ['/var/lib/langbot/box'],
+ },
+ },
+ # Proves mutable product metadata does not participate in selection.
+ 'system': {'edition': 'community'},
+ }
+
+
+async def test_no_closed_entry_point_selects_oss_singleton_even_if_edition_says_cloud():
+ deployment = await resolve_deployment(
+ instance_uuid='instance-a',
+ instance_config={'system': {'edition': 'cloud'}},
+ entry_points=lambda: _EntryPoints(),
+ )
+
+ assert isinstance(deployment, OpenSourceDeployment)
+ assert deployment.multi_workspace_enabled is False
+
+
+async def test_verified_closed_entry_point_activates_cloud_policy():
+ deployment = await resolve_deployment(
+ instance_uuid='instance-a',
+ instance_config=_cloud_config(),
+ entry_points=lambda: _EntryPoints([_EntryPoint(_Provider)]),
+ now=1_000,
+ )
+
+ assert isinstance(deployment, VerifiedCloudDeployment)
+ assert deployment.multi_workspace_enabled is True
+ assert deployment.persistence_mode == 'cloud_runtime'
+
+
+@pytest.mark.parametrize(
+ ('field', 'value', 'message'),
+ [
+ ('database', {'use': 'sqlite'}, 'database.use=postgresql'),
+ ('vdb', {'use': 'chroma'}, 'vdb.use=pgvector'),
+ ('mcp', {'stdio': {'enabled': True}}, 'mcp.stdio.enabled=false'),
+ ('plugin', {'worker': {'require_hard_limits': False}}, 'plugin.worker.require_hard_limits=true'),
+ ],
+)
+async def test_cloud_runtime_config_is_fail_closed(field, value, message):
+ config = _cloud_config()
+ config[field] = value
+
+ with pytest.raises(CloudBootstrapError, match=message):
+ await resolve_deployment(
+ instance_uuid='instance-a',
+ instance_config=config,
+ entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
+ now=1_000,
+ )
+
+
+@pytest.mark.parametrize(
+ ('directory_config', 'message'),
+ [
+ ({'max_active_workspaces': 0}, 'greater than or equal to 1'),
+ ({'max_active_workspaces': True}, 'must be an integer'),
+ (
+ {
+ 'max_active_workspaces': 10,
+ 'max_snapshot_workspaces': 9,
+ },
+ 'max_snapshot_workspaces',
+ ),
+ ({'max_response_bytes': 64 * 1024 * 1024 + 1}, 'less than or equal to'),
+ ],
+)
+async def test_cloud_directory_capacity_contract_is_fail_closed(directory_config, message):
+ config = _cloud_config()
+ config['cloud'] = {'directory': directory_config}
+
+ with pytest.raises(CloudBootstrapError, match=message):
+ await resolve_deployment(
+ instance_uuid='instance-a',
+ instance_config=config,
+ entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
+ now=1_000,
+ )
+
+
+@pytest.mark.parametrize(
+ ('pgvector_config', 'message'),
+ [
+ ({'use_business_database': False, 'allowed_dimensions': [1536]}, 'use_business_database=true'),
+ ({'use_business_database': True, 'allowed_dimensions': []}, 'allowed_dimensions'),
+ ({'use_business_database': True, 'allowed_dimensions': [3072]}, 'allowed_dimensions'),
+ ({'use_business_database': True, 'allowed_dimensions': [True]}, 'allowed_dimensions'),
+ ],
+)
+async def test_cloud_pgvector_contract_is_fail_closed(pgvector_config, message):
+ config = _cloud_config()
+ config['vdb']['pgvector'] = pgvector_config
+
+ with pytest.raises(CloudBootstrapError, match=message):
+ await resolve_deployment(
+ instance_uuid='instance-a',
+ instance_config=config,
+ entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
+ now=1_000,
+ )
+
+
+@pytest.mark.parametrize(
+ ('mutate', 'message'),
+ [
+ (lambda config: config['box'].update(enabled=False), 'box.enabled=true'),
+ (lambda config: config['box'].update(backend='docker'), 'box.backend=nsjail'),
+ (lambda config: config['box']['runtime'].update(endpoint=''), 'box.runtime.endpoint'),
+ (
+ lambda config: config['box']['admission'].update(max_sessions=2),
+ 'grant-enforced Box admission',
+ ),
+ (
+ lambda config: config['box']['admission'].update(max_managed_processes=1),
+ 'zero managed processes',
+ ),
+ (
+ lambda config: config['box']['admission'].update(max_grant_ttl_sec=301),
+ 'max_grant_ttl_sec',
+ ),
+ (
+ lambda config: config['box']['admission'].update(workspace_quota_mb=0),
+ 'workspace_quota_mb must be a positive integer',
+ ),
+ (
+ lambda config: config['box']['admission'].update(workspace_quota_mb=True),
+ 'workspace_quota_mb must be a positive integer',
+ ),
+ (
+ lambda config: config['box']['local'].update(default_workspace='relative/workspaces'),
+ 'default_workspace must be an absolute',
+ ),
+ (
+ lambda config: config['box']['local'].update(
+ default_workspace='/other/workspaces',
+ ),
+ 'under allowed_mount_roots',
+ ),
+ ],
+)
+async def test_cloud_box_contract_is_fail_closed(mutate, message):
+ config = _cloud_config()
+ mutate(config)
+
+ with pytest.raises(CloudBootstrapError, match=message):
+ await resolve_deployment(
+ instance_uuid='instance-a',
+ instance_config=config,
+ entry_points=lambda: _EntryPoints([_EntryPoint(_Provider())]),
+ now=1_000,
+ )
+
+
+async def test_invalid_provider_never_falls_back_to_oss():
+ provider = SimpleNamespace(bootstrap=lambda **_: object())
+
+ with pytest.raises(CloudBootstrapError, match='must return VerifiedCloudDeployment'):
+ await resolve_deployment(
+ instance_uuid='instance-a',
+ instance_config=_cloud_config(),
+ entry_points=lambda: _EntryPoints([_EntryPoint(provider)]),
+ now=1_000,
+ )
+
+
+async def test_duplicate_closed_providers_fail_closed():
+ with pytest.raises(CloudBootstrapError, match='Exactly one'):
+ await resolve_deployment(
+ instance_uuid='instance-a',
+ instance_config=_cloud_config(),
+ entry_points=lambda: _EntryPoints([_EntryPoint(_Provider()), _EntryPoint(_Provider())]),
+ now=1_000,
+ )
+
+
+async def test_deployment_admission_expires_even_after_wall_clock_rollback():
+ wall = [1_000.0]
+ monotonic = [50.0]
+ deployment = dataclasses.replace(
+ _Provider().bootstrap(instance_uuid='instance-a', instance_config={}),
+ expires_at=1_010,
+ )
+ guard = DeploymentAdmissionGuard(
+ 'instance-a',
+ deployment,
+ wall_time=lambda: wall[0],
+ monotonic_time=lambda: monotonic[0],
+ )
+
+ assert guard.require_active() is deployment
+ wall[0] = 900.0
+ monotonic[0] = 60.0
+ with pytest.raises(CloudRuntimeUnavailableError, match='expired'):
+ guard.require_active()
+
+
+async def test_deployment_admission_accepts_only_monotonic_non_conflicting_renewal():
+ wall = [1_000.0]
+ monotonic = [50.0]
+ current = dataclasses.replace(
+ _Provider().bootstrap(instance_uuid='instance-a', instance_config={}),
+ expires_at=1_010,
+ )
+ guard = DeploymentAdmissionGuard(
+ 'instance-a',
+ current,
+ wall_time=lambda: wall[0],
+ monotonic_time=lambda: monotonic[0],
+ )
+ renewed = dataclasses.replace(
+ current,
+ manifest_jti='manifest-b',
+ manifest_generation=4,
+ expires_at=2_000,
+ )
+ guard.replace(renewed)
+ assert guard.require_active() is renewed
+
+ rollback = dataclasses.replace(current, manifest_generation=2)
+ with pytest.raises(CloudRuntimeUnavailableError, match='rolled back'):
+ guard.replace(rollback)
+
+ conflicting = dataclasses.replace(renewed, manifest_jti='different')
+ with pytest.raises(CloudRuntimeUnavailableError, match='conflicting'):
+ guard.replace(conflicting)
+
+
+async def test_manifest_refresh_replaces_receipt_before_short_ttl_expires():
+ wall = [1_000.0]
+ provider = _Provider()
+ current = dataclasses.replace(
+ provider.bootstrap(instance_uuid='instance-a', instance_config={}),
+ expires_at=1_300,
+ )
+ guard = DeploymentAdmissionGuard('instance-a', current, wall_time=lambda: wall[0])
+ renewed = dataclasses.replace(
+ current,
+ manifest_jti='manifest-renewed',
+ manifest_generation=current.manifest_generation + 1,
+ expires_at=2_000,
+ )
+ provider.manifest_provider.candidate = renewed
+ service = CloudManifestRefreshService(
+ guard,
+ provider.manifest_provider,
+ SimpleNamespace(exception=lambda *_: None),
+ wall_time=lambda: wall[0],
+ )
+
+ assert service.next_refresh_delay() == 120
+ assert await service.refresh_once() is renewed
+ assert guard.deployment is renewed
diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py
new file mode 100644
index 000000000..ec11b9f74
--- /dev/null
+++ b/tests/unit_tests/cloud/test_directory_projection.py
@@ -0,0 +1,1062 @@
+from __future__ import annotations
+
+import datetime
+import logging
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from langbot.pkg.cloud.directory import (
+ DirectoryDelta,
+ DirectoryEvent,
+ DirectoryEventBatch,
+ DirectoryMember,
+ DirectoryProjectionLimits,
+ DirectoryProjectionUnavailableError,
+ DirectorySnapshot,
+ DirectoryWorkspace,
+)
+from langbot.pkg.cloud.directory_projection import DirectoryProjectionService
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.cloud_directory import DirectoryProjectionInbox, DirectoryProjectionState
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.entity.persistence.workspace import (
+ Workspace,
+ WorkspaceExecutionState,
+ WorkspaceMembership,
+)
+from langbot.pkg.persistence.tenant_uow import PersistenceScope, TenantUnitOfWork
+
+
+pytestmark = pytest.mark.asyncio
+
+INSTANCE_UUID = 'instance-directory-test'
+WORKSPACE_UUID = '10000000-0000-0000-0000-000000000001'
+SECOND_WORKSPACE_UUID = '10000000-0000-0000-0000-000000000002'
+ACCOUNT_UUID = '20000000-0000-0000-0000-000000000001'
+MEMBERSHIP_UUID = '30000000-0000-0000-0000-000000000001'
+SECOND_MEMBERSHIP_UUID = '30000000-0000-0000-0000-000000000002'
+
+
+class _Persistence:
+ def __init__(self, engine):
+ self.engine = engine
+
+ def directory_projection_uow(self, instance_uuid: str) -> TenantUnitOfWork:
+ return TenantUnitOfWork(
+ self.engine,
+ scope=PersistenceScope.directory(instance_uuid),
+ )
+
+ def get_db_engine(self):
+ return self.engine
+
+
+class _Provider:
+ def __init__(
+ self,
+ snapshots: list[DirectorySnapshot],
+ batches: list[DirectoryEventBatch] | None = None,
+ deltas: list[DirectoryDelta] | None = None,
+ ) -> None:
+ self.snapshots = snapshots
+ self.batches = list(batches or [])
+ self.deltas = list(deltas or [])
+ self.snapshot_calls = 0
+ self.delta_calls = 0
+ self.after_cursors: list[int] = []
+
+ async def fetch_snapshot(self, instance_uuid: str) -> DirectorySnapshot:
+ assert instance_uuid == INSTANCE_UUID
+ snapshot = self.snapshots[min(self.snapshot_calls, len(self.snapshots) - 1)]
+ self.snapshot_calls += 1
+ return snapshot
+
+ async def fetch_events(
+ self,
+ instance_uuid: str,
+ after_cursor: int,
+ limit: int,
+ ) -> DirectoryEventBatch:
+ assert instance_uuid == INSTANCE_UUID
+ assert limit == 100
+ self.after_cursors.append(after_cursor)
+ if self.batches:
+ return self.batches.pop(0)
+ return DirectoryEventBatch(
+ instance_uuid=instance_uuid,
+ after_cursor=after_cursor,
+ cursor=after_cursor,
+ high_water_cursor=after_cursor,
+ events=[],
+ )
+
+ async def fetch_workspaces(
+ self,
+ instance_uuid: str,
+ workspace_uuids: tuple[str, ...],
+ ) -> DirectoryDelta:
+ assert instance_uuid == INSTANCE_UUID
+ assert workspace_uuids == (WORKSPACE_UUID,)
+ delta = self.deltas[min(self.delta_calls, len(self.deltas) - 1)]
+ self.delta_calls += 1
+ return delta
+
+
+@pytest.fixture
+async def projection_context(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "directory-projection.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ application = SimpleNamespace(
+ persistence_mgr=_Persistence(engine),
+ logger=logging.getLogger('test-directory-projection'),
+ )
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
+ yield application, session_factory
+ await engine.dispose()
+
+
+def _member(*, revision: int = 1, role: str = 'member') -> DirectoryMember:
+ return DirectoryMember(
+ membership_uuid=MEMBERSHIP_UUID,
+ account_uuid=ACCOUNT_UUID,
+ normalized_email='owner@example.com',
+ display_name='Workspace Owner',
+ account_status='active',
+ role=role,
+ membership_status='active',
+ projection_revision=revision,
+ joined_at=datetime.datetime(2026, 7, 24, tzinfo=datetime.UTC),
+ )
+
+
+def _workspace(
+ *,
+ revision: int = 1,
+ status: str = 'active',
+ name: str = 'Personal Workspace',
+ role: str = 'member',
+ execution_generation: int = 1,
+) -> DirectoryWorkspace:
+ return DirectoryWorkspace(
+ uuid=WORKSPACE_UUID,
+ name=name,
+ slug='personal-workspace',
+ type='personal',
+ status=status,
+ created_by_account_uuid=ACCOUNT_UUID,
+ projection_revision=revision,
+ execution_generation=execution_generation,
+ members=[_member(revision=revision, role=role)],
+ )
+
+
+def _snapshot(
+ cursor: int,
+ *,
+ workspaces: list[DirectoryWorkspace] | None = None,
+) -> DirectorySnapshot:
+ return DirectorySnapshot(
+ instance_uuid=INSTANCE_UUID,
+ cursor=cursor,
+ generated_at=datetime.datetime(2026, 7, 24, 12, cursor % 60, tzinfo=datetime.UTC),
+ workspaces=[_workspace(revision=max(cursor, 1))] if workspaces is None else workspaces,
+ )
+
+
+def _delta(
+ *,
+ workspaces: list[DirectoryWorkspace] | None = None,
+ requested_workspace_uuids: tuple[str, ...] = (WORKSPACE_UUID,),
+) -> DirectoryDelta:
+ return DirectoryDelta(
+ instance_uuid=INSTANCE_UUID,
+ requested_workspace_uuids=requested_workspace_uuids,
+ generated_at=datetime.datetime(2026, 7, 24, 12, 30, tzinfo=datetime.UTC),
+ workspaces=[_workspace(revision=2)] if workspaces is None else workspaces,
+ )
+
+
+async def test_initial_snapshot_projects_core_owned_rows(projection_context):
+ application, session_factory = projection_context
+ reconcile_execution_projection = Mock()
+ application.tool_mgr = SimpleNamespace(
+ mcp_tool_loader=SimpleNamespace(
+ reconcile_execution_projection=reconcile_execution_projection,
+ )
+ )
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ )
+
+ await service.initialize()
+ service.require_ready()
+
+ async with session_factory() as session:
+ account = await session.scalar(sqlalchemy.select(User))
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
+ execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
+ state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
+
+ assert account is not None
+ assert account.uuid == ACCOUNT_UUID
+ assert account.source == 'cloud_projection'
+ assert account.account_type == 'space'
+ assert account.password == ''
+ assert workspace is not None
+ assert workspace.source == 'cloud_projection'
+ assert membership is not None
+ assert membership.role == 'developer'
+ assert execution is not None
+ assert execution.source == 'cloud'
+ assert execution.write_fenced is False
+ assert state is not None
+ assert state.cursor == 1
+ assert state.snapshot_coverage_cursor == 1
+ reconcile_execution_projection.assert_called_once_with(
+ INSTANCE_UUID,
+ {WORKSPACE_UUID: 1},
+ affected_workspace_uuids=None,
+ )
+
+
+async def test_same_cursor_equivocation_and_rollback_fail_closed(projection_context):
+ application, _session_factory = projection_context
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(2)]),
+ INSTANCE_UUID,
+ )
+ await service.initialize()
+
+ conflicting = _snapshot(
+ 2,
+ workspaces=[_workspace(revision=2, name='Conflicting Name')],
+ )
+ with pytest.raises(DirectoryProjectionUnavailableError, match='conflicting contents'):
+ await service.apply_snapshot(conflicting)
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='rolled back'):
+ await service.apply_snapshot(_snapshot(1))
+
+
+async def test_execution_generation_cannot_change_without_directory_revision(projection_context):
+ application, _session_factory = projection_context
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ )
+ await service.initialize()
+
+ conflicting = _snapshot(
+ 2,
+ workspaces=[_workspace(revision=1, execution_generation=2)],
+ )
+ with pytest.raises(DirectoryProjectionUnavailableError, match='execution state has conflicting contents'):
+ await service.apply_snapshot(conflicting)
+
+
+async def test_event_limit_matches_single_delta_request_limit(projection_context):
+ application, _session_factory = projection_context
+ with pytest.raises(ValueError, match='between 1 and 100'):
+ DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ event_limit=101,
+ )
+
+
+async def test_snapshot_capacity_rejects_atomically_before_projection(projection_context):
+ application, session_factory = projection_context
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ limits=DirectoryProjectionLimits(
+ max_active_workspaces=1,
+ max_snapshot_workspaces=1,
+ max_snapshot_memberships=1,
+ ),
+ )
+ second_workspace = DirectoryWorkspace(
+ uuid=SECOND_WORKSPACE_UUID,
+ name='Second Workspace',
+ slug='second-workspace',
+ type='personal',
+ status='active',
+ created_by_account_uuid='20000000-0000-0000-0000-000000000002',
+ projection_revision=1,
+ execution_generation=1,
+ members=[
+ DirectoryMember(
+ membership_uuid=SECOND_MEMBERSHIP_UUID,
+ account_uuid='20000000-0000-0000-0000-000000000002',
+ normalized_email='second@example.com',
+ display_name='Second Owner',
+ account_status='active',
+ role='owner',
+ membership_status='active',
+ projection_revision=1,
+ )
+ ],
+ )
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='Workspace capacity exceeded'):
+ await service.apply_snapshot(
+ _snapshot(
+ 1,
+ workspaces=[
+ _workspace(),
+ second_workspace,
+ ],
+ )
+ )
+
+ async with session_factory() as session:
+ assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 0
+ assert await session.get(DirectoryProjectionState, INSTANCE_UUID) is None
+
+
+async def test_incremental_capacity_rolls_back_without_advancing_cursor(projection_context):
+ application, session_factory = projection_context
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ limits=DirectoryProjectionLimits(
+ max_active_workspaces=1,
+ max_snapshot_workspaces=1,
+ max_snapshot_memberships=2,
+ ),
+ )
+ await service.initialize()
+
+ second_account_uuid = '20000000-0000-0000-0000-000000000002'
+ second_workspace = DirectoryWorkspace(
+ uuid=SECOND_WORKSPACE_UUID,
+ name='Second Workspace',
+ slug='second-workspace',
+ type='personal',
+ status='active',
+ created_by_account_uuid=second_account_uuid,
+ projection_revision=2,
+ execution_generation=1,
+ members=[
+ DirectoryMember(
+ membership_uuid=SECOND_MEMBERSHIP_UUID,
+ account_uuid=second_account_uuid,
+ normalized_email='second@example.com',
+ display_name='Second Owner',
+ account_status='active',
+ role='owner',
+ membership_status='active',
+ projection_revision=2,
+ )
+ ],
+ )
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000002',
+ aggregate_uuid=SECOND_WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={
+ 'workspace_uuid': SECOND_WORKSPACE_UUID,
+ 'directory_revision': 2,
+ },
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[event],
+ )
+ delta = DirectoryDelta(
+ instance_uuid=INSTANCE_UUID,
+ requested_workspace_uuids=[SECOND_WORKSPACE_UUID],
+ generated_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ workspaces=[second_workspace],
+ )
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='Projected active Workspace capacity exceeded'):
+ await service.apply_delta(delta, batch)
+
+ async with session_factory() as session:
+ assert await session.get(Workspace, SECOND_WORKSPACE_UUID) is None
+ state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
+ assert state is not None
+ assert state.cursor == 1
+ assert service.resource_snapshot()['active_workspaces'] == 1
+
+
+async def test_account_projection_reads_large_directory_in_bounded_batches(projection_context):
+ application, session_factory = projection_context
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ )
+ workspaces = []
+ for number in range(501):
+ account_uuid = f'20000000-0000-0000-0000-{number:012d}'
+ workspaces.append(
+ DirectoryWorkspace(
+ uuid=f'10000000-0000-0000-0000-{number:012d}',
+ name=f'Workspace {number}',
+ slug=f'workspace-{number}',
+ type='personal',
+ status='active',
+ created_by_account_uuid=account_uuid,
+ projection_revision=1,
+ execution_generation=1,
+ members=[
+ DirectoryMember(
+ membership_uuid=f'30000000-0000-0000-0000-{number:012d}',
+ account_uuid=account_uuid,
+ normalized_email=f'owner-{number}@example.com',
+ display_name=f'Owner {number}',
+ account_status='active',
+ role='owner',
+ membership_status='active',
+ projection_revision=1,
+ )
+ ],
+ )
+ )
+ snapshot = _snapshot(1, workspaces=workspaces)
+ user_selects = 0
+
+ def count_user_selects(_connection, _cursor, statement, _parameters, _context, _executemany):
+ nonlocal user_selects
+ if statement.lstrip().upper().startswith('SELECT') and 'users' in statement:
+ user_selects += 1
+
+ engine = application.persistence_mgr.engine
+ sqlalchemy.event.listen(engine.sync_engine, 'before_cursor_execute', count_user_selects)
+ try:
+ async with application.persistence_mgr.directory_projection_uow(INSTANCE_UUID) as uow:
+ projected = await service._apply_accounts(uow.session, snapshot)
+ finally:
+ sqlalchemy.event.remove(engine.sync_engine, 'before_cursor_execute', count_user_selects)
+
+ assert len(projected) == 501
+ assert user_selects == 2
+ async with session_factory() as session:
+ assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(User)) == 501
+
+
+async def test_archived_and_absent_workspaces_are_execution_fenced(projection_context):
+ application, session_factory = projection_context
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ )
+ await service.initialize()
+
+ await service.apply_snapshot(
+ _snapshot(
+ 2,
+ workspaces=[_workspace(revision=2, status='archived')],
+ )
+ )
+ async with session_factory() as session:
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
+ membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
+ assert workspace.status == 'archived'
+ assert execution.state == 'inactive'
+ assert execution.write_fenced is True
+ assert membership.status == 'removed'
+
+ await service.apply_snapshot(_snapshot(3, workspaces=[]))
+ async with session_factory() as session:
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
+ assert workspace.status == 'archived'
+ assert execution.state == 'inactive'
+ assert execution.write_fenced is True
+
+
+async def test_event_poll_fetches_workspace_delta_and_records_receipt(projection_context):
+ application, session_factory = projection_context
+ reconcile_execution_projection = Mock()
+ application.tool_mgr = SimpleNamespace(
+ mcp_tool_loader=SimpleNamespace(
+ reconcile_execution_projection=reconcile_execution_projection,
+ )
+ )
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000001',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[event],
+ )
+ provider = _Provider(
+ [_snapshot(1)],
+ [batch],
+ [_delta(workspaces=[_workspace(revision=2, name='Updated Workspace')])],
+ )
+ service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
+ await service.initialize()
+ reconcile_execution_projection.reset_mock()
+
+ await service.sync_once()
+
+ async with session_factory() as session:
+ account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
+ inbox = await session.scalar(sqlalchemy.select(DirectoryProjectionInbox))
+ assert account.projection_revision == 1
+ assert workspace.name == 'Updated Workspace'
+ assert state.cursor == 2
+ assert inbox.event_uuid == event.uuid
+ assert inbox.applied_at is not None
+ assert provider.snapshot_calls == 1
+ assert provider.delta_calls == 1
+ reconcile_execution_projection.assert_called_once_with(
+ INSTANCE_UUID,
+ {WORKSPACE_UUID: 1},
+ affected_workspace_uuids={WORKSPACE_UUID},
+ )
+
+
+async def test_directory_delta_does_not_skip_unfetched_event_cursors(projection_context):
+ application, session_factory = projection_context
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000003',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=3,
+ events=[event],
+ )
+ provider = _Provider(
+ [_snapshot(1)],
+ [batch],
+ [_delta(workspaces=[_workspace(revision=3, name='Latest Workspace')])],
+ )
+ service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
+ await service.initialize()
+
+ await service.sync_once()
+
+ async with session_factory() as session:
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
+ assert workspace.name == 'Latest Workspace'
+ assert state.cursor == 2
+
+
+async def test_entitlement_event_advances_cursor_without_refetching_directory(projection_context):
+ application, session_factory = projection_context
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000002',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='entitlement.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'entitlement_revision': 2},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[event],
+ )
+ provider = _Provider([_snapshot(1)], [batch])
+ service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
+ await service.initialize()
+
+ await service.sync_once()
+ await service.apply_snapshot(_snapshot(2, workspaces=[_workspace(revision=1)]))
+
+ async with session_factory() as session:
+ state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
+ inbox = await session.scalar(sqlalchemy.select(DirectoryProjectionInbox))
+ assert provider.snapshot_calls == 1
+ assert state.cursor == 2
+ assert inbox.event_uuid == event.uuid
+ assert inbox.applied_at is not None
+
+
+async def test_missing_workspace_in_delta_fences_only_requested_workspace(projection_context):
+ application, session_factory = projection_context
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000004',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[event],
+ )
+ provider = _Provider([_snapshot(1)], [batch], [_delta(workspaces=[])])
+ service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
+ await service.initialize()
+
+ await service.sync_once()
+
+ async with session_factory() as session:
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
+ membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
+ assert workspace.status == 'archived'
+ assert workspace.projection_revision == 2
+ assert execution.state == 'inactive'
+ assert execution.write_fenced is True
+ assert execution.desired_state_revision == 2
+ assert membership.status == 'removed'
+ assert membership.projection_revision == 2
+
+ stale_same_cursor_snapshot = _snapshot(
+ 2,
+ workspaces=[_workspace(revision=2, status='active')],
+ )
+ with pytest.raises(DirectoryProjectionUnavailableError, match='conflicting contents'):
+ await service.apply_snapshot(stale_same_cursor_snapshot)
+ async with session_factory() as session:
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
+ assert workspace.status == 'archived'
+ assert execution.write_fenced is True
+
+
+async def test_each_replica_consumes_events_with_its_own_cursor(projection_context):
+ application, session_factory = projection_context
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000005',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[event],
+ )
+ updated = _delta(workspaces=[_workspace(revision=2, name='Replica-safe Workspace')])
+ first_provider = _Provider([_snapshot(1)], [batch], [updated])
+ second_provider = _Provider([_snapshot(1)], [batch], [updated])
+ first = DirectoryProjectionService(application, first_provider, INSTANCE_UUID)
+ second = DirectoryProjectionService(application, second_provider, INSTANCE_UUID)
+ await first.initialize()
+ await second.initialize()
+
+ await first.sync_once()
+ await second.sync_once()
+ await second.sync_once()
+
+ async with session_factory() as session:
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
+ inbox_rows = (await session.scalars(sqlalchemy.select(DirectoryProjectionInbox))).all()
+ assert workspace.name == 'Replica-safe Workspace'
+ assert state.cursor == 2
+ assert len(inbox_rows) == 1
+ assert first_provider.after_cursors == [1]
+ assert second_provider.after_cursors == [1, 2]
+
+
+async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
+ application, session_factory = projection_context
+ event_two = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000008',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ event_three = DirectoryEvent(
+ cursor=3,
+ uuid='40000000-0000-0000-0000-000000000009',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=3,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 3},
+ created_at=datetime.datetime(2026, 7, 24, 12, 3, tzinfo=datetime.UTC),
+ )
+ batch_two = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=3,
+ events=[event_two],
+ )
+ batch_three = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=2,
+ cursor=3,
+ high_water_cursor=3,
+ events=[event_three],
+ )
+ lagging_provider = _Provider(
+ [_snapshot(1)],
+ [batch_two, batch_three],
+ [
+ _delta(workspaces=[_workspace(revision=2, name='Intermediate Workspace')]),
+ _delta(workspaces=[_workspace(revision=3, name='Current Workspace')]),
+ ],
+ )
+ lagging = DirectoryProjectionService(application, lagging_provider, INSTANCE_UUID)
+ leading = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(3, workspaces=[_workspace(revision=3, name='Current Workspace')])]),
+ INSTANCE_UUID,
+ )
+ await lagging.initialize()
+ await leading.initialize()
+
+ await lagging.sync_once()
+ await lagging.sync_once()
+ lagging.require_ready()
+
+ async with session_factory() as session:
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
+ inbox_rows = (
+ await session.scalars(sqlalchemy.select(DirectoryProjectionInbox).order_by(DirectoryProjectionInbox.cursor))
+ ).all()
+ assert workspace.name == 'Current Workspace'
+ assert state.cursor == 3
+ assert state.snapshot_coverage_cursor == 3
+ assert [row.cursor for row in inbox_rows] == [2, 3]
+ assert lagging_provider.after_cursors == [1, 2]
+
+
+async def test_initialize_retries_snapshot_superseded_by_another_replica(projection_context):
+ application, _session_factory = projection_context
+ leading = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(2)]),
+ INSTANCE_UUID,
+ )
+ await leading.initialize()
+ racing_provider = _Provider([_snapshot(1), _snapshot(2)])
+ racing = DirectoryProjectionService(application, racing_provider, INSTANCE_UUID)
+
+ await racing.initialize()
+ await racing.sync_once()
+ racing.require_ready()
+
+ assert racing_provider.snapshot_calls == 2
+ assert racing_provider.after_cursors == [2]
+
+
+async def test_page_below_signed_high_water_does_not_renew_readiness(projection_context):
+ application, _session_factory = projection_context
+ monotonic = [10.0]
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000010',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=3,
+ events=[event],
+ )
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)], [batch], [_delta()]),
+ INSTANCE_UUID,
+ sync_interval_seconds=5,
+ max_staleness_seconds=60,
+ monotonic_time=lambda: monotonic[0],
+ )
+ await service.initialize()
+ await service.sync_once()
+
+ monotonic[0] = 70.0
+ with pytest.raises(DirectoryProjectionUnavailableError, match='stale'):
+ service.require_ready()
+
+
+async def test_empty_page_cannot_hide_shared_projection_progress(projection_context):
+ application, _session_factory = projection_context
+ lagging = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ )
+ leading = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(2)]),
+ INSTANCE_UUID,
+ )
+ await lagging.initialize()
+ await leading.initialize()
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='has not consumed'):
+ await lagging.sync_once()
+
+
+async def test_event_payload_revision_mismatch_fails_closed(projection_context):
+ application, _session_factory = projection_context
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000006',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 3},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[event],
+ )
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)], [batch], [_delta()]),
+ INSTANCE_UUID,
+ )
+ await service.initialize()
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='conflicting revision'):
+ await service.sync_once()
+
+
+async def test_workspace_delta_older_than_event_fails_closed(projection_context):
+ application, _session_factory = projection_context
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000007',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=3,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 3},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[event],
+ )
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)], [batch], [_delta(workspaces=[_workspace(revision=2)])]),
+ INSTANCE_UUID,
+ )
+ await service.initialize()
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='older'):
+ await service.sync_once()
+
+
+async def test_stale_workspace_tombstone_revision_fails_closed(projection_context):
+ application, session_factory = projection_context
+ event = DirectoryEvent(
+ cursor=2,
+ uuid='40000000-0000-0000-0000-000000000011',
+ aggregate_uuid=WORKSPACE_UUID,
+ event_type='directory.changed',
+ revision=2,
+ payload={'workspace_uuid': WORKSPACE_UUID, 'directory_revision': 2},
+ created_at=datetime.datetime(2026, 7, 24, 12, 2, tzinfo=datetime.UTC),
+ )
+ batch = DirectoryEventBatch(
+ instance_uuid=INSTANCE_UUID,
+ after_cursor=1,
+ cursor=2,
+ high_water_cursor=2,
+ events=[event],
+ )
+ service = DirectoryProjectionService(
+ application,
+ _Provider(
+ [_snapshot(1, workspaces=[_workspace(revision=3)])],
+ [batch],
+ [_delta(workspaces=[])],
+ ),
+ INSTANCE_UUID,
+ )
+ await service.initialize()
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='tombstone revision rolled back'):
+ await service.sync_once()
+
+ async with session_factory() as session:
+ workspace = await session.get(Workspace, WORKSPACE_UUID)
+ membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
+ execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
+ assert workspace.status == 'active'
+ assert membership.status == 'active'
+ assert execution.write_fenced is False
+
+
+async def test_conflicting_account_projection_across_workspaces_fails_closed(projection_context):
+ application, _session_factory = projection_context
+ conflicting_member = DirectoryMember(
+ membership_uuid=SECOND_MEMBERSHIP_UUID,
+ account_uuid=ACCOUNT_UUID,
+ normalized_email='owner@example.com',
+ display_name='Workspace Owner',
+ account_status='blocked',
+ role='member',
+ membership_status='active',
+ projection_revision=99,
+ joined_at=datetime.datetime(2026, 7, 24, tzinfo=datetime.UTC),
+ )
+ second_workspace = DirectoryWorkspace(
+ uuid=SECOND_WORKSPACE_UUID,
+ name='Second Workspace',
+ slug='second-workspace',
+ type='team',
+ status='active',
+ created_by_account_uuid=ACCOUNT_UUID,
+ projection_revision=99,
+ execution_generation=1,
+ members=[conflicting_member],
+ )
+ snapshot = _snapshot(
+ 1,
+ workspaces=[_workspace(revision=1), second_workspace],
+ )
+ service = DirectoryProjectionService(application, _Provider([snapshot]), INSTANCE_UUID)
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='conflicting account projections'):
+ await service.initialize()
+
+
+async def test_account_projection_revision_advances_when_account_fields_change(projection_context):
+ application, session_factory = projection_context
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ )
+ await service.initialize()
+ blocked_member = _member(revision=2).model_copy(update={'account_status': 'blocked'})
+ blocked_workspace = _workspace(revision=2).model_copy(update={'members': (blocked_member,)})
+
+ await service.apply_snapshot(_snapshot(2, workspaces=[blocked_workspace]))
+
+ async with session_factory() as session:
+ account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
+ assert account.status == 'disabled'
+ assert account.projection_revision == 2
+
+
+async def test_readiness_expires_on_monotonic_staleness(projection_context):
+ application, _session_factory = projection_context
+ monotonic = [10.0]
+ service = DirectoryProjectionService(
+ application,
+ _Provider([_snapshot(1)]),
+ INSTANCE_UUID,
+ sync_interval_seconds=5,
+ max_staleness_seconds=60,
+ monotonic_time=lambda: monotonic[0],
+ )
+ await service.initialize()
+ service.require_ready()
+
+ monotonic[0] = 70.0
+ with pytest.raises(DirectoryProjectionUnavailableError, match='stale'):
+ service.require_ready()
+
+
+async def test_snapshot_for_another_instance_is_rejected(projection_context):
+ application, _session_factory = projection_context
+ other = _snapshot(1).model_copy(update={'instance_uuid': 'other-instance'})
+ service = DirectoryProjectionService(application, _Provider([other]), INSTANCE_UUID)
+
+ with pytest.raises(DirectoryProjectionUnavailableError, match='another LangBot instance'):
+ await service.initialize()
+
+
+async def test_core_owned_membership_survives_directory_updates_and_omission(projection_context):
+ application, session_factory = projection_context
+ service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID)
+ await service.initialize()
+
+ async with session_factory() as session:
+ async with session.begin():
+ membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
+ membership.role = 'viewer'
+ membership.status = 'active'
+ membership.projection_revision = 0
+ session.add(
+ WorkspaceMembership(
+ uuid=SECOND_MEMBERSHIP_UUID,
+ workspace_uuid=WORKSPACE_UUID,
+ account_uuid='20000000-0000-0000-0000-000000000099',
+ role='viewer',
+ status='active',
+ joined_at=membership.joined_at,
+ projection_revision=0,
+ )
+ )
+
+ projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'})
+ projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)})
+ await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace]))
+
+ async with session_factory() as session:
+ memberships = {
+ membership.uuid: membership
+ for membership in (await session.scalars(sqlalchemy.select(WorkspaceMembership))).all()
+ }
+ assert memberships[MEMBERSHIP_UUID].role == 'viewer'
+ assert memberships[MEMBERSHIP_UUID].status == 'active'
+ assert memberships[MEMBERSHIP_UUID].projection_revision == 0
+ assert memberships[SECOND_MEMBERSHIP_UUID].status == 'active'
+ assert memberships[SECOND_MEMBERSHIP_UUID].projection_revision == 0
diff --git a/tests/unit_tests/cloud/test_entitlements.py b/tests/unit_tests/cloud/test_entitlements.py
new file mode 100644
index 000000000..9268f8a13
--- /dev/null
+++ b/tests/unit_tests/cloud/test_entitlements.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+from unittest.mock import AsyncMock
+
+from langbot.pkg.cloud.entitlements import EntitlementResolver, EntitlementSnapshot, EntitlementUnavailableError
+
+
+def _snapshot(**overrides) -> EntitlementSnapshot:
+ values = {
+ 'instance_uuid': 'instance-a',
+ 'workspace_uuid': 'workspace-a',
+ 'entitlement_revision': 7,
+ 'status': 'active',
+ 'not_before': 100,
+ 'expires_at': 200,
+ 'features': {'managed_sandbox': True, 'mcp_stdio': False},
+ 'limits': {'managed_sandbox_sessions': 1},
+ }
+ values.update(overrides)
+ return EntitlementSnapshot(**values)
+
+
+def test_active_snapshot_exposes_only_generic_features_and_limits():
+ snapshot = _snapshot().require_active(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ now=150,
+ )
+
+ snapshot.require_feature('managed_sandbox')
+ assert snapshot.limit('managed_sandbox_sessions') == 1
+ assert 'plan' not in snapshot.model_fields
+
+
+@pytest.mark.parametrize(
+ 'snapshot,now',
+ [
+ (_snapshot(status='suspended'), 150),
+ (_snapshot(), 99),
+ (_snapshot(), 200),
+ ],
+)
+def test_inactive_or_expired_snapshot_fails_closed(snapshot, now):
+ with pytest.raises(EntitlementUnavailableError):
+ snapshot.require_active(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ now=now,
+ )
+
+
+def test_scope_mismatch_fails_closed():
+ with pytest.raises(EntitlementUnavailableError, match='scope'):
+ _snapshot().require_active(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-b',
+ now=150,
+ )
+
+
+@pytest.mark.asyncio
+async def test_resolver_rejects_revision_rollback():
+ provider = AsyncMock()
+ provider.get_workspace_entitlement = AsyncMock(side_effect=[_snapshot(), _snapshot(entitlement_revision=6)])
+ resolver = EntitlementResolver('instance-a', provider)
+
+ await resolver.resolve('workspace-a', now=150)
+ with pytest.raises(EntitlementUnavailableError, match='rolled back'):
+ await resolver.resolve('workspace-a', now=150)
+
+
+@pytest.mark.asyncio
+async def test_resolver_rejects_same_revision_with_different_contents():
+ provider = AsyncMock()
+ provider.get_workspace_entitlement = AsyncMock(
+ side_effect=[
+ _snapshot(),
+ _snapshot(features={'managed_sandbox': False}),
+ ]
+ )
+ resolver = EntitlementResolver('instance-a', provider)
+
+ await resolver.resolve('workspace-a', now=150)
+ with pytest.raises(EntitlementUnavailableError, match='conflicting contents'):
+ await resolver.resolve('workspace-a', now=150)
+
+
+@pytest.mark.asyncio
+async def test_resolver_checks_deployment_admission_before_and_after_provider_call():
+ checks = 0
+
+ def require_admission() -> None:
+ nonlocal checks
+ checks += 1
+ if checks == 2:
+ raise RuntimeError('manifest expired during provider call')
+
+ provider = AsyncMock()
+ provider.get_workspace_entitlement = AsyncMock(return_value=_snapshot())
+ resolver = EntitlementResolver(
+ 'instance-a',
+ provider,
+ deployment_admission=require_admission,
+ )
+
+ with pytest.raises(RuntimeError, match='expired during provider call'):
+ await resolver.resolve('workspace-a', now=150)
+ assert checks == 2
+
+
+@pytest.mark.asyncio
+async def test_directory_activity_reconciliation_drops_historical_snapshots():
+ provider = AsyncMock()
+ provider.get_workspace_entitlement = AsyncMock(return_value=_snapshot())
+ resolver = EntitlementResolver('instance-a', provider)
+ await resolver.reconcile_active_workspaces({'workspace-a', 'workspace-b'})
+ await resolver.resolve('workspace-a', now=150)
+
+ await resolver.reconcile_active_workspaces({'workspace-b'})
+
+ assert resolver.snapshot_counts() == {
+ 'active_workspaces': 1,
+ 'cached_snapshots': 0,
+ }
+ with pytest.raises(EntitlementUnavailableError, match='directory projection'):
+ await resolver.resolve('workspace-a', now=150)
+ provider.get_workspace_entitlement.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_directory_fence_wins_race_with_inflight_entitlement_fetch():
+ provider_started = asyncio.Event()
+ release_provider = asyncio.Event()
+
+ async def fetch(_workspace_uuid: str) -> EntitlementSnapshot:
+ provider_started.set()
+ await release_provider.wait()
+ return _snapshot()
+
+ provider = AsyncMock()
+ provider.get_workspace_entitlement = AsyncMock(side_effect=fetch)
+ resolver = EntitlementResolver('instance-a', provider)
+ await resolver.reconcile_active_workspaces({'workspace-a'})
+ resolve_task = asyncio.create_task(resolver.resolve('workspace-a', now=150))
+ await provider_started.wait()
+
+ await resolver.update_workspace_activity(
+ active_workspace_uuids=set(),
+ inactive_workspace_uuids={'workspace-a'},
+ )
+ release_provider.set()
+
+ with pytest.raises(EntitlementUnavailableError, match='directory projection'):
+ await resolve_task
+ assert resolver.snapshot_counts() == {
+ 'active_workspaces': 0,
+ 'cached_snapshots': 0,
+ }
diff --git a/tests/unit_tests/cloud/test_space_launch.py b/tests/unit_tests/cloud/test_space_launch.py
new file mode 100644
index 000000000..14b6b7f17
--- /dev/null
+++ b/tests/unit_tests/cloud/test_space_launch.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import base64
+import json
+import time
+import uuid
+from types import SimpleNamespace
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
+
+
+pytestmark = pytest.mark.asyncio
+
+
+INSTANCE_UUID = 'instance-test'
+ACCOUNT_UUID = '11111111-1111-4111-8111-111111111111'
+WORKSPACE_UUID = '22222222-2222-4222-8222-222222222222'
+KEY_ID = 'space-key-1'
+
+
+def _base64url(raw: bytes) -> str:
+ return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
+
+
+def _sign(private_key: Ed25519PrivateKey, claims: dict, *, key_id: str = KEY_ID) -> str:
+ header = {'alg': 'EdDSA', 'kid': key_id, 'typ': 'langbot-control-plane+jwt'}
+ encoded_header = _base64url(json.dumps(header, separators=(',', ':')).encode('utf-8'))
+ encoded_claims = _base64url(json.dumps(claims, separators=(',', ':')).encode('utf-8'))
+ signing_input = f'{encoded_header}.{encoded_claims}'
+ return f'{signing_input}.{_base64url(private_key.sign(signing_input.encode("ascii")))}'
+
+
+def _claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE_UUID) -> dict:
+ return {
+ 'iss': 'langbot-space',
+ 'aud': 'langbot-cloud-runtime',
+ 'sub': f'langbot-instance:{INSTANCE_UUID}',
+ 'jti': jti or str(uuid.uuid4()),
+ 'iat': now,
+ 'nbf': now - 5,
+ 'exp': now + 90,
+ 'instance_uuid': INSTANCE_UUID,
+ 'kind': 'workspace.launch',
+ 'payload': {
+ 'account_uuid': ACCOUNT_UUID,
+ 'workspace_uuid': workspace_uuid,
+ },
+ }
+
+
+def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
+ public_key = private_key.public_key().public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw,
+ )
+ app = SimpleNamespace(
+ deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
+ workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
+ instance_config=SimpleNamespace(
+ data={
+ 'space': {
+ 'launch': {
+ 'control_plane_public_key': _base64url(public_key),
+ }
+ }
+ }
+ ),
+ )
+ return SpaceLaunchService(app, wall_time=lambda: now)
+
+
+async def test_consumes_valid_workspace_launch_assertion_once():
+ private_key = Ed25519PrivateKey.generate()
+ now = int(time.time())
+ service = _service(private_key, now=now)
+ token = _sign(private_key, _claims(now=now))
+
+ launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
+
+ assert launch == {'account_uuid': ACCOUNT_UUID, 'workspace_uuid': WORKSPACE_UUID}
+ with pytest.raises(SpaceLaunchError, match='already been consumed'):
+ await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
+
+
+async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch):
+ private_key = Ed25519PrivateKey.generate()
+ now = int(time.time())
+ service = _service(private_key, now=now)
+ for index in range(512):
+ await service._consume_jti(f'jti-{index}', now + 90)
+
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('replay admission scanned all live assertions')
+
+ def keys(self):
+ raise AssertionError('replay admission scanned all live assertions')
+
+ def items(self):
+ raise AssertionError('replay admission scanned all live assertions')
+
+ def values(self):
+ raise AssertionError('replay admission scanned all live assertions')
+
+ guarded_jtis = NoGlobalIterationDict(service._consumed_jtis)
+ monkeypatch.setattr(service, '_consumed_jtis', guarded_jtis)
+
+ await service._consume_jti('jti-new', now + 90)
+
+ assert len(guarded_jtis) == 513
+
+
+async def test_replay_cache_fails_closed_at_capacity(monkeypatch):
+ from langbot.pkg.cloud import launch
+
+ private_key = Ed25519PrivateKey.generate()
+ now = int(time.time())
+ service = _service(private_key, now=now)
+ monkeypatch.setattr(launch, '_CONSUMED_JTI_MAX_ENTRIES', 2)
+ await service._consume_jti('jti-1', now + 90)
+ await service._consume_jti('jti-2', now + 90)
+
+ with pytest.raises(SpaceLaunchError, match='replay cache capacity'):
+ await service._consume_jti('jti-3', now + 90)
+ with pytest.raises(SpaceLaunchError, match='already been consumed'):
+ await service._consume_jti('jti-1', now + 90)
+
+
+async def test_rejects_expired_wrong_workspace_and_wrong_instance_assertions():
+ private_key = Ed25519PrivateKey.generate()
+ now = int(time.time())
+ service = _service(private_key, now=now)
+
+ expired = _claims(now=now)
+ expired['exp'] = now - 60
+ with pytest.raises(SpaceLaunchError, match='expired'):
+ await service.consume_assertion(_sign(private_key, expired), expected_workspace_uuid=WORKSPACE_UUID)
+
+ wrong_workspace = _sign(private_key, _claims(now=now, workspace_uuid='33333333-3333-4333-8333-333333333333'))
+ with pytest.raises(SpaceLaunchError, match='another Workspace'):
+ await service.consume_assertion(wrong_workspace, expected_workspace_uuid=WORKSPACE_UUID)
+
+ wrong_instance = _claims(now=now)
+ wrong_instance['instance_uuid'] = 'other-instance'
+ with pytest.raises(SpaceLaunchError, match='instance UUID'):
+ await service.consume_assertion(_sign(private_key, wrong_instance), expected_workspace_uuid=WORKSPACE_UUID)
+
+
+async def test_rejects_invalid_signature_and_non_cloud_mode():
+ private_key = Ed25519PrivateKey.generate()
+ now = int(time.time())
+ token = _sign(private_key, _claims(now=now))
+ service = _service(Ed25519PrivateKey.generate(), now=now)
+ with pytest.raises(SpaceLaunchError, match='signature'):
+ await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
+
+ oss_service = _service(private_key, now=now)
+ oss_service.ap.deployment.multi_workspace_enabled = False
+ with pytest.raises(SpaceLaunchError, match='verified Cloud mode'):
+ await oss_service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
diff --git a/tests/unit_tests/core/test_app_resource_maintenance.py b/tests/unit_tests/core/test_app_resource_maintenance.py
new file mode 100644
index 000000000..8ba094d1f
--- /dev/null
+++ b/tests/unit_tests/core/test_app_resource_maintenance.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from langbot.pkg.core.app import Application
+
+
+pytestmark = pytest.mark.asyncio
+
+
+class _TaskManager:
+ def __init__(self, stop: asyncio.Event) -> None:
+ self.stop = stop
+ self.tasks: list[asyncio.Task] = []
+
+ def create_task(self, coro, *, name='', **_kwargs):
+ task = asyncio.create_task(coro, name=name)
+ self.tasks.append(task)
+ return SimpleNamespace(task=task)
+
+ async def wait_all(self) -> None:
+ await self.stop.wait()
+ for task in self.tasks:
+ task.cancel()
+ await asyncio.gather(*self.tasks, return_exceptions=True)
+
+
+async def _wait_forever() -> None:
+ await asyncio.Event().wait()
+
+
+async def test_resource_maintenance_waits_and_shares_workspace_discovery() -> None:
+ stop = asyncio.Event()
+ completed = asyncio.Event()
+ discovery_calls = 0
+ job_calls: list[str] = []
+
+ async def list_bindings():
+ nonlocal discovery_calls
+ discovery_calls += 1
+ return [
+ SimpleNamespace(
+ instance_uuid='instance',
+ workspace_uuid='workspace',
+ placement_generation=1,
+ )
+ ]
+
+ async def cleanup_monitoring(_context, _retention_days, *, batch_size):
+ assert batch_size == 10
+ job_calls.append('monitoring')
+ return {}
+
+ async def cleanup_storage(_context):
+ job_calls.append('storage')
+ completed.set()
+ return {}
+
+ application = Application()
+ application.event_loop = asyncio.get_running_loop()
+ application.event_loop_monitor = SimpleNamespace(start=lambda: None)
+ application.task_mgr = _TaskManager(stop)
+ application.plugin_connector = SimpleNamespace(initialize_plugins=lambda: asyncio.sleep(0))
+ application.platform_mgr = SimpleNamespace(run=_wait_forever)
+ application.ctrl = SimpleNamespace(run=_wait_forever)
+ application.http_ctrl = SimpleNamespace(run=_wait_forever)
+ application.telemetry = None
+ application.workspace_collaboration_service = None
+ application.workspace_service = SimpleNamespace(list_active_execution_bindings=list_bindings)
+ application.monitoring_service = SimpleNamespace(cleanup_expired_records=cleanup_monitoring)
+ application.maintenance_service = SimpleNamespace(cleanup_expired_files=cleanup_storage)
+ application.instance_config = SimpleNamespace(
+ data={
+ 'monitoring': {
+ 'auto_cleanup': {
+ 'enabled': True,
+ 'retention_days': 30,
+ 'delete_batch_size': 10,
+ 'check_interval_hours': 0.00002,
+ }
+ },
+ 'storage': {
+ 'cleanup': {
+ 'enabled': True,
+ 'check_interval_hours': 0.00002,
+ }
+ },
+ }
+ )
+ application.logger = SimpleNamespace(
+ info=lambda *_args, **_kwargs: None,
+ warning=lambda *_args, **_kwargs: None,
+ error=lambda *_args, **_kwargs: None,
+ debug=lambda *_args, **_kwargs: None,
+ )
+
+ async def no_web_info() -> None:
+ return None
+
+ application.print_web_access_info = no_web_info
+ run_task = asyncio.create_task(application.run())
+ try:
+ await asyncio.sleep(0.01)
+ assert discovery_calls == 0
+ await asyncio.wait_for(completed.wait(), timeout=1)
+ assert discovery_calls == 1
+ assert job_calls == ['monitoring', 'storage']
+ finally:
+ stop.set()
+ await asyncio.wait_for(run_task, timeout=1)
diff --git a/tests/unit_tests/core/test_app_shutdown.py b/tests/unit_tests/core/test_app_shutdown.py
new file mode 100644
index 000000000..e284f5000
--- /dev/null
+++ b/tests/unit_tests/core/test_app_shutdown.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from langbot.pkg.core.app import Application
+
+
+@pytest.mark.asyncio
+async def test_shutdown_closes_mcp_session_manager_once() -> None:
+ app = Application()
+ stop_session_manager = AsyncMock()
+ app.platform_mgr = SimpleNamespace(shutdown=AsyncMock())
+ app.tool_mgr = SimpleNamespace(shutdown=AsyncMock())
+ app.model_mgr = SimpleNamespace(shutdown=AsyncMock())
+ app.box_service = SimpleNamespace(shutdown=AsyncMock())
+ app.plugin_connector = SimpleNamespace(aclose=AsyncMock())
+ app.telemetry = SimpleNamespace(shutdown=AsyncMock())
+ app.vector_db_mgr = SimpleNamespace(shutdown=AsyncMock())
+ app.storage_mgr = SimpleNamespace(shutdown=AsyncMock())
+ manifest_provider = SimpleNamespace(aclose=AsyncMock())
+ app.deployment = SimpleNamespace(manifest_provider=manifest_provider)
+ persistence_engine = SimpleNamespace(dispose=AsyncMock())
+ app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=persistence_engine))
+ app.http_ctrl = SimpleNamespace(mcp_mount=SimpleNamespace(stop_session_manager=stop_session_manager))
+
+ await app.shutdown()
+ await app.shutdown()
+
+ stop_session_manager.assert_awaited_once()
+ app.platform_mgr.shutdown.assert_awaited_once()
+ app.tool_mgr.shutdown.assert_awaited_once()
+ app.model_mgr.shutdown.assert_awaited_once()
+ app.box_service.shutdown.assert_awaited_once()
+ app.plugin_connector.aclose.assert_awaited_once()
+ app.telemetry.shutdown.assert_awaited_once()
+ app.vector_db_mgr.shutdown.assert_awaited_once()
+ app.storage_mgr.shutdown.assert_awaited_once()
+ manifest_provider.aclose.assert_awaited_once()
+ persistence_engine.dispose.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_dispose_tracks_only_one_shutdown_task() -> None:
+ app = Application()
+ app.event_loop = asyncio.get_running_loop()
+
+ app.dispose()
+ shutdown_task = app._shutdown_task
+ app.dispose()
+
+ assert shutdown_task is not None
+ assert app._shutdown_task is shutdown_task
+ await shutdown_task
+
+ app.dispose()
+ assert app._shutdown_task is shutdown_task
+
+
+@pytest.mark.asyncio
+async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
+ app = Application()
+ app.event_loop = asyncio.get_running_loop()
+ app.blocking_executor = SimpleNamespace(
+ snapshot=lambda: {
+ 'inflight': 3,
+ 'running': 2,
+ 'pending': 1,
+ 'rejected_total': 4,
+ }
+ )
+ app.task_mgr = SimpleNamespace(get_stats=lambda: {'total': 5, 'completed': 2})
+ app.query_pool = SimpleNamespace(
+ queries=[object()],
+ cached_queries={},
+ active_query_count_by_workspace={'workspace-a': 1},
+ )
+ app.model_mgr = SimpleNamespace(
+ provider_dict={'provider': object()},
+ llm_model_dict={},
+ embedding_model_dict={},
+ rerank_model_dict={},
+ )
+ app.platform_mgr = SimpleNamespace(_bots_by_key={})
+ app.pipeline_mgr = SimpleNamespace(_pipelines_by_key={})
+ app.rag_mgr = SimpleNamespace(knowledge_bases={})
+ app.plugin_connector = SimpleNamespace(_known_desired_states={'installation': object()})
+ app.persistence_mgr = SimpleNamespace(
+ get_resource_stats=lambda: {
+ 'configured_capacity': 20,
+ 'checked_out': 3,
+ }
+ )
+ app.directory_projection_service = SimpleNamespace(
+ resource_snapshot=lambda: {
+ 'active_workspaces': 10,
+ 'max_active_workspaces': 1000,
+ }
+ )
+ app.tool_mgr = SimpleNamespace(
+ mcp_tool_loader=SimpleNamespace(
+ _sessions={},
+ _hosted_mcp_tasks=[],
+ _host_dispatch_tasks=set(),
+ )
+ )
+ app.telemetry = SimpleNamespace(send_tasks=[])
+
+ stats = app.get_runtime_resource_stats()
+
+ assert stats['asyncio_tasks'] >= 1
+ assert stats['event_loop'] == {
+ 'running': False,
+ 'samples_total': 0,
+ 'last_lag_ms': 0,
+ 'recent_p95_lag_ms': 0,
+ 'recent_max_lag_ms': 0,
+ 'max_lag_ms': 0,
+ }
+ assert stats['blocking_executor']['rejected_total'] == 4
+ assert stats['application_tasks'] == {
+ 'total': 5,
+ 'completed': 2,
+ }
+ assert stats['database_pool'] == {
+ 'configured_capacity': 20,
+ 'checked_out': 3,
+ }
+ assert stats['directory'] == {
+ 'active_workspaces': 10,
+ 'max_active_workspaces': 1000,
+ }
+ assert stats['query_pool'] == {
+ 'queued': 1,
+ 'cached': 0,
+ 'active_workspaces': 1,
+ }
+ assert stats['models']['providers'] == 1
+ assert stats['runtimes']['plugin_installations'] == 1
diff --git a/tests/unit_tests/core/test_boot.py b/tests/unit_tests/core/test_boot.py
index b2b717006..03c82a732 100644
--- a/tests/unit_tests/core/test_boot.py
+++ b/tests/unit_tests/core/test_boot.py
@@ -2,13 +2,37 @@ from __future__ import annotations
import signal
from types import SimpleNamespace
-from unittest.mock import Mock
+from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.core import boot
+@pytest.mark.asyncio
+async def test_make_app_shuts_down_partially_built_application(monkeypatch):
+ app_inst = SimpleNamespace(
+ event_loop=None,
+ shutdown=AsyncMock(),
+ initialize=AsyncMock(),
+ )
+
+ class FailingStage:
+ async def run(self, ap):
+ assert ap is app_inst
+ raise RuntimeError('startup failed')
+
+ monkeypatch.setattr(boot.app, 'Application', lambda: app_inst)
+ monkeypatch.setattr(boot, 'stage_order', ['FailingStage'])
+ monkeypatch.setitem(boot.stage.preregistered_stages, 'FailingStage', FailingStage)
+
+ with pytest.raises(RuntimeError, match='startup failed'):
+ await boot.make_app(SimpleNamespace())
+
+ app_inst.shutdown.assert_awaited_once()
+ app_inst.initialize.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch):
captured_handler = {}
diff --git a/tests/unit_tests/core/test_load_config.py b/tests/unit_tests/core/test_load_config.py
index 9a83f2fb6..b95bc25ba 100644
--- a/tests/unit_tests/core/test_load_config.py
+++ b/tests/unit_tests/core/test_load_config.py
@@ -35,6 +35,22 @@ class TestApplyEnvOverridesToConfig:
assert result['system']['name'] == 'custom_name'
+ def test_override_log_never_prints_secret_value(self, capsys):
+ """Environment-backed credentials must not be copied into logs."""
+ load_config = get_load_config_module()
+
+ secret = 'database-password-that-must-not-leak'
+ cfg = {'database': {'postgresql': {'password': ''}}}
+ env = {'DATABASE__POSTGRESQL__PASSWORD': secret}
+
+ with patch.dict(os.environ, env, clear=True):
+ result = load_config._apply_env_overrides_to_config(cfg)
+
+ captured = capsys.readouterr().out
+ assert result['database']['postgresql']['password'] == secret
+ assert 'DATABASE__POSTGRESQL__PASSWORD' in captured
+ assert secret not in captured
+
def test_override_int_value(self):
"""Test overriding an int value with proper conversion."""
load_config = get_load_config_module()
@@ -48,6 +64,20 @@ class TestApplyEnvOverridesToConfig:
assert result['concurrency']['pipeline'] == 10
assert isinstance(result['concurrency']['pipeline'], int)
+ def test_cloud_directory_limit_override_keeps_integer_type_on_upgraded_config(self):
+ load_config = get_load_config_module()
+ cfg = load_config._complete_runtime_policy_defaults({})
+
+ with patch.dict(
+ os.environ,
+ {'CLOUD__DIRECTORY__MAX_ACTIVE_WORKSPACES': '250'},
+ clear=True,
+ ):
+ result = load_config._apply_env_overrides_to_config(cfg)
+
+ assert result['cloud']['directory']['max_active_workspaces'] == 250
+ assert isinstance(result['cloud']['directory']['max_active_workspaces'], int)
+
def test_override_int_value_invalid_conversion(self):
"""Test that invalid int conversion keeps string value."""
load_config = get_load_config_module()
@@ -196,6 +226,19 @@ class TestApplyEnvOverridesToConfig:
assert result['system']['name'] == 'default'
+ def test_skip_env_vars_with_empty_path_segments(self, capsys):
+ """Platform variables such as __CF_USER_TEXT_ENCODING are not config."""
+ load_config = get_load_config_module()
+
+ cfg = {'system': {'name': 'default'}}
+ env = {'__CF_USER_TEXT_ENCODING': '0x1F5:0x0:0x64'}
+
+ with patch.dict(os.environ, env, clear=True):
+ result = load_config._apply_env_overrides_to_config(cfg)
+
+ assert result == cfg
+ assert capsys.readouterr().out == ''
+
def test_nested_config_path(self):
"""Test overriding deeply nested config."""
load_config = get_load_config_module()
@@ -259,6 +302,84 @@ class TestApplyEnvOverridesToConfig:
assert result['system']['enable'] is False
assert result['concurrency']['pipeline'] == 10
+ def test_plugin_worker_and_stdio_policy_native_env_overrides(self):
+ load_config = get_load_config_module()
+ cfg = {
+ 'plugin': {
+ 'worker': {
+ 'max_cpus': 1.0,
+ 'max_memory_mb': 512,
+ 'max_pids': 128,
+ 'max_open_files': 256,
+ 'max_file_size_mb': 512,
+ 'max_concurrent_restarts': 1,
+ 'restart_failure_threshold': 8,
+ 'restart_failure_window_seconds': 30.0,
+ 'restart_circuit_open_seconds': 60.0,
+ }
+ },
+ 'mcp': {'stdio': {'enabled': True}},
+ }
+ env = {
+ 'PLUGIN__WORKER__MAX_CPUS': '2.5',
+ 'PLUGIN__WORKER__MAX_MEMORY_MB': '1024',
+ 'PLUGIN__WORKER__MAX_PIDS': '64',
+ 'PLUGIN__WORKER__MAX_OPEN_FILES': '128',
+ 'PLUGIN__WORKER__MAX_FILE_SIZE_MB': '256',
+ 'PLUGIN__WORKER__MAX_CONCURRENT_RESTARTS': '2',
+ 'PLUGIN__WORKER__RESTART_FAILURE_THRESHOLD': '12',
+ 'PLUGIN__WORKER__RESTART_FAILURE_WINDOW_SECONDS': '45.5',
+ 'PLUGIN__WORKER__RESTART_CIRCUIT_OPEN_SECONDS': '90.0',
+ 'MCP__STDIO__ENABLED': 'false',
+ }
+
+ with patch.dict(os.environ, env, clear=True):
+ result = load_config._apply_env_overrides_to_config(cfg)
+
+ assert result['plugin']['worker'] == {
+ 'max_cpus': 2.5,
+ 'max_memory_mb': 1024,
+ 'max_pids': 64,
+ 'max_open_files': 128,
+ 'max_file_size_mb': 256,
+ 'max_concurrent_restarts': 2,
+ 'restart_failure_threshold': 12,
+ 'restart_failure_window_seconds': 45.5,
+ 'restart_circuit_open_seconds': 90.0,
+ }
+ assert result['mcp']['stdio']['enabled'] is False
+
+ def test_runtime_policy_defaults_preserve_env_types_for_upgraded_config(self):
+ load_config = get_load_config_module()
+ cfg = {'plugin': {'enable': True}}
+
+ completed = load_config._complete_runtime_policy_defaults(cfg)
+ with patch.dict(
+ os.environ,
+ {
+ 'PLUGIN__WORKER__MAX_MEMORY_MB': '768',
+ 'MCP__STDIO__ENABLED': 'false',
+ 'SYSTEM__BLOCKING_EXECUTOR__MAX_WORKERS': '12',
+ 'SYSTEM__BLOCKING_EXECUTOR__MAX_PENDING': '256',
+ 'SYSTEM__BLOCKING_EXECUTOR__MAX_INFLIGHT_PER_SCOPE': '3',
+ },
+ clear=True,
+ ):
+ result = load_config._apply_env_overrides_to_config(completed)
+
+ assert result['system']['blocking_executor'] == {
+ 'max_workers': 12,
+ 'max_pending': 256,
+ 'max_inflight_per_scope': 3,
+ }
+ assert isinstance(
+ result['system']['blocking_executor']['max_workers'],
+ int,
+ )
+ assert result['plugin']['worker']['max_memory_mb'] == 768
+ assert isinstance(result['plugin']['worker']['max_memory_mb'], int)
+ assert result['mcp']['stdio']['enabled'] is False
+
def test_webhook_prefix_override(self):
"""Test overriding webhook_prefix via environment variable."""
load_config = get_load_config_module()
diff --git a/tests/unit_tests/core/test_taskmgr.py b/tests/unit_tests/core/test_taskmgr.py
index 6c0d9828f..44503de9a 100644
--- a/tests/unit_tests/core/test_taskmgr.py
+++ b/tests/unit_tests/core/test_taskmgr.py
@@ -12,6 +12,8 @@ from __future__ import annotations
import pytest
import asyncio
+import contextvars
+import inspect
import sys
from unittest.mock import Mock, MagicMock
from contextlib import contextmanager
@@ -264,6 +266,28 @@ class TestTaskWrapper:
wrapper.cancel()
+ @pytest.mark.asyncio
+ async def test_workspace_task_sets_blocking_work_scope(self):
+ """Detached tasks recover tenant fairness from durable ownership."""
+ _, TaskWrapper, _ = get_taskmgr_classes()
+ from langbot.pkg.utils.bounded_executor import (
+ current_blocking_work_scope,
+ )
+
+ mock_app = create_mock_app()
+
+ async def read_scope():
+ return current_blocking_work_scope()
+
+ wrapper = TaskWrapper(
+ mock_app,
+ read_scope(),
+ workspace_uuid='workspace-a',
+ )
+
+ assert await wrapper.task == 'workspace-a'
+ assert current_blocking_work_scope() is None
+
@pytest.mark.asyncio
async def test_to_dict_serialization(self):
"""Test TaskWrapper.to_dict serialization."""
@@ -360,6 +384,53 @@ class TestAsyncTaskManager:
wrapper.cancel()
+ @pytest.mark.asyncio
+ async def test_create_task_does_not_inherit_request_context(self):
+ """Long-lived tasks must receive identity through explicit arguments."""
+
+ _, _, AsyncTaskManager = get_taskmgr_classes()
+ mock_app = create_mock_app()
+ manager = AsyncTaskManager(mock_app)
+ request_value = contextvars.ContextVar('request_value', default=None)
+ token = request_value.set('request-scoped-transaction')
+ observed = []
+
+ async def detached_task(captured_workspace: str) -> None:
+ observed.append((request_value.get(), captured_workspace))
+
+ try:
+ wrapper = manager.create_task(detached_task('workspace-a'))
+ await wrapper.task
+ finally:
+ request_value.reset(token)
+
+ assert observed == [(None, 'workspace-a')]
+
+ @pytest.mark.asyncio
+ async def test_create_task_waits_for_registered_transaction_commit(self):
+ _, _, AsyncTaskManager = get_taskmgr_classes()
+ mock_app = create_mock_app()
+ gate = asyncio.get_running_loop().create_future()
+
+ class PersistenceManagerStub:
+ def create_after_commit_gate(self):
+ return gate
+
+ mock_app.persistence_mgr = PersistenceManagerStub()
+ manager = AsyncTaskManager(mock_app)
+ observed = []
+
+ async def background_work() -> None:
+ observed.append('started')
+
+ wrapper = manager.create_task(background_work())
+ await asyncio.sleep(0)
+ assert observed == []
+
+ gate.set_result(None)
+ await wrapper.task
+ assert observed == ['started']
+
@pytest.mark.asyncio
async def test_get_stats_counts_correctly(self):
"""Test get_stats returns correct counts."""
@@ -482,6 +553,56 @@ class TestAsyncTaskManager:
wrapper.cancel()
+ @pytest.mark.asyncio
+ async def test_create_user_task_enforces_workspace_active_limit_and_closes_rejected_coroutine(self):
+ """A noisy Workspace cannot accumulate unbounded background work."""
+ _, _, AsyncTaskManager = get_taskmgr_classes()
+ mock_app = create_mock_app()
+ mock_app.instance_config.data['system']['task_retention'].update(
+ {
+ 'max_active_user_tasks': 10,
+ 'max_active_user_tasks_per_workspace': 1,
+ }
+ )
+ manager = AsyncTaskManager(mock_app)
+
+ async def long_coro():
+ await asyncio.sleep(10)
+
+ first = manager.create_user_task(long_coro(), workspace_uuid='workspace-a')
+ rejected = long_coro()
+ with pytest.raises(RuntimeError, match='Workspace has too many active user operations'):
+ manager.create_user_task(rejected, workspace_uuid='workspace-a')
+
+ assert inspect.getcoroutinestate(rejected) == inspect.CORO_CLOSED
+ other_workspace = manager.create_user_task(long_coro(), workspace_uuid='workspace-b')
+ first.cancel()
+ other_workspace.cancel()
+
+ @pytest.mark.asyncio
+ async def test_create_user_task_enforces_instance_active_limit(self):
+ """The shared process retains a hard cap even across Workspaces."""
+ _, _, AsyncTaskManager = get_taskmgr_classes()
+ mock_app = create_mock_app()
+ mock_app.instance_config.data['system']['task_retention'].update(
+ {
+ 'max_active_user_tasks': 1,
+ 'max_active_user_tasks_per_workspace': 10,
+ }
+ )
+ manager = AsyncTaskManager(mock_app)
+
+ async def long_coro():
+ await asyncio.sleep(10)
+
+ first = manager.create_user_task(long_coro(), workspace_uuid='workspace-a')
+ rejected = long_coro()
+ with pytest.raises(RuntimeError, match='instance has too many active user operations'):
+ manager.create_user_task(rejected, workspace_uuid='workspace-b')
+
+ assert inspect.getcoroutinestate(rejected) == inspect.CORO_CLOSED
+ first.cancel()
+
@pytest.mark.asyncio
async def test_get_task_by_id(self):
"""Test get_task_by_id returns correct task."""
diff --git a/tests/unit_tests/persistence/test_database_decorator.py b/tests/unit_tests/persistence/test_database_decorator.py
index d72d48e0b..8d66dbdbc 100644
--- a/tests/unit_tests/persistence/test_database_decorator.py
+++ b/tests/unit_tests/persistence/test_database_decorator.py
@@ -11,9 +11,19 @@ Note: Uses import isolation to break circular import chains.
from __future__ import annotations
import sys
-from unittest.mock import Mock, MagicMock
from contextlib import contextmanager
from typing import Generator
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def isolate_database_manager_registry(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Keep decorator tests from mutating the process-wide manager registry."""
+ from langbot.pkg.persistence import database
+
+ monkeypatch.setattr(database, 'preregistered_managers', list(database.preregistered_managers))
@contextmanager
diff --git a/tests/unit_tests/persistence/test_postgresql_database.py b/tests/unit_tests/persistence/test_postgresql_database.py
new file mode 100644
index 000000000..ad903ff95
--- /dev/null
+++ b/tests/unit_tests/persistence/test_postgresql_database.py
@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+# Persistence manager performs the package's database-manager registration;
+# importing a concrete manager first would enter the historical app/mgr cycle.
+from langbot.pkg.persistence import mgr as _persistence_mgr # noqa: F401
+from langbot.pkg.persistence.databases import postgresql
+
+
+@pytest.mark.asyncio
+async def test_postgresql_manager_parses_explicit_url_without_string_reassembly(monkeypatch) -> None:
+ captured = None
+ captured_options = None
+ sentinel_engine = object()
+
+ def create_engine(url, **options):
+ nonlocal captured, captured_options
+ captured = url
+ captured_options = options
+ return sentinel_engine
+
+ monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'postgresql': {
+ 'url': 'postgresql://runtime:p%40ss@db.internal:5432/langbot?sslmode=require',
+ }
+ }
+ }
+ )
+ )
+
+ manager = postgresql.PostgreSQLDatabaseManager(ap)
+ await manager.initialize()
+
+ assert captured.drivername == 'postgresql+asyncpg'
+ assert captured.password == 'p@ss'
+ assert captured.query['ssl'] == 'require'
+ assert 'sslmode' not in captured.query
+ assert captured_options == {
+ 'pool_size': 10,
+ 'max_overflow': 10,
+ 'pool_timeout': 30,
+ 'pool_recycle': 1800,
+ 'pool_pre_ping': True,
+ }
+ assert manager.engine is sentinel_engine
+
+
+@pytest.mark.asyncio
+async def test_postgresql_manager_builds_structured_url_with_special_password(monkeypatch) -> None:
+ captured = None
+
+ def create_engine(url, **_options):
+ nonlocal captured
+ captured = url
+ return object()
+
+ monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'postgresql': {
+ 'host': 'db.internal',
+ 'port': 5432,
+ 'user': 'runtime',
+ 'password': 'p@ss:/?#word',
+ 'database': 'langbot',
+ }
+ }
+ }
+ )
+ )
+
+ await postgresql.PostgreSQLDatabaseManager(ap).initialize()
+
+ assert captured.password == 'p@ss:/?#word'
+ assert captured.host == 'db.internal'
+ assert captured.database == 'langbot'
+
+
+@pytest.mark.asyncio
+async def test_postgresql_manager_applies_explicit_bounded_pool_options(monkeypatch) -> None:
+ captured_options = None
+
+ def create_engine(_url, **options):
+ nonlocal captured_options
+ captured_options = options
+ return object()
+
+ monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'postgresql': {
+ 'pool_size': 24,
+ 'max_overflow': 0,
+ 'pool_timeout_seconds': 7,
+ 'pool_recycle_seconds': 600,
+ }
+ }
+ }
+ )
+ )
+
+ await postgresql.PostgreSQLDatabaseManager(ap).initialize()
+
+ assert captured_options == {
+ 'pool_size': 24,
+ 'max_overflow': 0,
+ 'pool_timeout': 7,
+ 'pool_recycle': 600,
+ 'pool_pre_ping': True,
+ }
+
+
+@pytest.mark.asyncio
+async def test_cloud_postgresql_manager_applies_bounded_server_timeouts(monkeypatch) -> None:
+ captured_options = None
+
+ def create_engine(_url, **options):
+ nonlocal captured_options
+ captured_options = options
+ return object()
+
+ monkeypatch.setattr(postgresql.sqlalchemy_asyncio, 'create_async_engine', create_engine)
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'postgresql': {
+ 'statement_timeout_ms': 45_000,
+ 'lock_timeout_ms': 4_000,
+ 'idle_in_transaction_session_timeout_ms': 55_000,
+ }
+ }
+ }
+ )
+ )
+
+ manager = postgresql.PostgreSQLDatabaseManager(ap)
+ manager.persistence_mode = 'cloud_runtime'
+ await manager.initialize()
+
+ assert captured_options['connect_args'] == {
+ 'server_settings': {
+ 'statement_timeout': '45000',
+ 'lock_timeout': '4000',
+ 'idle_in_transaction_session_timeout': '55000',
+ }
+ }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ('name', 'value'),
+ [
+ ('pool_size', 0),
+ ('pool_size', True),
+ ('max_overflow', -1),
+ ('pool_size', 101),
+ ('max_overflow', 101),
+ ('pool_timeout_seconds', 0),
+ ('pool_timeout_seconds', 301),
+ ('pool_recycle_seconds', '1800'),
+ ('pool_recycle_seconds', 86401),
+ ],
+)
+async def test_postgresql_manager_rejects_invalid_pool_options(name, value) -> None:
+ ap = SimpleNamespace(instance_config=SimpleNamespace(data={'database': {'postgresql': {name: value}}}))
+
+ with pytest.raises(ValueError, match=rf'database\.postgresql\.{name}'):
+ await postgresql.PostgreSQLDatabaseManager(ap).initialize()
+
+
+@pytest.mark.asyncio
+async def test_postgresql_manager_rejects_combined_pool_capacity_above_hard_ceiling() -> None:
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'postgresql': {
+ 'pool_size': 60,
+ 'max_overflow': 41,
+ }
+ }
+ }
+ )
+ )
+
+ with pytest.raises(ValueError, match=r'pool_size \+ max_overflow'):
+ await postgresql.PostgreSQLDatabaseManager(ap).initialize()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ('name', 'value'),
+ [
+ ('statement_timeout_ms', 0),
+ ('statement_timeout_ms', 300_001),
+ ('lock_timeout_ms', 60_001),
+ ('idle_in_transaction_session_timeout_ms', True),
+ ('idle_in_transaction_session_timeout_ms', 300_001),
+ ],
+)
+async def test_cloud_postgresql_manager_rejects_unsafe_server_timeouts(name, value) -> None:
+ ap = SimpleNamespace(instance_config=SimpleNamespace(data={'database': {'postgresql': {name: value}}}))
+
+ with pytest.raises(ValueError, match=rf'database\.postgresql\.{name}'):
+ manager = postgresql.PostgreSQLDatabaseManager(ap)
+ manager.persistence_mode = 'cloud_runtime'
+ await manager.initialize()
+
+
+@pytest.mark.asyncio
+async def test_postgresql_manager_rejects_non_postgresql_url_without_echoing_secret() -> None:
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={'database': {'postgresql': {'url': 'sqlite:///operator-super-secret.db'}}}
+ )
+ )
+
+ manager = postgresql.PostgreSQLDatabaseManager(ap)
+ with pytest.raises(ValueError, match='valid PostgreSQL') as exc_info:
+ await manager.initialize()
+ assert 'operator-super-secret' not in str(exc_info.value)
diff --git a/tests/unit_tests/persistence/test_release_migration.py b/tests/unit_tests/persistence/test_release_migration.py
new file mode 100644
index 000000000..bcbaf4ffa
--- /dev/null
+++ b/tests/unit_tests/persistence/test_release_migration.py
@@ -0,0 +1,155 @@
+from __future__ import annotations
+
+import logging
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import sqlalchemy
+
+from langbot.__main__ import _build_parser
+from langbot.pkg.persistence import release_migration
+from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
+
+
+def _cloud_config(*, database_use: str = 'postgresql', runtime_user: str = 'langbot_runtime') -> dict:
+ return {
+ 'database': {
+ 'use': database_use,
+ 'postgresql': {
+ 'host': 'runtime-db',
+ 'port': 5432,
+ 'user': runtime_user,
+ 'password': 'runtime-secret',
+ 'database': 'langbot',
+ },
+ 'cloud_migration': {
+ 'operator_dsn_env': 'TEST_LANGBOT_OPERATOR_DSN',
+ },
+ },
+ 'vdb': {
+ 'use': 'pgvector',
+ 'pgvector': {
+ 'use_business_database': True,
+ 'allowed_dimensions': [384, 1536],
+ },
+ },
+ }
+
+
+def _operator_environ(
+ *,
+ user: str = 'langbot_migrator',
+ database: str = 'langbot',
+ host: str = 'runtime-db',
+ port: int = 5432,
+) -> dict[str, str]:
+ return {
+ 'TEST_LANGBOT_OPERATOR_DSN': (f'postgresql://{user}:operator%40secret@{host}:{port}/{database}?sslmode=require')
+ }
+
+
+def test_cloud_migration_cli_is_explicit() -> None:
+ args = _build_parser().parse_args(['migrate', '--cloud'])
+ assert args.command == 'migrate'
+ assert args.cloud is True
+
+ with pytest.raises(SystemExit) as exc_info:
+ _build_parser().parse_args(['migrate'])
+ assert exc_info.value.code == 2
+
+
+def test_operator_url_is_separate_and_preserves_escaped_secret() -> None:
+ url = release_migration._operator_database_url(
+ _cloud_config(),
+ environ=_operator_environ(),
+ )
+
+ assert url.drivername == 'postgresql+asyncpg'
+ assert url.username == 'langbot_migrator'
+ assert url.password == 'operator@secret'
+ assert url.host == 'runtime-db'
+ assert url.port == 5432
+ assert url.database == 'langbot'
+ assert url.query['ssl'] == 'require'
+ assert 'sslmode' not in url.query
+
+
+@pytest.mark.parametrize(
+ ('config', 'environ', 'message'),
+ [
+ (_cloud_config(database_use='sqlite'), _operator_environ(), 'SQLite fallback is forbidden'),
+ (_cloud_config(), {}, 'requires the operator DSN'),
+ (_cloud_config(), {'TEST_LANGBOT_OPERATOR_DSN': 'not a secret://operator-password'}, 'DSN is invalid'),
+ (
+ _cloud_config(),
+ {'TEST_LANGBOT_OPERATOR_DSN': 'postgresql://operator:secret@runtime-db:not-a-port/langbot'},
+ 'DSN is invalid',
+ ),
+ (_cloud_config(), _operator_environ(user='langbot_runtime'), 'distinct operator role'),
+ (_cloud_config(), _operator_environ(database='another_database'), 'configured runtime database'),
+ (_cloud_config(), _operator_environ(host='other-cluster'), 'runtime PostgreSQL endpoint'),
+ (_cloud_config(), _operator_environ(port=6432), 'runtime PostgreSQL endpoint'),
+ ],
+)
+def test_operator_url_rejects_unsafe_configuration(config: dict, environ: dict[str, str], message: str) -> None:
+ with pytest.raises(release_migration.CloudReleaseMigrationConfigurationError, match=message) as exc_info:
+ release_migration._operator_database_url(config, environ=environ)
+ assert 'operator-password' not in str(exc_info.value)
+
+
+@pytest.mark.asyncio
+async def test_release_migration_disposes_operator_engine_on_failure(monkeypatch) -> None:
+ engine = SimpleNamespace(dispose=AsyncMock())
+ manager = SimpleNamespace(
+ db=SimpleNamespace(engine=engine),
+ initialize=AsyncMock(side_effect=RuntimeError('migration failed')),
+ shutdown=AsyncMock(side_effect=engine.dispose),
+ )
+
+ def manager_factory(*args, **kwargs):
+ del args, kwargs
+ return manager
+
+ monkeypatch.setattr(release_migration, 'PersistenceManager', manager_factory)
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(data=_cloud_config()),
+ logger=logging.getLogger('release-migration-disposal-test'),
+ persistence_mgr=None,
+ )
+
+ with pytest.raises(RuntimeError, match='migration failed'):
+ await release_migration.run_cloud_release_migration(ap, environ=_operator_environ())
+
+ assert ap.persistence_mgr is manager
+ manager.shutdown.assert_awaited_once()
+ engine.dispose.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_release_mode_rejects_sqlite_before_schema_changes(tmp_path, monkeypatch) -> None:
+ from langbot.pkg.persistence import mgr as persistence_mgr_module
+ from langbot.pkg.persistence.databases.sqlite import SQLiteDatabaseManager
+
+ monkeypatch.setattr(persistence_mgr_module.database, 'preregistered_managers', [SQLiteDatabaseManager])
+ ap = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'database': {
+ 'use': 'sqlite',
+ 'sqlite': {'path': str(tmp_path / 'must-not-migrate.db')},
+ }
+ }
+ ),
+ logger=logging.getLogger('release-migration-sqlite-rejection-test'),
+ )
+ manager = PersistenceManager(ap, mode=PersistenceMode.RELEASE_MIGRATION)
+ with pytest.raises(RuntimeError, match='requires PostgreSQL'):
+ await manager.initialize()
+ await manager.get_db_engine().dispose()
+
+ engine = sqlalchemy.create_engine(f'sqlite:///{tmp_path / "must-not-migrate.db"}')
+ try:
+ assert sqlalchemy.inspect(engine).get_table_names() == []
+ finally:
+ engine.dispose()
diff --git a/tests/unit_tests/persistence/test_tenant_uow.py b/tests/unit_tests/persistence/test_tenant_uow.py
new file mode 100644
index 000000000..7be62bf2e
--- /dev/null
+++ b/tests/unit_tests/persistence/test_tenant_uow.py
@@ -0,0 +1,1170 @@
+from __future__ import annotations
+
+import asyncio
+import contextvars
+import datetime
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy as sa
+from pgvector.sqlalchemy import Vector
+from sqlalchemy.dialects.postgresql import insert as postgresql_insert
+from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+from sqlalchemy.ext.asyncio import create_async_engine
+from sqlalchemy.ext.asyncio import async_object_session
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, registry, relationship, with_loader_criteria
+from sqlalchemy.sql import quoted_name
+
+from langbot.pkg.core.task_boundary import create_detached_task
+from langbot.pkg.entity.persistence.cloud_directory import (
+ DirectoryProjectionInbox,
+ DirectoryProjectionState,
+)
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
+from langbot.pkg.persistence.tenant_uow import (
+ CrossScopeTransactionError,
+ PersistenceScopeKind,
+ ScopedSessionTransactionError,
+ TenantScopedAsyncSession,
+ TenantScopedSyncSession,
+ TenantScopeRequiredError,
+ TenantUnitOfWork,
+ TransactionRollbackOnlyError,
+ _validate_scoped_statement_call,
+)
+
+
+pytestmark = pytest.mark.asyncio
+
+
+async def test_tenant_uow_reports_pool_timeout_during_transaction_admission(monkeypatch) -> None:
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ pool_timeouts = 0
+
+ def record_pool_timeout() -> None:
+ nonlocal pool_timeouts
+ pool_timeouts += 1
+
+ async def fail_transaction_start(self, capability):
+ del self, capability
+ raise sa.exc.TimeoutError('pool exhausted')
+
+ monkeypatch.setattr(TenantScopedAsyncSession, '_start_owned_transaction', fail_transaction_start)
+ try:
+ with pytest.raises(sa.exc.TimeoutError, match='pool exhausted'):
+ async with TenantUnitOfWork(
+ engine,
+ '10000000-0000-0000-0000-000000000001',
+ on_pool_timeout=record_pool_timeout,
+ ):
+ pass
+ finally:
+ await engine.dispose()
+
+ assert pool_timeouts == 1
+
+
+def _on_conflict_statement(*, update_value, update_key='value', index_element=None):
+ table = sa.table('conflict_rows', sa.column('id'), sa.column('value'))
+ if index_element is None:
+ index_element = table.c.id
+ return (
+ sqlite_insert(table)
+ .values(id=1, value=1)
+ .on_conflict_do_update(
+ index_elements=[index_element],
+ set_={update_key: update_value},
+ )
+ )
+
+
+def _on_conflict_constraint_statement(*, constraint):
+ table = sa.table('conflict_rows', sa.column('id'), sa.column('value'))
+ return (
+ postgresql_insert(table)
+ .values(id=1, value=1)
+ .on_conflict_do_update(
+ constraint=constraint,
+ set_={'value': 2},
+ )
+ )
+
+
+def _multi_value_statement(*, value, value_key='value'):
+ table = sa.table('multi_value_rows', sa.column('id'), sa.column('value'))
+ return sa.insert(table).values([{'id': 1, value_key: value}])
+
+
+class _SpoofedCount(sa.sql.functions.FunctionElement):
+ name = 'count'
+ inherit_cache = True
+
+
+class _UntrustedCastType(sa.types.UserDefinedType):
+ def get_col_spec(self, **kwargs) -> str:
+ del kwargs
+ return 'INTEGER'
+
+
+async def test_sqlite_tenant_uow_commits_and_rolls_back() -> None:
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ table = sa.Table(
+ 'uow_rows',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ )
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ async with TenantUnitOfWork(engine, 'workspace-a') as uow:
+ await uow.execute(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
+
+ with pytest.raises(RuntimeError, match='roll back'):
+ async with TenantUnitOfWork(engine, 'workspace-a') as uow:
+ await uow.execute(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
+ raise RuntimeError('roll back this transaction')
+
+ async with engine.connect() as conn:
+ rows = (await conn.execute(sa.select(table.c.id).order_by(table.c.id))).scalars().all()
+ assert rows == [1]
+ finally:
+ await engine.dispose()
+
+
+async def test_tenant_uow_is_single_use_and_requires_an_active_scope() -> None:
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ uow = TenantUnitOfWork(engine, 'workspace-a')
+ try:
+ with pytest.raises(RuntimeError, match='not active'):
+ _ = uow.session
+
+ async with uow:
+ assert uow.session.in_transaction()
+
+ with pytest.raises(RuntimeError, match='cannot be reused'):
+ async with uow:
+ pass
+ finally:
+ await engine.dispose()
+
+
+def test_persistence_mode_must_be_a_trusted_enum() -> None:
+ with pytest.raises(TypeError, match='trusted PersistenceMode'):
+ PersistenceManager(object(), mode='cloud_runtime') # type: ignore[arg-type]
+
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ assert manager.mode is PersistenceMode.CLOUD_RUNTIME
+
+
+async def test_manager_reuses_one_session_and_rejects_cross_workspace(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-uow.db"}')
+ table = sa.Table(
+ 'manager_rows',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ )
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ with pytest.raises(TenantScopeRequiredError, match='explicit Workspace or discovery'):
+ await manager.execute_async(sa.select(table))
+
+ async with manager.tenant_uow('workspace-a') as outer:
+ await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
+ async with manager.tenant_uow('workspace-a') as inner:
+ assert inner.session is outer.session
+ assert manager.current_session() is outer.session
+ assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
+ with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
+ async with manager.tenant_uow('workspace-b'):
+ pass
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
+ finally:
+ await engine.dispose()
+
+
+async def test_directory_projection_uow_is_instance_scoped_and_not_workspace_nestable(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "directory-projection-uow.db"}')
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ fingerprint = 'a' * 64
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(DirectoryProjectionState.__table__.create)
+ await conn.run_sync(DirectoryProjectionInbox.__table__.create)
+
+ async with manager.directory_projection_uow('instance-a') as uow:
+ assert manager.current_scope() is not None
+ assert manager.current_scope().kind is PersistenceScopeKind.DIRECTORY_PROJECTION
+ assert manager.current_scope().settings == (('langbot.directory_instance_uuid', 'instance-a'),)
+ manager.require_current_session(PersistenceScopeKind.DIRECTORY_PROJECTION)
+ uow.session.add(
+ DirectoryProjectionState(
+ instance_uuid='instance-a',
+ cursor=1,
+ snapshot_fingerprint=fingerprint,
+ last_applied_at=datetime.datetime.now(datetime.UTC),
+ )
+ )
+ uow.session.add(
+ DirectoryProjectionInbox(
+ instance_uuid='instance-a',
+ event_uuid='00000000-0000-0000-0000-000000000001',
+ cursor=1,
+ event_type='workspace.changed',
+ revision=1,
+ fingerprint=fingerprint,
+ )
+ )
+ with pytest.raises(CrossScopeTransactionError, match='while directory_projection scope is active'):
+ async with manager.tenant_uow('workspace-a'):
+ pass
+
+ async with manager.tenant_scope('workspace-a'):
+ with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
+ async with manager.directory_projection_uow('instance-a'):
+ pass
+
+ with pytest.raises(ValueError, match='must not be empty'):
+ manager.directory_projection_uow(' ')
+ finally:
+ await engine.dispose()
+
+
+async def test_manager_scoped_execute_preserves_core_row_and_scalar_contract(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager-result-contract.db"}')
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(User.__table__.create)
+ await conn.execute(
+ sa.insert(User).values(
+ uuid='account-a',
+ user='owner@example.com',
+ normalized_email='owner@example.com',
+ password='hashed-password',
+ )
+ )
+
+ async with manager.tenant_uow('workspace-a') as uow:
+ result = await manager.execute_async(sa.select(User))
+ row = result.first()
+ assert row is not None
+ assert row.uuid == 'account-a'
+ assert row.user == 'owner@example.com'
+
+ scalar_result = await manager.execute_async(sa.select(User.uuid))
+ assert scalar_result.scalar_one() == 'account-a'
+
+ list_result = await manager.execute_async(sa.select(User.user))
+ assert list_result.scalars().all() == ['owner@example.com']
+
+ # Direct UoW execution remains an ORM API for code that opts into it.
+ orm_result = await uow.execute(sa.select(User))
+ assert orm_result.scalars().one().uuid == 'account-a'
+ finally:
+ await engine.dispose()
+
+
+async def test_transaction_free_tenant_scope_opens_one_short_uow_per_database_call(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope.db"}')
+ table = sa.Table(
+ 'short_scope_rows',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ )
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ async with manager.tenant_scope('workspace-a'):
+ assert manager.current_scope() is not None
+ assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
+ assert manager.current_scope().settings == (('langbot.workspace_uuid', 'workspace-a'),)
+ assert manager.current_session() is None
+
+ await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
+ assert manager.current_session() is None
+
+ # The first statement has already committed. Long external waits
+ # inside this boundary retain only identity, never a DB session.
+ await asyncio.sleep(0)
+ assert manager.current_session() is None
+
+ assert (await manager.execute_async(sa.select(table.c.id))).scalar_one() == 1
+ assert manager.current_session() is None
+
+ async with manager.tenant_scope('workspace-a'):
+ assert manager.current_session() is None
+ with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
+ async with manager.tenant_scope('workspace-b'):
+ pass
+ with pytest.raises(CrossScopeTransactionError, match='while workspace scope is active'):
+ async with manager.tenant_uow('workspace-b'):
+ pass
+
+ assert manager.current_scope() is None
+ with pytest.raises(TenantScopeRequiredError, match='explicit Workspace'):
+ await manager.execute_async(sa.select(table))
+ finally:
+ await engine.dispose()
+
+
+async def test_transaction_free_scope_requires_explicit_child_task_scope(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "short-scope-child.db"}')
+ table = sa.Table('child_scope_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ async with manager.tenant_scope('workspace-a'):
+
+ async def inherited_access() -> None:
+ await manager.execute_async(sa.select(table))
+
+ with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
+ await asyncio.create_task(inherited_access())
+
+ async def explicitly_scoped_access() -> None:
+ async with manager.tenant_scope('workspace-a'):
+ await manager.execute_async(sa.insert(table).values(id=1))
+ assert manager.current_session() is None
+
+ await asyncio.create_task(explicitly_scoped_access())
+ assert manager.current_session() is None
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
+ finally:
+ await engine.dispose()
+
+
+async def test_caught_nested_failure_marks_outer_transaction_rollback_only(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "rollback-only.db"}')
+ table = sa.Table('rollback_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with manager.tenant_uow('workspace-a'):
+ await manager.execute_async(sa.insert(table).values(id=1))
+ try:
+ async with manager.tenant_uow('workspace-a'):
+ raise ValueError('caught nested failure')
+ except ValueError:
+ pass
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table))).all() == []
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.parametrize('executor_kind', ['manager', 'uow', 'session'])
+async def test_caught_database_error_rolls_back_and_cancels_after_commit_gate(tmp_path, executor_kind: str) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"db-error-{executor_kind}.db"}')
+ table = sa.Table('unique_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
+ async with manager.tenant_uow('workspace-a') as uow:
+ statement = sa.insert(table).values(id=1)
+ if executor_kind == 'manager':
+ await manager.execute_async(statement)
+ elif executor_kind == 'uow':
+ await uow.execute(statement)
+ else:
+ await uow.session.execute(statement)
+
+ gate = manager.create_after_commit_gate()
+ assert gate is not None
+ try:
+ if executor_kind == 'manager':
+ await manager.execute_async(statement)
+ elif executor_kind == 'uow':
+ await uow.execute(statement)
+ else:
+ await uow.session.execute(statement)
+ except sa.exc.IntegrityError:
+ pass
+
+ assert gate.cancelled()
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table))).all() == []
+ finally:
+ await engine.dispose()
+
+
+async def test_child_task_must_open_its_own_explicit_uow(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "child-task.db"}')
+ table = sa.Table(
+ 'child_rows',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ )
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ async with manager.tenant_uow('workspace-a'):
+
+ async def inherited_access() -> None:
+ await manager.execute_async(sa.select(table))
+
+ with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
+ await asyncio.create_task(inherited_access())
+
+ async def explicitly_scoped_access() -> None:
+ async with manager.tenant_uow('workspace-a'):
+ await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
+
+ await asyncio.create_task(explicitly_scoped_access())
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [2]
+ finally:
+ await engine.dispose()
+
+
+async def test_captured_session_rejects_child_task_database_access(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "captured-session-child.db"}')
+ table = sa.Table('captured_session_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ mapper_registry = registry()
+
+ class CapturedSessionRow:
+ pass
+
+ mapper_registry.map_imperatively(CapturedSessionRow, table)
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ async with manager.tenant_uow('workspace-a') as uow:
+ captured_session = uow.session
+ captured_execute = captured_session.execute
+ captured_add = captured_session.add
+ await captured_session.execute(sa.insert(table).values(id=1))
+
+ async def inherited_session_write() -> None:
+ await captured_execute(sa.insert(table).values(id=2))
+
+ with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
+ await asyncio.create_task(inherited_session_write())
+
+ async def inherited_session_mutation() -> None:
+ captured_add(CapturedSessionRow(id=2))
+
+ with pytest.raises(CrossScopeTransactionError, match='cannot be inherited by child tasks'):
+ await asyncio.create_task(inherited_session_mutation())
+
+ # The rejected child never touched the connection and therefore
+ # must not poison valid work still owned by the parent task.
+ await captured_session.execute(sa.insert(table).values(id=3))
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table.c.id).order_by(table.c.id))).scalars().all() == [1, 3]
+ finally:
+ await engine.dispose()
+
+
+async def test_captured_session_is_permanently_inactive_after_uow_exit(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "captured-session-exit.db"}')
+ table = sa.Table('captured_session_exit_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ async with manager.tenant_uow('workspace-a') as uow:
+ captured_session = uow.session
+ captured_execute = captured_session.execute
+ await captured_execute(sa.insert(table).values(id=1))
+
+ with pytest.raises(ScopedSessionTransactionError, match='no longer active'):
+ await captured_session.execute(sa.select(table))
+ with pytest.raises(ScopedSessionTransactionError, match='no longer active'):
+ await captured_execute(sa.select(table))
+ with pytest.raises(ScopedSessionTransactionError, match='no longer active'):
+ captured_session.in_transaction()
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.parametrize(
+ 'operation',
+ [
+ 'begin',
+ 'commit',
+ 'rollback',
+ 'close',
+ 'close_all',
+ 'connection',
+ 'get_bind',
+ 'bind',
+ 'sync_session',
+ 'stream',
+ 'stream_scalars',
+ ],
+)
+async def test_scoped_session_cannot_escape_uow_transaction_lifecycle(tmp_path, operation: str) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"session-escape-{operation}.db"}')
+ table = sa.Table('session_escape_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with manager.tenant_uow('workspace-a') as uow:
+ await uow.execute(sa.insert(table).values(id=1))
+ with pytest.raises(ScopedSessionTransactionError, match=f'direct {operation}'):
+ if operation == 'begin':
+ uow.session.begin()
+ elif operation == 'commit':
+ await uow.session.commit()
+ elif operation == 'rollback':
+ await uow.session.rollback()
+ elif operation == 'close':
+ await uow.session.close()
+ elif operation == 'close_all':
+ await uow.session.close_all()
+ elif operation == 'connection':
+ await uow.session.connection()
+ elif operation == 'get_bind':
+ uow.session.get_bind().connect()
+ elif operation == 'bind':
+ _ = uow.session.bind
+ elif operation == 'sync_session':
+ _ = uow.session.sync_session
+ elif operation == 'stream':
+ await uow.session.stream(sa.select(table))
+ else:
+ await uow.session.stream_scalars(sa.select(table.c.id))
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table))).all() == []
+ finally:
+ await engine.dispose()
+
+
+async def test_scoped_session_no_autoflush_keeps_the_sync_proxy_private(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "no-autoflush.db"}')
+ table = sa.Table('no_autoflush_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ mapper_registry = registry()
+
+ class NoAutoflushRow:
+ pass
+
+ mapper_registry.map_imperatively(NoAutoflushRow, table)
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ async with manager.tenant_uow('workspace-a') as uow:
+ assert uow.session.autoflush is True
+ with uow.session.no_autoflush:
+ assert uow.session.autoflush is False
+ uow.session.add(NoAutoflushRow(id=1))
+ assert uow.session.autoflush is True
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.parametrize('access_kind', ['async_instance', 'global_sync'])
+async def test_orm_object_cannot_expose_the_uow_sync_session(tmp_path, access_kind: str) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"object-session-{access_kind}.db"}')
+ table = sa.Table('object_session_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ mapper_registry = registry()
+
+ class ObjectSessionRow:
+ pass
+
+ mapper_registry.map_imperatively(ObjectSessionRow, table)
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with manager.tenant_uow('workspace-a') as uow:
+ row = ObjectSessionRow(id=1)
+ uow.session.add(row)
+ await uow.session.flush()
+ assert async_object_session(row) is uow.session
+ gate = manager.create_after_commit_gate()
+ assert gate is not None
+
+ if access_kind == 'async_instance':
+ with pytest.raises(ScopedSessionTransactionError, match='object_session access'):
+ uow.session.object_session(row)
+ else:
+ sync_session = sa.orm.object_session(row)
+ assert sync_session is not None
+ with pytest.raises(ScopedSessionTransactionError, match='synchronous Session access'):
+ sync_session.rollback()
+
+ assert gate.cancelled()
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table))).all() == []
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.parametrize('event_name', ['do_orm_execute', 'before_flush'])
+async def test_orm_session_events_fail_closed_before_callbacks_run(tmp_path, event_name: str) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"orm-event-{event_name}.db"}')
+ table = sa.Table('orm_event_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ mapper_registry = registry()
+
+ class EventRow:
+ pass
+
+ mapper_registry.map_imperatively(EventRow, table)
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ callback_called = False
+ listener_registered = False
+
+ def do_orm_execute_escape(state) -> None:
+ nonlocal callback_called
+ callback_called = True
+ state.session.connection().exec_driver_sql('COMMIT')
+
+ def before_flush_escape(session, flush_context, instances) -> None:
+ nonlocal callback_called
+ del flush_context, instances
+ callback_called = True
+ session.bind.connect()
+
+ listener = do_orm_execute_escape if event_name == 'do_orm_execute' else before_flush_escape
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with manager.tenant_uow('workspace-a') as uow:
+ await uow.execute(sa.insert(table).values(id=1))
+ sa.event.listen(TenantScopedSyncSession, event_name, listener)
+ listener_registered = True
+ with pytest.raises(ScopedSessionTransactionError, match=f'event listener {event_name}'):
+ if event_name == 'do_orm_execute':
+ await uow.session.execute(sa.select(table))
+ else:
+ uow.session.add(EventRow(id=2))
+ await uow.session.flush()
+
+ assert not callback_called
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table))).all() == []
+ finally:
+ if listener_registered:
+ sa.event.remove(TenantScopedSyncSession, event_name, listener)
+ await engine.dispose()
+
+
+async def test_pre_registered_orm_session_event_prevents_uow_start() -> None:
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ callback_called = False
+
+ def after_transaction_create_escape(session, transaction) -> None:
+ nonlocal callback_called
+ del session, transaction
+ callback_called = True
+
+ sa.event.listen(TenantScopedSyncSession, 'after_transaction_create', after_transaction_create_escape)
+ try:
+ with pytest.raises(ScopedSessionTransactionError, match='event listener after_transaction_create'):
+ async with TenantUnitOfWork(engine, 'workspace-a'):
+ pass
+ assert not callback_called
+ finally:
+ sa.event.remove(TenantScopedSyncSession, 'after_transaction_create', after_transaction_create_escape)
+ await engine.dispose()
+
+
+async def test_explicit_async_refresh_loads_relationship_without_exposing_sync_session(tmp_path) -> None:
+ class Base(DeclarativeBase):
+ pass
+
+ class Parent(Base):
+ __tablename__ = 'async_attr_parents'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ children: Mapped[list[Child]] = relationship(back_populates='parent')
+
+ class Child(Base):
+ __tablename__ = 'async_attr_children'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ parent_id: Mapped[int] = mapped_column(sa.ForeignKey('async_attr_parents.id'))
+ parent: Mapped[Parent] = relationship(back_populates='children')
+
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "async-attrs.db"}')
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ async with manager.tenant_uow('workspace-a') as uow:
+ uow.session.add(Parent(id=1, children=[Child(id=1)]))
+
+ async with manager.tenant_uow('workspace-a') as uow:
+ parent = await uow.session.get(Parent, 1)
+ assert parent is not None
+ assert 'children' not in parent.__dict__
+ await uow.session.refresh(parent, ['children'])
+ assert [child.id for child in parent.children] == [1]
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.parametrize(
+ ('statement', 'params', 'keyword_call'),
+ [
+ (sa.text('ROLLBACK'), None, False),
+ (sa.text('/* caller comment */ COMMIT'), None, False),
+ (sa.text('SET LOCAL langbot.workspace_uuid = :value'), {'value': 'workspace-b'}, False),
+ (sa.text("SET LOCAL langbot . workspace_uuid = 'workspace-b'"), None, False),
+ (sa.text("SET/**/ LOCAL /**/ langbot . workspace_uuid = 'workspace-b'"), None, False),
+ (
+ sa.text('SELECT set_config(:setting_name, :setting_value, true)'),
+ {'setting_name': 'langbot.workspace_uuid', 'setting_value': 'workspace-b'},
+ False,
+ ),
+ (
+ sa.text('SELECT set_config/**/(:setting_name, :setting_value, true)'),
+ {'setting_name': 'langbot.workspace_uuid', 'setting_value': 'workspace-b'},
+ False,
+ ),
+ (
+ sa.text('SELECT "set_config"(:setting_name, :setting_value, true)'),
+ {'setting_name': 'langbot.workspace_uuid', 'setting_value': 'workspace-b'},
+ False,
+ ),
+ (sa.text("DO $$ BEGIN PERFORM set_config('langbot.workspace_uuid', 'workspace-b', true); END $$"), None, False),
+ (sa.text('CALL tenant_scope_escape()'), None, False),
+ (sa.text('CREATE FUNCTION tenant_scope_escape() RETURNS void LANGUAGE SQL AS $$ SELECT 1 $$'), None, False),
+ (sa.text('SELECT * INTO TEMP leaked_rows FROM sql_escape_rows'), None, False),
+ (sa.text('SELECT * INTO pg_temp.leaked_rows FROM sql_escape_rows'), None, False),
+ (sa.text('DECLARE leaked_rows CURSOR WITH HOLD FOR SELECT * FROM sql_escape_rows'), None, False),
+ (sa.text('FETCH ALL FROM leaked_rows'), None, False),
+ (sa.text('PREPARE scope_escape AS SELECT 1'), None, False),
+ (sa.text('EXECUTE scope_escape'), None, False),
+ (sa.text('EXPLAIN (ANALYZE true) EXECUTE scope_escape'), None, False),
+ (sa.text('LISTEN tenant_scope_escape'), None, False),
+ (sa.text("NOTIFY tenant_scope_escape, 'payload'"), None, False),
+ (sa.text('LOCK TABLE sql_escape_rows'), None, False),
+ (sa.text('SELECT pg_advisory_lock(123)'), None, False),
+ (sa.text('VALUES (pg_try_advisory_lock(123))'), None, False),
+ (sa.text('EXPLAIN (ANALYZE TRUE) SELECT pg_advisory_lock(123)'), None, False),
+ (sa.text("SELECT pg_notify('tenant_scope_escape', 'payload')"), None, False),
+ (sa.text("SELECT lo_from_bytea(0, decode('AA==', 'base64'))"), None, False),
+ (sa.text('SELECT lo_get(123)'), None, False),
+ (sa.text("SELECT currval('shared_sequence')"), None, False),
+ (sa.text('SELECT lastval()'), None, False),
+ (
+ sa.select(
+ sa.func.query_to_xml(
+ sa.literal('SELECT 1'),
+ sa.literal(True),
+ sa.literal(False),
+ sa.literal(''),
+ )
+ ),
+ None,
+ False,
+ ),
+ (sa.select(sa.func.ts_stat(sa.literal('SELECT 1'))), None, False),
+ (sa.select(sa.func.pg_catalog.count()), None, False),
+ (sa.select(_SpoofedCount()), None, False),
+ (sa.select(sa.literal_column('1')), None, False),
+ (sa.select(sa.text('1')), None, False),
+ (sa.select(sa.bindparam('value', 1, literal_execute=True)), None, False),
+ (sa.select(sa.bindparam('value', 1, type_=_UntrustedCastType())), None, False),
+ (sa.select(sa.column('value').op('@@')(sa.literal('query'))), None, False),
+ (
+ sa.select(
+ sa.sql.expression.UnaryExpression(
+ sa.literal(True),
+ operator=sa.sql.operators.custom_op('unsafe_prefix'),
+ )
+ ),
+ None,
+ False,
+ ),
+ (
+ sa.select(
+ sa.sql.expression.UnaryExpression(
+ sa.literal(True),
+ modifier=sa.sql.operators.custom_op('unsafe_suffix'),
+ )
+ ),
+ None,
+ False,
+ ),
+ (sa.select(sa.extract('year', sa.literal('2026-01-01'))), None, False),
+ (sa.select(sa.cast(sa.literal(1), _UntrustedCastType())), None, False),
+ (
+ sa.select(User).options(with_loader_criteria(User, sa.literal_column('1 = 1'))),
+ None,
+ False,
+ ),
+ (sa.select(sa.table(quoted_name('forced unquoted table', quote=False))), None, False),
+ (sa.select(sa.literal(1).label(quoted_name('forced unquoted label', quote=False))), None, False),
+ (
+ sa.select(sa.collate(sa.column('value'), quoted_name('forced unquoted collation', quote=False))),
+ None,
+ False,
+ ),
+ (sa.select(sa.literal(1)).prefix_with('/* caller prefix */'), None, False),
+ (sa.select(sa.literal(1)).suffix_with('FOR UPDATE'), None, False),
+ (sa.select(sa.literal(1)).with_statement_hint('caller hint'), None, False),
+ (
+ _on_conflict_statement(
+ update_value=sa.func.query_to_xml(
+ sa.literal('SELECT 1'),
+ sa.literal(True),
+ sa.literal(False),
+ sa.literal(''),
+ )
+ ),
+ None,
+ False,
+ ),
+ (_on_conflict_statement(update_value=sa.text('set_config(:name, :value, true)')), None, False),
+ (
+ _on_conflict_statement(
+ update_value=1,
+ update_key=quoted_name('forced unquoted update', quote=False),
+ ),
+ None,
+ False,
+ ),
+ (
+ _on_conflict_statement(
+ update_value=1,
+ index_element=quoted_name('forced unquoted target', quote=False),
+ ),
+ None,
+ False,
+ ),
+ (
+ _on_conflict_constraint_statement(constraint=quoted_name('forced unquoted constraint', quote=False)),
+ None,
+ False,
+ ),
+ (_multi_value_statement(value=sa.func.ts_stat(sa.literal('SELECT 1'))), None, False),
+ (_multi_value_statement(value=sa.text('set_config(:name, :value, true)')), None, False),
+ (
+ _multi_value_statement(
+ value=1,
+ value_key=quoted_name('forced unquoted batch key', quote=False),
+ ),
+ None,
+ False,
+ ),
+ (sa.values(sa.column('value')).data([(sa.func.ts_stat(sa.literal('SELECT 1')),)]), None, False),
+ (
+ sa.insert(sa.table('rows', sa.column('value'))).from_select(
+ ['value'],
+ sa.select(sa.literal(1)),
+ ),
+ None,
+ False,
+ ),
+ (sa.text('ROLLBACK'), None, True),
+ (
+ sa.text('SELECT set_config(:setting_name, :setting_value, true)'),
+ {'setting_name': 'langbot.workspace_uuid', 'setting_value': 'workspace-b'},
+ True,
+ ),
+ ],
+)
+async def test_scoped_session_rejects_raw_or_unapproved_sql(
+ tmp_path,
+ statement,
+ params,
+ keyword_call: bool,
+) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "sql-transaction-escape.db"}')
+ table = sa.Table('sql_escape_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ with pytest.raises(TransactionRollbackOnlyError, match='after-commit work was cancelled'):
+ async with manager.tenant_uow('workspace-a') as uow:
+ await uow.execute(sa.insert(table).values(id=1))
+ gate = manager.create_after_commit_gate()
+ assert gate is not None
+ with pytest.raises(ScopedSessionTransactionError):
+ if keyword_call:
+ await uow.session.execute(statement=statement, params=params)
+ elif params is None:
+ await uow.session.execute(statement)
+ else:
+ await uow.session.execute(statement, params)
+
+ assert gate.cancelled()
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table))).all() == []
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.parametrize(
+ 'statement',
+ [
+ sa.select(sa.literal('set_config(')),
+ sa.select(sa.func.count()),
+ sa.select(sa.func.coalesce(sa.func.sum(sa.literal(1)), sa.literal(0))),
+ sa.select(
+ sa.func.now(),
+ sa.func.length(sa.literal('value')),
+ sa.func.nullif(sa.literal('value'), sa.literal('')),
+ ),
+ sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
+ sa.select(sa.cast(sa.column('embedding'), Vector(384))),
+ sa.insert(sa.table('rows', sa.column('id'))).values(id=1),
+ _multi_value_statement(value=1),
+ _on_conflict_statement(update_value=sa.func.coalesce(sa.literal(1), sa.literal(0))),
+ ],
+)
+async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statement) -> None:
+ _validate_scoped_statement_call((statement,), {})
+
+
+async def test_scoped_sql_rejects_public_execution_options() -> None:
+ statement = sa.select(sa.literal(1))
+ with pytest.raises(ScopedSessionTransactionError, match='execution options'):
+ _validate_scoped_statement_call(
+ (statement,),
+ {'execution_options': {'schema_translate_map': {None: 'other_schema'}}},
+ )
+
+
+@pytest.mark.parametrize('operation', ['get', 'get_one', 'refresh', 'merge'])
+async def test_scoped_orm_loaders_reject_public_query_options(operation: str) -> None:
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ try:
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with TenantUnitOfWork(engine, 'workspace-a') as uow:
+ with pytest.raises(ScopedSessionTransactionError, match=operation):
+ if operation == 'get':
+ await uow.session.get(
+ User,
+ 1,
+ execution_options={'schema_translate_map': {None: 'other_schema'}},
+ )
+ elif operation == 'get_one':
+ await uow.session.get_one(User, 1, options=[object()])
+ elif operation == 'refresh':
+ await uow.session.refresh(object(), with_for_update=True)
+ else:
+ await uow.session.merge(User(), options=[object()])
+ finally:
+ await engine.dispose()
+
+
+async def test_scoped_get_rejects_empty_for_update_mapping() -> None:
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ try:
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with TenantUnitOfWork(engine, 'workspace-a') as uow:
+ with pytest.raises(ScopedSessionTransactionError, match='get option with_for_update'):
+ await uow.session.get(User, 1, with_for_update={})
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.parametrize('flush_kind', ['explicit', 'autoflush'])
+async def test_scoped_orm_writes_reject_attribute_sql_expressions(tmp_path, flush_kind: str) -> None:
+ class Base(DeclarativeBase):
+ pass
+
+ class Row(Base):
+ __tablename__ = f'orm_expression_{flush_kind}'
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ value: Mapped[int] = mapped_column()
+
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / f"orm-expression-{flush_kind}.db"}')
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with TenantUnitOfWork(engine, 'workspace-a') as uow:
+ uow.session.add(Row(id=1, value=sa.literal_column('40 + 2')))
+ with pytest.raises(ScopedSessionTransactionError, match='ORM SQL expression'):
+ if flush_kind == 'explicit':
+ await uow.session.flush()
+ else:
+ await uow.session.execute(sa.select(Row.id))
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(Row))).all() == []
+ finally:
+ await engine.dispose()
+
+
+async def test_scoped_session_rejects_an_explicit_foreign_bind(tmp_path) -> None:
+ primary = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "primary-bind.db"}')
+ foreign = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "foreign-bind.db"}')
+ table = sa.Table('bind_escape_rows', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: primary)
+ try:
+ for engine in (primary, foreign):
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ with pytest.raises(TransactionRollbackOnlyError, match='transaction was rolled back'):
+ async with manager.tenant_uow('workspace-a') as uow:
+ await uow.execute(sa.insert(table).values(id=1))
+ with pytest.raises(ScopedSessionTransactionError, match='foreign database bind'):
+ await uow.session.execute(
+ sa.insert(table).values(id=2),
+ bind_arguments={'bind': foreign.sync_engine},
+ )
+
+ for engine in (primary, foreign):
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table))).all() == []
+ finally:
+ await primary.dispose()
+ await foreign.dispose()
+
+
+async def test_detached_task_starts_without_parent_scope_and_rolls_back_its_uow(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "detached-task.db"}')
+ table = sa.Table(
+ 'detached_rows',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('workspace_uuid', sa.String(36), nullable=False),
+ )
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ async with manager.tenant_uow('workspace-a'):
+
+ async def detached_write() -> None:
+ # Before the detached boundary this access raises
+ # CrossScopeTransactionError because asyncio copies the
+ # parent's ActiveScopedTransaction into this child task.
+ assert manager.current_scope() is None
+ async with manager.tenant_uow('workspace-a'):
+ await manager.execute_async(sa.insert(table).values(id=2, workspace_uuid='workspace-a'))
+ raise RuntimeError('roll back detached write')
+
+ task = create_detached_task(detached_write())
+ with pytest.raises(RuntimeError, match='roll back detached write'):
+ await task
+
+ await manager.execute_async(sa.insert(table).values(id=1, workspace_uuid='workspace-a'))
+
+ async with engine.connect() as conn:
+ assert (await conn.execute(sa.select(table.c.id))).scalars().all() == [1]
+ finally:
+ await engine.dispose()
+
+
+async def test_after_commit_task_waits_for_commit_and_starts_with_empty_context(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-commit.db"}')
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ request_value = contextvars.ContextVar('after_commit_request_value', default=None)
+ observed = []
+ try:
+ async with manager.tenant_uow('workspace-a'):
+ token = request_value.set('request-scope')
+
+ async def after_commit_work() -> None:
+ observed.append((request_value.get(), manager.current_scope()))
+
+ try:
+ task = create_detached_task(
+ after_commit_work(),
+ after_commit_manager=manager,
+ )
+ await asyncio.sleep(0)
+ assert observed == []
+ finally:
+ request_value.reset(token)
+
+ await task
+ assert observed == [(None, None)]
+ finally:
+ await engine.dispose()
+
+
+async def test_after_commit_task_is_cancelled_and_coroutine_closed_on_rollback(tmp_path) -> None:
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "after-rollback.db"}')
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ started = False
+
+ async def should_not_start() -> None:
+ nonlocal started
+ started = True
+
+ coro = should_not_start()
+ try:
+ with pytest.raises(RuntimeError, match='rollback request'):
+ async with manager.tenant_uow('workspace-a'):
+ task = create_detached_task(coro, after_commit_manager=manager)
+ await asyncio.sleep(0)
+ assert not task.done()
+ raise RuntimeError('rollback request')
+
+ await asyncio.sleep(0)
+ assert task.cancelled()
+ assert not started
+ assert coro.cr_frame is None
+ finally:
+ await engine.dispose()
diff --git a/tests/unit_tests/pipeline/conftest.py b/tests/unit_tests/pipeline/conftest.py
index ce8ee7eb0..ebd3b5363 100644
--- a/tests/unit_tests/pipeline/conftest.py
+++ b/tests/unit_tests/pipeline/conftest.py
@@ -17,6 +17,7 @@ from unittest.mock import AsyncMock, Mock
# this, running a stage test in isolation triggers a circular-import error:
# stage.py → core.app → pipelinemgr → stage.stage_class (not yet bound).
import langbot.pkg.pipeline.pipelinemgr # noqa: F401
+from langbot.pkg.api.http.context import ExecutionContext
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -40,6 +41,14 @@ class MockApplication:
self.query_pool = self._create_mock_query_pool()
self.instance_config = self._create_mock_instance_config()
self.task_mgr = self._create_mock_task_manager()
+ self.workspace_service = AsyncMock()
+ self.workspace_service.get_execution_binding = AsyncMock(
+ return_value=Mock(
+ instance_uuid='test-instance',
+ workspace_uuid='test-workspace',
+ placement_generation=1,
+ )
+ )
# Skill manager is optional; PreProcessor only touches it for the
# local-agent runner. None keeps the skill-binding branch inert.
self.skill_mgr = None
@@ -83,6 +92,7 @@ class MockApplication:
query_pool.cached_queries = {}
query_pool.queries = []
query_pool.condition = AsyncMock()
+ query_pool.remove_query = AsyncMock(return_value=True)
return query_pool
def _create_mock_instance_config(self):
@@ -191,6 +201,9 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
# Use model_construct to bypass Pydantic validation for test purposes
query = pipeline_query.Query.model_construct(
+ instance_uuid='test-instance',
+ workspace_uuid='test-workspace',
+ placement_generation=1,
query_id='test-query-id',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -219,6 +232,17 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
resp_message_chain=None,
current_stage_name=None,
)
+ object.__setattr__(
+ query,
+ '_execution_context',
+ ExecutionContext(
+ instance_uuid='test-instance',
+ workspace_uuid='test-workspace',
+ placement_generation=1,
+ bot_uuid='test-bot-uuid',
+ pipeline_uuid='test-pipeline-uuid',
+ ),
+ )
return query
diff --git a/tests/unit_tests/pipeline/test_aggregator.py b/tests/unit_tests/pipeline/test_aggregator.py
index 9eab7615f..4b8e5f059 100644
--- a/tests/unit_tests/pipeline/test_aggregator.py
+++ b/tests/unit_tests/pipeline/test_aggregator.py
@@ -13,8 +13,11 @@ from __future__ import annotations
import pytest
import asyncio
+import contextvars
+from contextlib import asynccontextmanager
from unittest.mock import Mock, AsyncMock
from importlib import import_module
+from types import SimpleNamespace
from tests.factories import (
FakeApp,
@@ -25,6 +28,49 @@ from tests.factories import (
import langbot_plugin.api.entities.builtin.provider.session as provider_session
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.pipeline.pool import (
+ ExecutionContextMismatchError,
+ ExecutionContextRequiredError,
+ bind_execution_context,
+)
+from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
+
+
+def execution_context(
+ workspace_uuid='workspace-test',
+ *,
+ bot_uuid='test-bot',
+ pipeline_uuid=None,
+ placement_generation=1,
+):
+ return ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid=workspace_uuid,
+ placement_generation=placement_generation,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ )
+
+
+def aggregation_key(
+ context,
+ *,
+ launcher_type=provider_session.LauncherTypes.PERSON,
+ launcher_id=12345,
+ bot_uuid='test-bot',
+ pipeline_uuid=None,
+):
+ return (
+ context.instance_uuid,
+ context.workspace_uuid,
+ context.placement_generation,
+ bot_uuid,
+ pipeline_uuid,
+ launcher_type.value,
+ launcher_id,
+ )
+
def get_aggregator_module():
"""Lazy import to avoid circular import issues."""
@@ -36,12 +82,66 @@ def make_aggregator_app():
app = FakeApp()
# Ensure query_pool has add_query method
app.query_pool.add_query = AsyncMock()
+
+ async def resolve_context(
+ context,
+ *,
+ bot_uuid,
+ pipeline_uuid,
+ query_uuid=None,
+ ):
+ if context is None:
+ raise ExecutionContextRequiredError('ExecutionContext required in test')
+ return bind_execution_context(
+ context,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ query_uuid=query_uuid,
+ )
+
+ app.query_pool.resolve_execution_context = AsyncMock(side_effect=resolve_context)
# Add pipeline_mgr mock
app.pipeline_mgr = AsyncMock()
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
+ app.workspace_service = Mock()
+ app.workspace_service.get_execution_binding = AsyncMock(
+ return_value=Mock(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ )
+ )
return app
+def enable_aggregation(app, *, delay=10.0):
+ pipeline = Mock()
+ pipeline.pipeline_entity.config = {
+ 'trigger': {
+ 'message-aggregation': {
+ 'enabled': True,
+ 'delay': delay,
+ }
+ }
+ }
+ app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=pipeline)
+
+
+def scoped_message_kwargs(context, *, launcher_id=12345, text='hello'):
+ chain = text_chain(text)
+ return {
+ 'execution_context': context,
+ 'bot_uuid': context.bot_uuid,
+ 'launcher_type': provider_session.LauncherTypes.PERSON,
+ 'launcher_id': launcher_id,
+ 'sender_id': launcher_id,
+ 'message_event': friend_message_event(chain),
+ 'message_chain': chain,
+ 'adapter': mock_adapter(),
+ 'pipeline_uuid': context.pipeline_uuid,
+ }
+
+
class TestPendingMessage:
"""Tests for PendingMessage dataclass."""
@@ -54,6 +154,7 @@ class TestPendingMessage:
adapter = mock_adapter()
pending = aggregator.PendingMessage(
+ execution_context=execution_context(pipeline_uuid='test-pipeline'),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -77,9 +178,14 @@ class TestSessionBuffer:
"""SessionBuffer should be created with correct fields."""
aggregator = get_aggregator_module()
- buffer = aggregator.SessionBuffer(session_id='test-session')
+ context = execution_context()
+ key = aggregation_key(context)
+ buffer = aggregator.SessionBuffer(
+ aggregation_key=key,
+ execution_context=context,
+ )
- assert buffer.session_id == 'test-session'
+ assert buffer.aggregation_key == key
assert buffer.messages == []
assert buffer.timer_task is None
assert buffer.last_message_time is not None
@@ -93,6 +199,7 @@ class TestSessionBuffer:
adapter = mock_adapter()
pending = aggregator.PendingMessage(
+ execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -103,8 +210,10 @@ class TestSessionBuffer:
pipeline_uuid=None,
)
+ context = execution_context()
buffer = aggregator.SessionBuffer(
- session_id='test-session',
+ aggregation_key=aggregation_key(context),
+ execution_context=context,
messages=[pending],
)
@@ -127,7 +236,7 @@ class TestMessageAggregatorInit:
class TestMessageAggregatorSessionId:
- """Tests for session ID generation."""
+ """Tests for scoped aggregation key generation."""
def test_session_id_format(self):
"""Session ID should be correctly formatted."""
@@ -136,13 +245,24 @@ class TestMessageAggregatorSessionId:
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
- session_id = agg._get_session_id(
+ context = execution_context()
+ session_id = agg._get_aggregation_key(
+ context,
bot_uuid='bot-123',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=45678,
+ pipeline_uuid=None,
)
- assert session_id == 'bot-123:person:45678'
+ assert session_id == (
+ 'instance-test',
+ 'workspace-test',
+ 1,
+ 'bot-123',
+ None,
+ 'person',
+ 45678,
+ )
def test_session_id_different_launchers(self):
"""Different launcher types should produce different IDs."""
@@ -151,16 +271,21 @@ class TestMessageAggregatorSessionId:
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
- person_id = agg._get_session_id(
+ context = execution_context()
+ person_id = agg._get_aggregation_key(
+ context,
bot_uuid='bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=123,
+ pipeline_uuid=None,
)
- group_id = agg._get_session_id(
+ group_id = agg._get_aggregation_key(
+ context,
bot_uuid='bot',
launcher_type=provider_session.LauncherTypes.GROUP,
launcher_id=123,
+ pipeline_uuid=None,
)
assert person_id != group_id
@@ -177,7 +302,7 @@ class TestMessageAggregatorConfig:
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
- enabled, delay = await agg._get_aggregation_config(None)
+ enabled, delay = await agg._get_aggregation_config(execution_context(), None)
assert enabled == False
assert delay == 1.5
@@ -191,7 +316,10 @@ class TestMessageAggregatorConfig:
app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(return_value=None)
agg = aggregator.MessageAggregator(app)
- enabled, delay = await agg._get_aggregation_config('unknown-pipeline')
+ enabled, delay = await agg._get_aggregation_config(
+ execution_context(pipeline_uuid='unknown-pipeline'),
+ 'unknown-pipeline',
+ )
assert enabled == False
assert delay == 1.5
@@ -217,7 +345,10 @@ class TestMessageAggregatorConfig:
agg = aggregator.MessageAggregator(app)
- enabled, delay = await agg._get_aggregation_config('test-pipeline')
+ enabled, delay = await agg._get_aggregation_config(
+ execution_context(pipeline_uuid='test-pipeline'),
+ 'test-pipeline',
+ )
assert enabled == True
assert delay == 2.0
@@ -243,7 +374,10 @@ class TestMessageAggregatorConfig:
agg = aggregator.MessageAggregator(app)
- enabled, delay = await agg._get_aggregation_config('test-pipeline')
+ enabled, delay = await agg._get_aggregation_config(
+ execution_context(pipeline_uuid='test-pipeline'),
+ 'test-pipeline',
+ )
assert delay == 1.0 # Clamped to minimum
@@ -268,7 +402,10 @@ class TestMessageAggregatorConfig:
agg = aggregator.MessageAggregator(app)
- enabled, delay = await agg._get_aggregation_config('test-pipeline')
+ enabled, delay = await agg._get_aggregation_config(
+ execution_context(pipeline_uuid='test-pipeline'),
+ 'test-pipeline',
+ )
assert delay == 10.0 # Clamped to maximum
@@ -293,7 +430,10 @@ class TestMessageAggregatorConfig:
agg = aggregator.MessageAggregator(app)
- enabled, delay = await agg._get_aggregation_config('test-pipeline')
+ enabled, delay = await agg._get_aggregation_config(
+ execution_context(pipeline_uuid='test-pipeline'),
+ 'test-pipeline',
+ )
assert delay == 1.5 # Default
@@ -314,6 +454,7 @@ class TestMessageAggregatorAddMessage:
adapter = mock_adapter()
await agg.add_message(
+ execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -353,6 +494,7 @@ class TestMessageAggregatorAddMessage:
adapter = mock_adapter()
await agg.add_message(
+ execution_context=execution_context(pipeline_uuid='test-pipeline'),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -394,6 +536,7 @@ class TestMessageAggregatorAddMessage:
# Add messages up to MAX_BUFFER_MESSAGES
for i in range(aggregator.MAX_BUFFER_MESSAGES):
await agg.add_message(
+ execution_context=execution_context(pipeline_uuid='test-pipeline'),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -405,7 +548,14 @@ class TestMessageAggregatorAddMessage:
)
# Buffer should be flushed (empty or no buffer)
- session_id = agg._get_session_id('test-bot', provider_session.LauncherTypes.PERSON, 12345)
+ context = execution_context(pipeline_uuid='test-pipeline')
+ session_id = agg._get_aggregation_key(
+ context,
+ 'test-bot',
+ provider_session.LauncherTypes.PERSON,
+ 12345,
+ 'test-pipeline',
+ )
assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0
@@ -424,6 +574,7 @@ class TestMessageAggregatorMerge:
adapter = mock_adapter()
pending = aggregator.PendingMessage(
+ execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -451,6 +602,7 @@ class TestMessageAggregatorMerge:
adapter = mock_adapter()
pending1 = aggregator.PendingMessage(
+ execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -462,6 +614,7 @@ class TestMessageAggregatorMerge:
)
pending2 = aggregator.PendingMessage(
+ execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -492,6 +645,7 @@ class TestMessageAggregatorMerge:
adapter = mock_adapter()
pending1 = aggregator.PendingMessage(
+ execution_context=execution_context(pipeline_uuid='test-pipeline-uuid'),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -504,6 +658,7 @@ class TestMessageAggregatorMerge:
)
pending2 = aggregator.PendingMessage(
+ execution_context=execution_context(pipeline_uuid='test-pipeline-uuid'),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -532,7 +687,8 @@ class TestMessageAggregatorFlush:
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
- await agg._flush_buffer('nonexistent-session')
+ context = execution_context()
+ await agg._flush_buffer(aggregation_key(context), context)
# Should not call query_pool
assert not app.query_pool.add_query.called
@@ -550,6 +706,7 @@ class TestMessageAggregatorFlush:
adapter = mock_adapter()
pending = aggregator.PendingMessage(
+ execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -560,17 +717,57 @@ class TestMessageAggregatorFlush:
pipeline_uuid=None,
)
+ context = execution_context()
+ key = aggregation_key(context)
buffer = aggregator.SessionBuffer(
- session_id='test-session',
+ aggregation_key=key,
+ execution_context=context,
messages=[pending],
)
- agg.buffers['test-session'] = buffer
+ agg.buffers[key] = buffer
- await agg._flush_buffer('test-session')
+ await agg._flush_buffer(key, context)
assert app.query_pool.add_query.called
- assert 'test-session' not in agg.buffers
+ assert key not in agg.buffers
+
+ @pytest.mark.asyncio
+ async def test_flush_drops_buffer_when_placement_generation_is_stale(self):
+ """A debounce timer cannot enqueue work after its placement is fenced."""
+ aggregator = get_aggregator_module()
+
+ app = make_aggregator_app()
+ app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError('stale generation')
+ agg = aggregator.MessageAggregator(app)
+ context = execution_context(placement_generation=3)
+ pending = aggregator.PendingMessage(
+ execution_context=context,
+ bot_uuid='test-bot',
+ launcher_type=provider_session.LauncherTypes.PERSON,
+ launcher_id=12345,
+ sender_id=12345,
+ message_event=friend_message_event(text_chain('stale')),
+ message_chain=text_chain('stale'),
+ adapter=mock_adapter(),
+ pipeline_uuid=None,
+ )
+ key = aggregation_key(context)
+ agg.buffers[key] = aggregator.SessionBuffer(
+ aggregation_key=key,
+ execution_context=context,
+ messages=[pending],
+ )
+
+ with pytest.raises(WorkspaceGenerationMismatchError):
+ await agg._flush_buffer(key, context)
+
+ app.workspace_service.get_execution_binding.assert_awaited_once_with(
+ 'workspace-test',
+ expected_generation=3,
+ )
+ app.query_pool.add_query.assert_not_awaited()
+ assert key not in agg.buffers
class TestMessageAggregatorFlushAll:
@@ -603,6 +800,7 @@ class TestMessageAggregatorFlushAll:
# Create two buffers
pending1 = aggregator.PendingMessage(
+ execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -614,6 +812,7 @@ class TestMessageAggregatorFlushAll:
)
pending2 = aggregator.PendingMessage(
+ execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=67890,
@@ -624,14 +823,213 @@ class TestMessageAggregatorFlushAll:
pipeline_uuid=None,
)
- buffer1 = aggregator.SessionBuffer(session_id='session-1', messages=[pending1])
- buffer2 = aggregator.SessionBuffer(session_id='session-2', messages=[pending2])
+ context = execution_context()
+ key1 = aggregation_key(context, launcher_id=12345)
+ key2 = aggregation_key(context, launcher_id=67890)
+ buffer1 = aggregator.SessionBuffer(
+ aggregation_key=key1,
+ execution_context=context,
+ messages=[pending1],
+ )
+ buffer2 = aggregator.SessionBuffer(
+ aggregation_key=key2,
+ execution_context=context,
+ messages=[pending2],
+ )
- agg.buffers['session-1'] = buffer1
- agg.buffers['session-2'] = buffer2
+ agg.buffers[key1] = buffer1
+ agg.buffers[key2] = buffer2
await agg.flush_all()
# Both buffers should be flushed
assert len(agg.buffers) == 0
assert app.query_pool.add_query.call_count == 2
+
+
+class TestMessageAggregatorWorkspaceIsolation:
+ """Regression coverage for fail-closed and cross-workspace behavior."""
+
+ @pytest.mark.asyncio
+ async def test_missing_execution_context_fails_closed(self):
+ app = make_aggregator_app()
+ agg = get_aggregator_module().MessageAggregator(app)
+ kwargs = scoped_message_kwargs(execution_context())
+ kwargs['execution_context'] = None
+
+ with pytest.raises(ExecutionContextRequiredError):
+ await agg.add_message(**kwargs)
+
+ app.query_pool.add_query.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_same_launcher_in_two_workspaces_uses_separate_buffers(self):
+ app = make_aggregator_app()
+ enable_aggregation(app)
+ agg = get_aggregator_module().MessageAggregator(app)
+
+ await agg.add_message(**scoped_message_kwargs(execution_context('workspace-a', pipeline_uuid='test-pipeline')))
+ await agg.add_message(**scoped_message_kwargs(execution_context('workspace-b', pipeline_uuid='test-pipeline')))
+
+ assert len(agg.buffers) == 2
+ assert {key[1] for key in agg.buffers} == {'workspace-a', 'workspace-b'}
+ await agg.flush_all()
+
+ @pytest.mark.asyncio
+ async def test_new_buffer_uses_scope_counter_without_global_scan(self):
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('aggregation admission scanned all buffers')
+
+ def items(self):
+ raise AssertionError('aggregation admission scanned all buffers')
+
+ def values(self):
+ raise AssertionError('aggregation admission scanned all buffers')
+
+ app = make_aggregator_app()
+ enable_aggregation(app)
+ agg = get_aggregator_module().MessageAggregator(app)
+ agg.max_buffers = 2_000
+ agg.max_buffers_per_workspace = 2_000
+ existing = {
+ (
+ 'instance-test',
+ f'workspace-{index}',
+ 1,
+ 'bot',
+ 'pipeline',
+ 'person',
+ index,
+ ): object()
+ for index in range(1_000)
+ }
+ agg.buffers = NoGlobalIterationDict(existing)
+ agg._buffer_counts_by_scope = {key[:3]: 1 for key in existing}
+ context = execution_context(
+ 'workspace-target',
+ pipeline_uuid='test-pipeline',
+ )
+
+ await agg.add_message(**scoped_message_kwargs(context))
+
+ key = aggregation_key(
+ context,
+ pipeline_uuid='test-pipeline',
+ )
+ assert key in agg.buffers
+ assert agg._buffer_counts_by_scope[key[:3]] == 1
+ timer_task = agg.buffers[key].timer_task
+ assert timer_task is not None
+ timer_task.cancel()
+ await asyncio.gather(timer_task, return_exceptions=True)
+ await agg._flush_buffer(key, context)
+ assert key[:3] not in agg._buffer_counts_by_scope
+
+ @pytest.mark.asyncio
+ async def test_same_launcher_in_two_bots_uses_separate_buffers(self):
+ app = make_aggregator_app()
+ enable_aggregation(app)
+ agg = get_aggregator_module().MessageAggregator(app)
+
+ await agg.add_message(
+ **scoped_message_kwargs(execution_context(bot_uuid='bot-a', pipeline_uuid='test-pipeline'))
+ )
+ await agg.add_message(
+ **scoped_message_kwargs(execution_context(bot_uuid='bot-b', pipeline_uuid='test-pipeline'))
+ )
+
+ assert len(agg.buffers) == 2
+ assert {key[3] for key in agg.buffers} == {'bot-a', 'bot-b'}
+ await agg.flush_all()
+
+ @pytest.mark.asyncio
+ async def test_timer_receives_exact_captured_execution_context(self, monkeypatch):
+ app = make_aggregator_app()
+ enable_aggregation(app)
+ agg = get_aggregator_module().MessageAggregator(app)
+ request_value = contextvars.ContextVar('aggregator_request_value', default=None)
+ token = request_value.set('request-scope')
+ observed = []
+
+ async def delayed_flush(*args):
+ observed.append((request_value.get(), args[2]))
+
+ monkeypatch.setattr(agg, '_delayed_flush', delayed_flush)
+ context = execution_context(pipeline_uuid='test-pipeline')
+
+ try:
+ await agg.add_message(**scoped_message_kwargs(context))
+ await asyncio.sleep(0)
+ finally:
+ request_value.reset(token)
+
+ assert observed == [(None, context)]
+ await agg.flush_all()
+
+ @pytest.mark.asyncio
+ async def test_delayed_flush_opens_explicit_workspace_uow(self, monkeypatch):
+ app = make_aggregator_app()
+ app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
+ scopes = []
+
+ @asynccontextmanager
+ async def tenant_uow(workspace_uuid):
+ scopes.append(workspace_uuid)
+ yield
+
+ app.persistence_mgr.tenant_uow = tenant_uow
+ agg = get_aggregator_module().MessageAggregator(app)
+ flush = AsyncMock()
+ monkeypatch.setattr(agg, '_flush_buffer', flush)
+ context = execution_context('workspace-a', pipeline_uuid='test-pipeline')
+ key = aggregation_key(context, pipeline_uuid='test-pipeline')
+
+ await agg._delayed_flush(key, 0, context)
+
+ assert scopes == ['workspace-a']
+ flush.assert_awaited_once_with(key, context)
+
+ @pytest.mark.asyncio
+ async def test_flush_rejects_context_from_another_workspace(self):
+ app = make_aggregator_app()
+ enable_aggregation(app)
+ agg = get_aggregator_module().MessageAggregator(app)
+ context_a = execution_context('workspace-a', pipeline_uuid='test-pipeline')
+ context_b = execution_context('workspace-b', pipeline_uuid='test-pipeline')
+ await agg.add_message(**scoped_message_kwargs(context_a))
+ key = next(iter(agg.buffers))
+
+ with pytest.raises(ExecutionContextMismatchError):
+ await agg._flush_buffer(key, context_b)
+
+ assert key in agg.buffers
+ await agg.flush_all()
+
+ def test_merge_rejects_messages_from_different_workspaces(self):
+ app = make_aggregator_app()
+ agg = get_aggregator_module().MessageAggregator(app)
+ aggregator = get_aggregator_module()
+
+ with pytest.raises(ExecutionContextMismatchError):
+ agg._merge_messages(
+ [
+ aggregator.PendingMessage(**scoped_message_kwargs(execution_context('workspace-a'))),
+ aggregator.PendingMessage(**scoped_message_kwargs(execution_context('workspace-b'))),
+ ]
+ )
+
+ @pytest.mark.asyncio
+ async def test_flush_all_preserves_each_workspace_context(self):
+ app = make_aggregator_app()
+ enable_aggregation(app)
+ agg = get_aggregator_module().MessageAggregator(app)
+ await agg.add_message(**scoped_message_kwargs(execution_context('workspace-a', pipeline_uuid='test-pipeline')))
+ await agg.add_message(**scoped_message_kwargs(execution_context('workspace-b', pipeline_uuid='test-pipeline')))
+
+ await agg.flush_all()
+
+ forwarded_workspaces = {
+ call.kwargs['execution_context'].workspace_uuid for call in app.query_pool.add_query.await_args_list
+ }
+ assert forwarded_workspaces == {'workspace-a', 'workspace-b'}
diff --git a/tests/unit_tests/pipeline/test_chat_handler.py b/tests/unit_tests/pipeline/test_chat_handler.py
index c8a923d78..2301ec23c 100644
--- a/tests/unit_tests/pipeline/test_chat_handler.py
+++ b/tests/unit_tests/pipeline/test_chat_handler.py
@@ -464,3 +464,13 @@ class TestChatHandlerHelper:
handler = chat.ChatMessageHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
+
+ def test_response_size_limit_uses_instance_config(self, fake_app):
+ from langbot_plugin.api.entities.builtin.provider.message import Message
+
+ fake_app.instance_config.data['system'] = {'response_limits': {'max_generated_chars': 4}}
+ chat = get_chat_handler()
+ handler = chat.ChatMessageHandler(fake_app)
+
+ with pytest.raises(RuntimeError, match='configured limit'):
+ handler._check_response_size(Message(role='assistant', content='12345'))
diff --git a/tests/unit_tests/pipeline/test_chat_session_limit.py b/tests/unit_tests/pipeline/test_chat_session_limit.py
index ef351b29f..1739c9623 100644
--- a/tests/unit_tests/pipeline/test_chat_session_limit.py
+++ b/tests/unit_tests/pipeline/test_chat_session_limit.py
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, Mock
import pytest
import yaml
+from langbot_plugin.api.entities.builtin.provider import session as provider_session
def _preproc_module():
@@ -43,7 +44,12 @@ def _prompt_preprocessing_context(default_prompt=None, prompt=None):
async def _run_preprocessor(mock_app, sample_query, conversation):
- session = SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
+ session = provider_session.Session(
+ launcher_type=sample_query.launcher_type,
+ launcher_id=sample_query.launcher_id,
+ sender_id=sample_query.sender_id,
+ bot_uuid=sample_query.bot_uuid,
+ )
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
diff --git a/tests/unit_tests/pipeline/test_controller_tenancy.py b/tests/unit_tests/pipeline/test_controller_tenancy.py
new file mode 100644
index 000000000..b99e54bb2
--- /dev/null
+++ b/tests/unit_tests/pipeline/test_controller_tenancy.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, Mock
+
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
+from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
+from langbot.pkg.pipeline.controller import Controller
+from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
+
+
+def _prepare_scheduler(mock_app):
+ query_pool = MagicMock()
+ query_pool.remove_query = AsyncMock(return_value=True)
+ query_pool.__aenter__ = AsyncMock(return_value=query_pool)
+ query_pool.__aexit__ = AsyncMock(return_value=None)
+ query_pool.condition = SimpleNamespace(notify_all=Mock())
+ mock_app.query_pool = query_pool
+
+ session = SimpleNamespace(_semaphore=SimpleNamespace(release=Mock()))
+ mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
+ mock_app.pipeline_mgr = SimpleNamespace(get_pipeline_by_uuid=AsyncMock())
+ return query_pool, session
+
+
+@pytest.mark.asyncio
+async def test_controller_drops_stale_query_before_pipeline_lookup(
+ mock_app,
+ sample_query,
+):
+ query_pool, session = _prepare_scheduler(mock_app)
+ mock_app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError('stale generation')
+ controller = Controller(mock_app)
+ initial_slots = controller.semaphore._value
+
+ await controller._process_query(sample_query)
+
+ mock_app.workspace_service.get_execution_binding.assert_awaited_once_with(
+ 'test-workspace',
+ expected_generation=1,
+ )
+ mock_app.pipeline_mgr.get_pipeline_by_uuid.assert_not_awaited()
+ query_pool.remove_query.assert_awaited_once_with(sample_query)
+ session._semaphore.release.assert_called_once_with()
+ query_pool.condition.notify_all.assert_called_once_with()
+ assert controller.semaphore._value == initial_slots
+
+
+@pytest.mark.asyncio
+async def test_cloud_controller_releases_database_connection_during_pipeline_wait(
+ tmp_path,
+ mock_app,
+ sample_query,
+):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "pipeline-short-scope.db"}')
+ table = sa.Table('pipeline_scope_probe', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ checked_out = 0
+
+ def on_checkout(*_args):
+ nonlocal checked_out
+ checked_out += 1
+
+ def on_checkin(*_args):
+ nonlocal checked_out
+ checked_out -= 1
+
+ sa.event.listen(engine.sync_engine, 'checkout', on_checkout)
+ sa.event.listen(engine.sync_engine, 'checkin', on_checkin)
+ try:
+ async with engine.begin() as conn:
+ await conn.run_sync(table.metadata.create_all)
+
+ _prepare_scheduler(mock_app)
+ mock_app.persistence_mgr = manager
+ pipeline_waiting = asyncio.Event()
+ release_pipeline = asyncio.Event()
+
+ async def get_binding(*_args, **_kwargs):
+ assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
+ assert manager.current_session() is None
+ await manager.execute_async(sa.select(table.c.id))
+ assert manager.current_session() is None
+ return SimpleNamespace(
+ instance_uuid='test-instance',
+ workspace_uuid='test-workspace',
+ placement_generation=1,
+ )
+
+ async def run_pipeline(_query):
+ await manager.execute_async(sa.select(table.c.id))
+ assert manager.current_session() is None
+ pipeline_waiting.set()
+ await release_pipeline.wait()
+ assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
+ assert manager.current_session() is None
+
+ runtime_pipeline = SimpleNamespace(run=AsyncMock(side_effect=run_pipeline))
+
+ async def get_pipeline(*_args, **_kwargs):
+ await manager.execute_async(sa.select(table.c.id))
+ assert manager.current_session() is None
+ return runtime_pipeline
+
+ mock_app.workspace_service.get_execution_binding = AsyncMock(side_effect=get_binding)
+ mock_app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(side_effect=get_pipeline)
+ controller = Controller(mock_app)
+
+ task = asyncio.create_task(controller._process_query(sample_query))
+ await asyncio.wait_for(pipeline_waiting.wait(), timeout=2)
+ assert checked_out == 0
+ assert not task.done()
+ release_pipeline.set()
+ await asyncio.wait_for(task, timeout=2)
+ assert checked_out == 0
+ runtime_pipeline.run.assert_awaited_once_with(sample_query)
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_controller_revalidates_generation_before_running_pipeline(
+ mock_app,
+ sample_query,
+):
+ query_pool, session = _prepare_scheduler(mock_app)
+ runtime_pipeline = SimpleNamespace(run=AsyncMock())
+ mock_app.pipeline_mgr.get_pipeline_by_uuid.return_value = runtime_pipeline
+ controller = Controller(mock_app)
+
+ await controller._process_query(sample_query)
+
+ mock_app.workspace_service.get_execution_binding.assert_awaited_once_with(
+ 'test-workspace',
+ expected_generation=1,
+ )
+ runtime_pipeline.run.assert_awaited_once_with(sample_query)
+ query_pool.remove_query.assert_awaited_once_with(sample_query)
+ session._semaphore.release.assert_called_once_with()
diff --git a/tests/unit_tests/pipeline/test_longtext_image.py b/tests/unit_tests/pipeline/test_longtext_image.py
new file mode 100644
index 000000000..f0d703e8e
--- /dev/null
+++ b/tests/unit_tests/pipeline/test_longtext_image.py
@@ -0,0 +1,50 @@
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+import langbot_plugin.api.entities.builtin.platform.message as platform_message
+from langbot.pkg.pipeline.longtext.strategies.image import Text2ImageStrategy
+from langbot.pkg.pipeline.longtext.strategies import image
+
+
+class _WideFont:
+ def getlength(self, text: str) -> int:
+ return len(text) * 100
+
+
+def test_image_strategy_line_split_always_consumes_input():
+ strategy = Text2ImageStrategy(Mock())
+
+ lines = strategy._split_text_lines('abc', 1, _WideFont())
+
+ assert lines == ['a', 'b', 'c']
+ assert ''.join(lines) == 'abc'
+
+
+def test_image_strategy_numeric_boundaries_are_found_in_linear_order():
+ strategy = Text2ImageStrategy(Mock())
+
+ assert strategy.indexNumber('a12-b12-c345') == [['12', 1], ['12', 5], ['345', 9]]
+
+
+def test_image_strategy_rejects_unbounded_line_count_before_allocating_canvas(monkeypatch):
+ strategy = Text2ImageStrategy(Mock())
+ monkeypatch.setattr(image, '_MAX_TEXT_TO_IMAGE_LINES', 2)
+
+ with pytest.raises(ValueError, match='2 lines'):
+ strategy._split_text_lines('one\ntwo\nthree', 1000, _WideFont())
+
+
+@pytest.mark.asyncio
+async def test_image_strategy_falls_back_to_forward_for_oversized_text(monkeypatch):
+ app = Mock()
+ strategy = Text2ImageStrategy(app)
+ monkeypatch.setattr(image, '_MAX_TEXT_TO_IMAGE_CHARS', 4)
+ query = SimpleNamespace(adapter=SimpleNamespace(bot_account_id='bot'))
+
+ components = await strategy.process('12345', query)
+
+ assert len(components) == 1
+ assert isinstance(components[0], platform_message.Forward)
+ app.logger.warning.assert_called_once()
diff --git a/tests/unit_tests/pipeline/test_n8nsvapi.py b/tests/unit_tests/pipeline/test_n8nsvapi.py
index 787472375..54266aec4 100644
--- a/tests/unit_tests/pipeline/test_n8nsvapi.py
+++ b/tests/unit_tests/pipeline/test_n8nsvapi.py
@@ -37,7 +37,11 @@ _saved_modules = {name: sys.modules.get(name) for name in _import_stubs}
for _name, _stub in _import_stubs.items():
sys.modules[_name] = _stub
try:
- from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner
+ from langbot.pkg.provider.runners.n8nsvapi import (
+ N8nAPIError,
+ N8nServiceAPIRunner,
+ _MAX_N8N_RESPONSE_CHARS,
+ )
finally:
for _name, _original in _saved_modules.items():
if _original is None:
@@ -230,6 +234,17 @@ async def test_plain_json_non_dict_response():
assert chunks[0].content == '["a", "b"]'
+@pytest.mark.asyncio
+async def test_response_size_is_bounded():
+ runner = make_runner()
+
+ with pytest.raises(N8nAPIError, match='exceeds the runtime limit'):
+ await collect_chunks(
+ runner,
+ [b'x' * (_MAX_N8N_RESPONSE_CHARS + 1)],
+ )
+
+
@pytest.mark.asyncio
async def test_invalid_json_returns_raw_text():
"""Non-JSON response returns raw text as-is."""
diff --git a/tests/unit_tests/pipeline/test_pipeline_service.py b/tests/unit_tests/pipeline/test_pipeline_service.py
index b862c3ff4..305d640b0 100644
--- a/tests/unit_tests/pipeline/test_pipeline_service.py
+++ b/tests/unit_tests/pipeline/test_pipeline_service.py
@@ -5,6 +5,9 @@ import pytest
from langbot.pkg.api.http.service.pipeline import PipelineService
+WORKSPACE_UUID = 'workspace-a'
+
+
@pytest.mark.asyncio
async def test_update_pipeline_filters_protected_fields_without_mutating_input(mock_app):
service = PipelineService(mock_app)
@@ -27,7 +30,7 @@ async def test_update_pipeline_filters_protected_fields_without_mutating_input(m
}
original_pipeline_data = pipeline_data.copy()
- await service.update_pipeline('pipeline-uuid', pipeline_data)
+ await service.update_pipeline(WORKSPACE_UUID, 'pipeline-uuid', pipeline_data)
assert pipeline_data == original_pipeline_data
@@ -36,8 +39,9 @@ async def test_update_pipeline_filters_protected_fields_without_mutating_input(m
assert updated_fields == {'name'}
mock_app.bot_service.update_bot.assert_awaited_once_with(
+ WORKSPACE_UUID,
'bot-uuid',
{'use_pipeline_name': 'Updated pipeline'},
)
- mock_app.pipeline_mgr.remove_pipeline.assert_awaited_once_with('pipeline-uuid')
- mock_app.pipeline_mgr.load_pipeline.assert_awaited_once_with(loaded_pipeline)
+ mock_app.pipeline_mgr.remove_pipeline.assert_awaited_once_with('workspace-a', 'pipeline-uuid')
+ mock_app.pipeline_mgr.load_pipeline.assert_awaited_once_with('workspace-a', loaded_pipeline)
diff --git a/tests/unit_tests/pipeline/test_pipelinemgr.py b/tests/unit_tests/pipeline/test_pipelinemgr.py
index 49984542c..5a290a8e1 100644
--- a/tests/unit_tests/pipeline/test_pipelinemgr.py
+++ b/tests/unit_tests/pipeline/test_pipelinemgr.py
@@ -3,9 +3,23 @@ PipelineManager unit tests
"""
import pytest
+from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from importlib import import_module
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
+from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError, WorkspaceInvariantError
+
+
+def _context(pipeline_uuid: str = 'test-uuid') -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid='test-instance',
+ workspace_uuid='test-workspace',
+ placement_generation=1,
+ pipeline_uuid=pipeline_uuid,
+ )
+
def get_pipelinemgr_module():
return import_module('langbot.pkg.pipeline.pipelinemgr')
@@ -37,6 +51,95 @@ async def test_pipeline_manager_initialize(mock_app):
assert len(manager.pipelines) == 0
+@pytest.mark.asyncio
+async def test_cloud_startup_reuses_validated_pipeline_binding(mock_app):
+ class TenantUow:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args):
+ return False
+
+ binding = WorkspaceExecutionBinding(
+ instance_uuid='test-instance',
+ workspace_uuid='test-workspace',
+ placement_generation=1,
+ write_fenced=False,
+ state='active',
+ )
+ pipeline_entity = Mock(
+ uuid='test-uuid',
+ workspace_uuid='test-workspace',
+ stages=[],
+ config={},
+ extensions_preferences={},
+ )
+ mock_app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
+ mock_app.persistence_mgr.tenant_uow = lambda _workspace_uuid: TenantUow()
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[pipeline_entity])))
+ mock_app.workspace_service.list_active_execution_bindings = AsyncMock(return_value=[binding])
+ mock_app.workspace_service.get_execution_binding = AsyncMock(
+ side_effect=AssertionError('startup pipeline loader repeated a validated binding lookup')
+ )
+ manager = get_pipelinemgr_module().PipelineManager(mock_app)
+ manager.stage_dict = {}
+
+ await manager.load_pipelines_from_db()
+
+ assert len(manager.pipelines) == 1
+ mock_app.workspace_service.get_execution_binding.assert_not_awaited()
+
+
+def test_generation_advance_prunes_superseded_workspace_pipelines(mock_app):
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('generation advance scanned every pipeline')
+
+ def items(self):
+ raise AssertionError('generation advance scanned every pipeline')
+
+ def values(self):
+ raise AssertionError('generation advance scanned every pipeline')
+
+ pipelinemgr = get_pipelinemgr_module()
+ manager = pipelinemgr.PipelineManager(mock_app)
+ old_context = _context()
+ next_context = ExecutionContext(
+ instance_uuid=old_context.instance_uuid,
+ workspace_uuid=old_context.workspace_uuid,
+ placement_generation=2,
+ pipeline_uuid=old_context.pipeline_uuid,
+ )
+ old_pipeline = SimpleNamespace(
+ execution_context=old_context,
+ workspace_uuid=old_context.workspace_uuid,
+ placement_generation=old_context.placement_generation,
+ )
+ other_pipelines = [
+ SimpleNamespace(
+ execution_context=ExecutionContext(
+ instance_uuid='test-instance',
+ workspace_uuid=f'workspace-{index}',
+ placement_generation=1,
+ pipeline_uuid=f'pipeline-{index}',
+ ),
+ workspace_uuid=f'workspace-{index}',
+ placement_generation=1,
+ )
+ for index in range(1_000)
+ ]
+ manager.pipelines = [old_pipeline, *other_pipelines]
+
+ manager._observe_execution_context(old_context)
+ manager._pipelines_by_key = NoGlobalIterationDict(manager._pipelines_by_key)
+ manager._observe_execution_context(next_context)
+ manager._pipelines_by_key = dict(manager._pipelines_by_key)
+
+ assert manager.pipelines == other_pipelines
+ with pytest.raises(WorkspaceInvariantError, match='rolled back'):
+ manager._observe_execution_context(old_context)
+
+
@pytest.mark.asyncio
async def test_load_pipeline(mock_app):
"""Test loading a single pipeline"""
@@ -51,11 +154,12 @@ async def test_load_pipeline(mock_app):
# Create test pipeline entity
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.uuid = 'test-uuid'
+ pipeline_entity.workspace_uuid = 'test-workspace'
pipeline_entity.stages = []
pipeline_entity.config = {'test': 'config'}
pipeline_entity.extensions_preferences = {'plugins': []}
- await manager.load_pipeline(pipeline_entity)
+ await manager.load_pipeline(_context(), pipeline_entity)
assert len(manager.pipelines) == 1
assert manager.pipelines[0].pipeline_entity.uuid == 'test-uuid'
@@ -75,19 +179,20 @@ async def test_get_pipeline_by_uuid(mock_app):
# Create and add test pipeline
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.uuid = 'test-uuid'
+ pipeline_entity.workspace_uuid = 'test-workspace'
pipeline_entity.stages = []
pipeline_entity.config = {}
pipeline_entity.extensions_preferences = {'plugins': []}
- await manager.load_pipeline(pipeline_entity)
+ await manager.load_pipeline(_context(), pipeline_entity)
# Test retrieval
- result = await manager.get_pipeline_by_uuid('test-uuid')
+ result = await manager.get_pipeline_by_uuid(_context(), 'test-uuid')
assert result is not None
assert result.pipeline_entity.uuid == 'test-uuid'
# Test non-existent UUID
- result = await manager.get_pipeline_by_uuid('non-existent')
+ result = await manager.get_pipeline_by_uuid(_context('non-existent'), 'non-existent')
assert result is None
@@ -105,15 +210,16 @@ async def test_remove_pipeline(mock_app):
# Create and add test pipeline
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
pipeline_entity.uuid = 'test-uuid'
+ pipeline_entity.workspace_uuid = 'test-workspace'
pipeline_entity.stages = []
pipeline_entity.config = {}
pipeline_entity.extensions_preferences = {'plugins': []}
- await manager.load_pipeline(pipeline_entity)
+ await manager.load_pipeline(_context(), pipeline_entity)
assert len(manager.pipelines) == 1
# Remove pipeline
- await manager.remove_pipeline('test-uuid')
+ await manager.remove_pipeline(_context(), 'test-uuid')
assert len(manager.pipelines) == 0
@@ -143,25 +249,104 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
# Create pipeline entity
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
+ pipeline_entity.uuid = 'test-pipeline-uuid'
+ pipeline_entity.workspace_uuid = 'test-workspace'
pipeline_entity.config = sample_query.pipeline_config
pipeline_entity.extensions_preferences = {'plugins': []}
# Create runtime pipeline
- runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [stage_container])
+ runtime_pipeline = pipelinemgr.RuntimePipeline(
+ mock_app,
+ pipeline_entity,
+ [stage_container],
+ _context('test-pipeline-uuid'),
+ )
# Mock plugin connector
event_ctx = Mock()
event_ctx.is_prevented_default = Mock(return_value=False)
mock_app.plugin_connector.emit_event = AsyncMock(return_value=event_ctx)
- # Add query to cached_queries to prevent KeyError in finally block
- mock_app.query_pool.cached_queries[sample_query.query_id] = sample_query
-
# Execute pipeline
await runtime_pipeline.run(sample_query)
# Verify stage was called
mock_stage.process.assert_called_once()
+ mock_app.query_pool.remove_query.assert_awaited_once_with(sample_query)
+
+
+@pytest.mark.asyncio
+async def test_runtime_pipeline_rejects_stale_generation_before_side_effects(
+ mock_app,
+ sample_query,
+):
+ pipelinemgr = get_pipelinemgr_module()
+ persistence_pipeline = get_persistence_pipeline_module()
+ pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
+ pipeline_entity.uuid = 'test-pipeline-uuid'
+ pipeline_entity.workspace_uuid = 'test-workspace'
+ pipeline_entity.config = sample_query.pipeline_config
+ pipeline_entity.extensions_preferences = {'plugins': []}
+ runtime_pipeline = pipelinemgr.RuntimePipeline(
+ mock_app,
+ pipeline_entity,
+ [],
+ _context('test-pipeline-uuid'),
+ )
+ mock_app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError('stale generation')
+
+ with pytest.raises(WorkspaceGenerationMismatchError):
+ await runtime_pipeline.run(sample_query)
+
+ mock_app.plugin_connector.emit_event.assert_not_awaited()
+ sample_query.adapter.reply_message.assert_not_awaited()
+ sample_query.adapter.reply_message_chunk.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_runtime_pipeline_revalidates_after_awaited_stage(
+ mock_app,
+ sample_query,
+):
+ pipelinemgr = get_pipelinemgr_module()
+ stage = get_stage_module()
+ persistence_pipeline = get_persistence_pipeline_module()
+ entities = get_entities_module()
+ pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
+ pipeline_entity.uuid = 'test-pipeline-uuid'
+ pipeline_entity.workspace_uuid = 'test-workspace'
+ pipeline_entity.config = sample_query.pipeline_config
+ pipeline_entity.extensions_preferences = {'plugins': []}
+
+ result = entities.StageProcessResult(
+ result_type=entities.ResultType.CONTINUE,
+ new_query=sample_query,
+ user_notice='must not be sent',
+ console_notice='',
+ debug_notice='',
+ error_notice='',
+ )
+
+ async def stage_process(*_args):
+ mock_app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError(
+ 'generation changed during stage'
+ )
+ return result
+
+ mock_stage = Mock(spec=stage.PipelineStage)
+ mock_stage.process = Mock(side_effect=stage_process)
+ runtime_pipeline = pipelinemgr.RuntimePipeline(
+ mock_app,
+ pipeline_entity,
+ [pipelinemgr.StageInstContainer(inst_name='TestStage', inst=mock_stage)],
+ _context('test-pipeline-uuid'),
+ )
+
+ with pytest.raises(WorkspaceGenerationMismatchError):
+ await runtime_pipeline._execute_from_stage(0, sample_query)
+
+ sample_query.adapter.reply_message.assert_not_awaited()
+ sample_query.adapter.reply_message_chunk.assert_not_awaited()
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
@@ -170,6 +355,8 @@ def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
+ pipeline_entity.uuid = 'test-uuid'
+ pipeline_entity.workspace_uuid = 'test-workspace'
pipeline_entity.config = {
'ai': {
'local-agent': {
@@ -183,7 +370,7 @@ def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
'mcp_resource_agent_read_enabled': True,
}
- runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
+ runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [], _context())
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}]
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
@@ -195,13 +382,15 @@ def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
persistence_pipeline = get_persistence_pipeline_module()
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
+ pipeline_entity.uuid = 'test-uuid'
+ pipeline_entity.workspace_uuid = 'test-workspace'
pipeline_entity.config = {'ai': {'local-agent': {}}}
pipeline_entity.extensions_preferences = {
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
'mcp_resource_agent_read_enabled': False,
}
- runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
+ runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [], _context())
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}]
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
diff --git a/tests/unit_tests/pipeline/test_pool.py b/tests/unit_tests/pipeline/test_pool.py
index 86515e7f0..853738512 100644
--- a/tests/unit_tests/pipeline/test_pool.py
+++ b/tests/unit_tests/pipeline/test_pool.py
@@ -6,10 +6,52 @@ Tests query management, ID generation, and async context handling.
from __future__ import annotations
+import uuid
+from types import SimpleNamespace
+
import pytest
from unittest.mock import Mock, patch
-from langbot.pkg.pipeline.pool import QueryPool
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.pipeline.pool import (
+ ExecutionContextMismatchError,
+ ExecutionContextRequiredError,
+ QueryNotFoundError,
+ QueryPool,
+ QueryPoolCapacityError,
+ get_query_execution_context,
+)
+
+
+TEST_CONTEXT = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+)
+
+
+def oss_pool():
+ """Build the explicit singleton resolver used by the OSS compatibility path."""
+ return QueryPool(singleton_context_resolver=lambda: TEST_CONTEXT)
+
+
+async def add_scoped_mock_query(pool, context, *, bot_uuid='bot-a'):
+ """Create a Query through the real pool while keeping SDK details mocked."""
+ query = Mock()
+ query.bot_uuid = bot_uuid
+ query.pipeline_uuid = None
+ query.query_id = pool.query_id_counter
+ with patch('langbot.pkg.pipeline.pool.pipeline_query.Query', return_value=query):
+ return await pool.add_query(
+ bot_uuid=bot_uuid,
+ launcher_type=Mock(),
+ launcher_id='launcher-1',
+ sender_id='sender-1',
+ message_event=Mock(),
+ message_chain=Mock(),
+ adapter=Mock(),
+ execution_context=context,
+ )
pytestmark = pytest.mark.asyncio
@@ -39,7 +81,7 @@ class TestQueryPoolAddQuery:
async def test_add_query_adds_query_with_id(self):
"""add_query creates, stores, and caches a Query with the correct ID."""
- pool = QueryPool()
+ pool = oss_pool()
# Mock Query creation
mock_query = Mock()
@@ -62,12 +104,12 @@ class TestQueryPoolAddQuery:
# Query is added to list and cache
assert pool.queries[0] is mock_query
- assert pool.cached_queries[0] is mock_query
+ assert pool.cached_queries[('workspace-test', mock_query.query_uuid)] is mock_query
assert mock_query.query_id == 0
async def test_add_query_increments_counter(self):
"""Each add_query increments the counter."""
- pool = QueryPool()
+ pool = oss_pool()
mock_query1 = Mock()
mock_query1.query_id = 0
@@ -103,7 +145,7 @@ class TestQueryPoolAddQuery:
async def test_add_query_appends_to_list(self):
"""Query is appended to queries list."""
- pool = QueryPool()
+ pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
@@ -126,7 +168,7 @@ class TestQueryPoolAddQuery:
async def test_add_query_caches_query(self):
"""Query is cached by query_id."""
- pool = QueryPool()
+ pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
@@ -144,12 +186,13 @@ class TestQueryPoolAddQuery:
adapter=Mock(),
)
- assert 0 in pool.cached_queries
- assert pool.cached_queries[0] is mock_query
+ cache_key = ('workspace-test', mock_query.query_uuid)
+ assert cache_key in pool.cached_queries
+ assert pool.cached_queries[cache_key] is mock_query
async def test_add_query_with_pipeline_uuid(self):
"""Query can have pipeline_uuid set."""
- pool = QueryPool()
+ pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
@@ -175,7 +218,7 @@ class TestQueryPoolAddQuery:
async def test_add_query_sets_routed_by_rule_variable(self):
"""Query has _routed_by_rule variable."""
- pool = QueryPool()
+ pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
@@ -201,7 +244,7 @@ class TestQueryPoolAddQuery:
async def test_add_query_notifier_condition(self):
"""add_query notifies waiting consumers."""
- pool = QueryPool()
+ pool = oss_pool()
mock_query = Mock()
mock_query.query_id = 0
@@ -237,7 +280,7 @@ class TestQueryPoolContext:
async def test_aenter_acquires_lock(self):
"""__aenter__ acquires the pool lock."""
- pool = QueryPool()
+ pool = oss_pool()
async with pool as p:
# Lock is acquired
@@ -260,7 +303,7 @@ class TestQueryPoolEdgeCases:
async def test_multiple_queries_cached_correctly(self):
"""Multiple queries are cached separately."""
- pool = QueryPool()
+ pool = oss_pool()
mock_queries = []
for i in range(5):
@@ -287,4 +330,159 @@ class TestQueryPoolEdgeCases:
# Each query is cached by its ID
for i in range(5):
- assert pool.cached_queries[i] is mock_queries[i]
+ query = mock_queries[i]
+ assert pool.cached_queries[('workspace-test', query.query_uuid)] is query
+
+
+class TestQueryPoolWorkspaceIsolation:
+ """Regression coverage for trusted scope and scoped cache indexes."""
+
+ async def test_add_query_requires_execution_context_by_default(self):
+ with pytest.raises(ExecutionContextRequiredError):
+ await QueryPool().add_query(
+ bot_uuid='bot-a',
+ launcher_type=Mock(),
+ launcher_id='launcher-1',
+ sender_id='sender-1',
+ message_event=Mock(),
+ message_chain=Mock(),
+ adapter=Mock(),
+ )
+
+ async def test_serialized_scope_fields_are_not_trusted_context(self):
+ forged_query = SimpleNamespace(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ bot_uuid='bot-a',
+ pipeline_uuid=None,
+ query_uuid='forged-query',
+ )
+
+ with pytest.raises(ExecutionContextRequiredError):
+ get_query_execution_context(forged_query)
+
+ async def test_query_lookup_is_workspace_scoped(self):
+ pool = QueryPool()
+ query = await add_scoped_mock_query(pool, TEST_CONTEXT)
+
+ uuid.UUID(query.query_uuid)
+ assert await pool.get_query('workspace-test', query.query_uuid) is query
+ assert await pool.get_query('workspace-other', query.query_uuid) is None
+ assert await pool.get_query_by_legacy_id('workspace-test', 0) is query
+ assert await pool.get_query_by_legacy_id('workspace-other', 0) is None
+ with pytest.raises(QueryNotFoundError):
+ await pool.require_query('workspace-other', query.query_uuid)
+
+ async def test_cache_separates_same_opaque_id_between_workspaces(self, monkeypatch):
+ fixed_uuid = uuid.UUID('11111111-1111-4111-8111-111111111111')
+ monkeypatch.setattr('langbot.pkg.pipeline.pool.uuid.uuid4', lambda: fixed_uuid)
+ pool = QueryPool()
+ context_a = TEST_CONTEXT
+ context_b = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-other',
+ placement_generation=1,
+ )
+
+ query_a = await add_scoped_mock_query(pool, context_a)
+ query_b = await add_scoped_mock_query(pool, context_b)
+
+ assert query_a.query_uuid == query_b.query_uuid
+ assert await pool.get_query('workspace-test', query_a.query_uuid) is query_a
+ assert await pool.get_query('workspace-other', query_b.query_uuid) is query_b
+
+ async def test_remove_query_cleans_both_scoped_indexes(self):
+ pool = QueryPool()
+ query = await add_scoped_mock_query(pool, TEST_CONTEXT)
+
+ assert await pool.remove_query(query) is True
+ assert await pool.get_query('workspace-test', query.query_uuid) is None
+ assert await pool.get_query_by_legacy_id('workspace-test', query.query_id) is None
+ assert await pool.remove_query(query) is False
+
+ async def test_context_cannot_substitute_bot_identity(self):
+ context = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ bot_uuid='bot-b',
+ )
+
+ with pytest.raises(ExecutionContextMismatchError):
+ await add_scoped_mock_query(QueryPool(), context, bot_uuid='bot-a')
+
+ async def test_query_counter_is_scoped_by_workspace_and_generation(self):
+ pool = QueryPool()
+ workspace_a = TEST_CONTEXT
+ workspace_b = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-other',
+ placement_generation=1,
+ )
+ next_generation = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=2,
+ )
+
+ await add_scoped_mock_query(pool, workspace_a)
+ await add_scoped_mock_query(pool, workspace_a)
+ await add_scoped_mock_query(pool, workspace_b)
+
+ assert pool.get_query_count(workspace_a) == 2
+ assert pool.get_query_count(workspace_b) == 1
+ assert pool.get_query_count(next_generation) == 0
+ assert pool.query_id_counter == 3
+
+ async def test_workspace_capacity_discards_oldest_queued_query(self):
+ pool = QueryPool(max_queries=3, max_queries_per_workspace=2)
+ first = await add_scoped_mock_query(pool, TEST_CONTEXT)
+ second = await add_scoped_mock_query(pool, TEST_CONTEXT)
+ third = await add_scoped_mock_query(pool, TEST_CONTEXT)
+
+ assert await pool.get_query(TEST_CONTEXT.workspace_uuid, first.query_uuid) is None
+ assert await pool.get_query(TEST_CONTEXT.workspace_uuid, second.query_uuid) is second
+ assert await pool.get_query(TEST_CONTEXT.workspace_uuid, third.query_uuid) is third
+ assert pool.active_query_count_by_workspace == {TEST_CONTEXT.workspace_uuid: 2}
+ assert pool.get_dropped_query_count(TEST_CONTEXT) == 1
+
+ async def test_capacity_rejects_when_every_query_is_already_running(self):
+ pool = QueryPool(max_queries=1, max_queries_per_workspace=1)
+ running = await add_scoped_mock_query(pool, TEST_CONTEXT)
+ async with pool:
+ pool.mark_query_running_locked(running)
+
+ with pytest.raises(QueryPoolCapacityError):
+ await add_scoped_mock_query(pool, TEST_CONTEXT)
+
+ assert pool.active_query_count_by_workspace == {TEST_CONTEXT.workspace_uuid: 1}
+
+ async def test_mark_query_running_keeps_active_indexes_but_removes_queue_entry(self):
+ pool = QueryPool(max_queries=1, max_queries_per_workspace=1)
+ running = await add_scoped_mock_query(pool, TEST_CONTEXT)
+
+ async with pool:
+ pool.mark_query_running_locked(running)
+
+ assert running not in pool.queries
+ assert await pool.get_query(TEST_CONTEXT.workspace_uuid, running.query_uuid) is running
+ assert pool.active_query_count_by_workspace == {TEST_CONTEXT.workspace_uuid: 1}
+
+ async def test_historical_workspace_counters_are_bounded(self):
+ pool = QueryPool(max_queries=2, max_queries_per_workspace=1)
+ contexts = [
+ ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid=f'workspace-{index}',
+ placement_generation=1,
+ )
+ for index in range(3)
+ ]
+
+ for context in contexts:
+ query = await add_scoped_mock_query(pool, context)
+ await pool.remove_query(query)
+
+ assert len(pool.query_count_by_scope) == 2
+ assert (contexts[0].instance_uuid, contexts[0].workspace_uuid, 1) not in pool.query_count_by_scope
diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py
index 15bf60801..c28858959 100644
--- a/tests/unit_tests/pipeline/test_preproc.py
+++ b/tests/unit_tests/pipeline/test_preproc.py
@@ -16,6 +16,8 @@ from unittest.mock import AsyncMock, Mock
from importlib import import_module
from types import SimpleNamespace
+from langbot_plugin.api.entities.builtin.provider import session as provider_session
+
from tests.factories import (
FakeApp,
text_query,
@@ -35,6 +37,20 @@ def get_entities_module():
return import_module('langbot.pkg.pipeline.entities')
+def make_session(
+ launcher_type: provider_session.LauncherTypes = provider_session.LauncherTypes.PERSON,
+ launcher_id: int = 12345,
+) -> provider_session.Session:
+ """Build a scope-aware Session that matches the shared Query factory."""
+
+ return provider_session.Session(
+ launcher_type=launcher_type,
+ launcher_id=launcher_id,
+ sender_id=12345,
+ bot_uuid='test-bot-uuid',
+ )
+
+
class TestPreProcessorNormalText:
"""Tests for normal text message preprocessing."""
@@ -46,9 +62,7 @@ class TestPreProcessorNormalText:
app = FakeApp()
# Mock session manager to return a session
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
# Mock conversation
@@ -92,9 +106,7 @@ class TestPreProcessorNormalText:
preproc = get_preproc_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
@@ -132,9 +144,7 @@ class TestPreProcessorEmptyMessage:
entities = get_entities_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
@@ -171,9 +181,7 @@ class TestPreProcessorImageSegment:
preproc = get_preproc_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
@@ -219,9 +227,7 @@ class TestPreProcessorImageSegment:
preproc = get_preproc_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
@@ -258,9 +264,7 @@ class TestPreProcessorModelSelection:
preproc = get_preproc_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
@@ -305,9 +309,7 @@ class TestPreProcessorModelSelection:
preproc = get_preproc_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
@@ -324,7 +326,7 @@ class TestPreProcessorModelSelection:
mock_fallback = Mock()
mock_fallback.model_entity = Mock(uuid='fallback-uuid', abilities=['func_call'])
- async def mock_get_model(uuid):
+ async def mock_get_model(_context, uuid):
if uuid == 'primary-uuid':
return mock_primary
elif uuid == 'fallback-uuid':
@@ -368,9 +370,7 @@ class TestPreProcessorVariables:
preproc = get_preproc_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
@@ -405,9 +405,10 @@ class TestPreProcessorVariables:
preproc = get_preproc_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='group')
- mock_session.launcher_id = 99999
+ mock_session = make_session(
+ provider_session.LauncherTypes.GROUP,
+ 99999,
+ )
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
@@ -443,9 +444,7 @@ class TestPreProcessorToolSelection:
preproc = get_preproc_module()
app = FakeApp()
- mock_session = Mock()
- mock_session.launcher_type = Mock(value='person')
- mock_session.launcher_id = 12345
+ mock_session = make_session()
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
mock_conversation = Mock()
diff --git a/tests/unit_tests/pipeline/test_query_pool.py b/tests/unit_tests/pipeline/test_query_pool.py
index 228be093e..a5c526372 100644
--- a/tests/unit_tests/pipeline/test_query_pool.py
+++ b/tests/unit_tests/pipeline/test_query_pool.py
@@ -9,6 +9,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.pipeline.pool import QueryPool
+from langbot.pkg.api.http.context import ExecutionContext
class DummyEventLogger(abstract_platform_logger.AbstractEventLogger):
@@ -64,12 +65,18 @@ async def test_add_query_returns_created_query_and_preserves_side_effects(
adapter=adapter,
pipeline_uuid='test-pipeline-uuid',
routed_by_rule=True,
+ execution_context=ExecutionContext(
+ instance_uuid='test-instance-uuid',
+ workspace_uuid='test-workspace-uuid',
+ placement_generation=1,
+ ),
)
assert query is query_pool.queries[0]
- assert query_pool.cached_queries[0] is query
+ assert query_pool.cached_queries[('test-workspace-uuid', query.query_uuid)] is query
assert query_pool.query_id_counter == 1
assert query.query_id == 0
assert query.bot_uuid == 'test-bot-uuid'
assert query.pipeline_uuid == 'test-pipeline-uuid'
+ assert query.workspace_uuid == 'test-workspace-uuid'
assert query.variables == {'_routed_by_rule': True}
diff --git a/tests/unit_tests/pipeline/test_ratelimit.py b/tests/unit_tests/pipeline/test_ratelimit.py
index be767ad56..e23636ab7 100644
--- a/tests/unit_tests/pipeline/test_ratelimit.py
+++ b/tests/unit_tests/pipeline/test_ratelimit.py
@@ -152,8 +152,18 @@ class TestFixedWindowAlgo:
# First request creates container
await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
- # Key format: 'LauncherTypes.PERSON_12345' (enum string representation)
- expected_key = 'LauncherTypes.PERSON_12345'
+ context = sample_query_with_rate_limit._execution_context
+ expected_key = ':'.join(
+ (
+ context.instance_uuid,
+ context.workspace_uuid,
+ str(context.placement_generation),
+ str(sample_query_with_rate_limit.bot_uuid),
+ str(sample_query_with_rate_limit.pipeline_uuid),
+ str(provider_session.LauncherTypes.PERSON),
+ '12345',
+ )
+ )
assert expected_key in algo.containers
container = algo.containers[expected_key]
@@ -191,8 +201,18 @@ class TestFixedWindowAlgo:
for i in range(5):
await algo.require_access(sample_query, provider_session.LauncherTypes.PERSON, 'test')
- # Key format: 'LauncherTypes.PERSON_test'
- expected_key = 'LauncherTypes.PERSON_test'
+ context = sample_query._execution_context
+ expected_key = ':'.join(
+ (
+ context.instance_uuid,
+ context.workspace_uuid,
+ str(context.placement_generation),
+ str(sample_query.bot_uuid),
+ str(sample_query.pipeline_uuid),
+ str(provider_session.LauncherTypes.PERSON),
+ 'test',
+ )
+ )
container = algo.containers[expected_key]
assert window_start in container.records
assert container.records[window_start] == 5
diff --git a/tests/unit_tests/platform/test_aiocqhttp_message_converter.py b/tests/unit_tests/platform/test_aiocqhttp_message_converter.py
index b41e82631..1ee1730d0 100644
--- a/tests/unit_tests/platform/test_aiocqhttp_message_converter.py
+++ b/tests/unit_tests/platform/test_aiocqhttp_message_converter.py
@@ -68,6 +68,25 @@ async def test_connection_listener_only_suppresses_exact_duplicates():
]
+@pytest.mark.asyncio
+async def test_connection_event_cache_is_bounded():
+ adapter, _ = _make_adapter()
+
+ for index in range(150):
+ await adapter._on_websocket_connection(aiocqhttp.Event({'self_id': index, 'time': index}))
+
+ assert len(adapter.on_websocket_connection_event_cache) == 100
+
+
+def test_group_lookup_caches_are_bounded():
+ converter = AiocqhttpEventConverter()
+ converter._group_name_cache = {index: (str(index), 10_000.0) for index in range(5000)}
+
+ converter._prune_caches(1.0)
+
+ assert len(converter._group_name_cache) == 4096
+
+
def test_unregister_listener_removes_registered_wrapper():
adapter, _ = _make_adapter()
diff --git a/tests/unit_tests/platform/test_botmgr_tenancy.py b/tests/unit_tests/platform/test_botmgr_tenancy.py
new file mode 100644
index 000000000..e3c055eb0
--- /dev/null
+++ b/tests/unit_tests/platform/test_botmgr_tenancy.py
@@ -0,0 +1,402 @@
+from __future__ import annotations
+
+import asyncio
+import contextlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.entity.persistence.bot import Bot
+from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
+from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
+from langbot.pkg.workspace.errors import WorkspaceInvariantError
+import langbot_plugin.api.entities.builtin.platform.events as platform_events
+
+
+WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
+BOT_A = '10000000-0000-0000-0000-00000000000a'
+BOT_B = '10000000-0000-0000-0000-00000000000b'
+
+
+def _context(workspace_uuid: str, bot_uuid: str, generation: int = 4) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid='instance',
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ bot_uuid=bot_uuid,
+ )
+
+
+def _runtime(application, workspace_uuid: str, bot_uuid: str) -> RuntimeBot:
+ entity = SimpleNamespace(
+ uuid=bot_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Same Name',
+ enable=True,
+ pipeline_routing_rules=[],
+ use_pipeline_uuid=None,
+ )
+ return RuntimeBot(
+ ap=application,
+ bot_entity=entity,
+ adapter=SimpleNamespace(),
+ logger=SimpleNamespace(),
+ execution_context=_context(workspace_uuid, bot_uuid),
+ )
+
+
+class _WorkspaceService:
+ async def get_execution_binding(self, workspace_uuid, expected_generation=None):
+ if workspace_uuid not in {WORKSPACE_A, WORKSPACE_B} or expected_generation != 4:
+ raise ValueError('stale')
+ return SimpleNamespace(
+ instance_uuid='instance',
+ workspace_uuid=workspace_uuid,
+ placement_generation=4,
+ )
+
+
+@pytest.fixture
+def manager():
+ application = SimpleNamespace(workspace_service=_WorkspaceService())
+ platform_manager = PlatformManager(application)
+ platform_manager.bots = [
+ _runtime(application, WORKSPACE_A, BOT_A),
+ _runtime(application, WORKSPACE_B, BOT_B),
+ ]
+ return platform_manager
+
+
+@pytest.mark.asyncio
+async def test_runtime_lookup_cannot_guess_another_workspace_bot(manager):
+ assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A), BOT_A) is manager.bots[0]
+ assert await manager.get_bot_by_uuid(_context(WORKSPACE_B, BOT_A), BOT_A) is None
+ assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_B), BOT_B) is None
+
+
+@pytest.mark.asyncio
+async def test_public_route_key_resolves_bound_runtime_and_rejects_non_opaque_input(manager):
+ assert await manager.resolve_public_bot(BOT_A) is manager.bots[0]
+ assert await manager.resolve_public_bot('Same Name') is None
+ assert await manager.resolve_public_bot('not-a-uuid') is None
+
+
+@pytest.mark.asyncio
+async def test_stale_runtime_generation_is_not_returned(manager):
+ with pytest.raises(ValueError, match='stale'):
+ await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A, generation=5), BOT_A)
+
+
+@pytest.mark.asyncio
+async def test_generation_advance_shuts_down_and_prunes_old_workspace_bots():
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('generation advance scanned every bot runtime')
+
+ def items(self):
+ raise AssertionError('generation advance scanned every bot runtime')
+
+ def values(self):
+ raise AssertionError('generation advance scanned every bot runtime')
+
+ manager = PlatformManager(SimpleNamespace())
+ old_bot = SimpleNamespace(
+ workspace_uuid=WORKSPACE_A,
+ placement_generation=4,
+ enable=True,
+ shutdown=AsyncMock(),
+ )
+ other_bot = SimpleNamespace(
+ workspace_uuid=WORKSPACE_B,
+ placement_generation=4,
+ enable=True,
+ shutdown=AsyncMock(),
+ )
+ unrelated_bots = [
+ SimpleNamespace(
+ workspace_uuid=f'workspace-{index}',
+ placement_generation=4,
+ enable=False,
+ shutdown=AsyncMock(),
+ )
+ for index in range(1_000)
+ ]
+ manager.bots = [old_bot, other_bot, *unrelated_bots]
+ old_context = _context(WORKSPACE_A, BOT_A, generation=4)
+ next_context = _context(WORKSPACE_A, BOT_A, generation=5)
+
+ await manager._observe_execution_context(old_context)
+ manager._bots_by_key = NoGlobalIterationDict(manager._bots_by_key)
+ await manager._observe_execution_context(next_context)
+ manager._bots_by_key = dict(manager._bots_by_key)
+
+ old_bot.shutdown.assert_awaited_once_with()
+ assert manager.bots == [other_bot, *unrelated_bots]
+ with pytest.raises(WorkspaceInvariantError, match='rolled back'):
+ await manager._observe_execution_context(old_context)
+
+
+@pytest.mark.asyncio
+async def test_concurrent_websocket_proxy_creation_reuses_one_runtime():
+ created_adapters = []
+
+ class WebsocketAdapter:
+ def __init__(self, *_args, **_kwargs):
+ created_adapters.append(self)
+
+ def register_listener(self, *_args):
+ pass
+
+ application = SimpleNamespace(workspace_service=_WorkspaceService())
+ manager = PlatformManager(application)
+ manager.adapter_dict = {'websocket': WebsocketAdapter}
+ context = ExecutionContext(
+ instance_uuid='instance',
+ workspace_uuid=WORKSPACE_A,
+ placement_generation=4,
+ )
+
+ runtimes = await asyncio.gather(*(manager.get_websocket_proxy_bot(context) for _ in range(20)))
+
+ assert len(created_adapters) == 1
+ assert len({id(runtime) for runtime in runtimes}) == 1
+ assert manager.websocket_proxy_bots == {WORKSPACE_A: runtimes[0]}
+
+
+@pytest.mark.asyncio
+async def test_websocket_proxy_cache_evicts_oldest_idle_workspace():
+ created_adapters = []
+
+ class WebsocketAdapter:
+ def __init__(self, *_args, **_kwargs):
+ self.kill = AsyncMock()
+ self.inbound_listener_tasks = set()
+ created_adapters.append(self)
+
+ def register_listener(self, *_args):
+ pass
+
+ application = SimpleNamespace(
+ workspace_service=_WorkspaceService(),
+ instance_config=SimpleNamespace(
+ data={
+ 'system': {
+ 'websocket_retention': {'max_workspace_proxies': 1},
+ }
+ }
+ ),
+ )
+ manager = PlatformManager(application)
+ manager.adapter_dict = {'websocket': WebsocketAdapter}
+
+ await manager.get_websocket_proxy_bot(
+ ExecutionContext(
+ instance_uuid='instance',
+ workspace_uuid=WORKSPACE_A,
+ placement_generation=4,
+ )
+ )
+ second = await manager.get_websocket_proxy_bot(
+ ExecutionContext(
+ instance_uuid='instance',
+ workspace_uuid=WORKSPACE_B,
+ placement_generation=4,
+ )
+ )
+
+ created_adapters[0].kill.assert_awaited_once_with()
+ assert manager.websocket_proxy_bots == {WORKSPACE_B: second}
+ assert WORKSPACE_A not in manager._proxy_last_accessed
+
+
+@pytest.mark.asyncio
+async def test_reload_stops_and_drops_existing_platform_runtimes():
+ old_bot = SimpleNamespace(enable=True, shutdown=AsyncMock())
+ old_proxy = SimpleNamespace(enable=True, shutdown=AsyncMock())
+ persistence_mgr = SimpleNamespace(
+ execute_async=AsyncMock(return_value=SimpleNamespace(all=lambda: [])),
+ )
+ application = SimpleNamespace(
+ logger=SimpleNamespace(info=lambda *_args: None, warning=lambda *_args: None),
+ persistence_mgr=persistence_mgr,
+ workspace_service=SimpleNamespace(),
+ )
+ manager = PlatformManager(application)
+ manager.bots = [old_bot]
+ manager.websocket_proxy_bots = {WORKSPACE_A: old_proxy}
+ manager._scope_generations = {('instance', WORKSPACE_A): 4}
+
+ await manager.load_bots_from_db()
+
+ old_bot.shutdown.assert_awaited_once_with()
+ old_proxy.shutdown.assert_awaited_once_with()
+ assert manager.bots == []
+ assert manager.websocket_proxy_bots == {}
+ assert manager._scope_generations == {}
+
+
+@pytest.mark.asyncio
+async def test_cloud_startup_reuses_validated_platform_binding():
+ class TenantUow:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args):
+ return False
+
+ class ProbeAdapter:
+ def __init__(self, _config, _logger):
+ self.listeners = []
+
+ def register_listener(self, event_type, listener):
+ self.listeners.append((event_type, listener))
+
+ async def kill(self):
+ return None
+
+ binding = WorkspaceExecutionBinding(
+ instance_uuid='instance',
+ workspace_uuid=WORKSPACE_A,
+ placement_generation=4,
+ write_fenced=False,
+ state='active',
+ )
+ bot = Bot(
+ uuid=BOT_A,
+ workspace_uuid=WORKSPACE_A,
+ name='Probe',
+ description='',
+ adapter='probe',
+ adapter_config={},
+ enable=False,
+ pipeline_routing_rules=[],
+ )
+ workspace_service = SimpleNamespace(
+ list_active_execution_bindings=AsyncMock(return_value=[binding]),
+ get_execution_binding=AsyncMock(
+ side_effect=AssertionError('startup platform loader repeated a validated binding lookup')
+ ),
+ )
+ application = SimpleNamespace(
+ logger=SimpleNamespace(
+ info=lambda *_args, **_kwargs: None,
+ warning=lambda *_args, **_kwargs: None,
+ error=lambda *_args, **_kwargs: None,
+ ),
+ persistence_mgr=SimpleNamespace(
+ mode=SimpleNamespace(value='cloud_runtime'),
+ tenant_uow=lambda _workspace_uuid: TenantUow(),
+ execute_async=AsyncMock(return_value=SimpleNamespace(all=lambda: [bot])),
+ ),
+ workspace_service=workspace_service,
+ )
+ manager = PlatformManager(application)
+ manager.adapter_dict = {'probe': ProbeAdapter}
+
+ await manager.load_bots_from_db()
+
+ assert len(manager.bots) == 1
+ workspace_service.get_execution_binding.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_runtime_bot_revalidates_its_generation_before_handling_events(manager):
+ runtime_bot = manager.bots[0]
+
+ await runtime_bot.assert_execution_active()
+
+ runtime_bot.placement_generation = 5
+ with pytest.raises(ValueError, match='stale'):
+ await runtime_bot.assert_execution_active()
+
+
+def test_runtime_bot_rejects_workspace_mismatch():
+ application = SimpleNamespace()
+ entity = SimpleNamespace(
+ uuid=BOT_A,
+ workspace_uuid=WORKSPACE_A,
+ name='Bot',
+ enable=True,
+ pipeline_routing_rules=[],
+ use_pipeline_uuid=None,
+ )
+ with pytest.raises(WorkspaceRequiredError):
+ RuntimeBot(
+ ap=application,
+ bot_entity=entity,
+ adapter=SimpleNamespace(),
+ logger=SimpleNamespace(),
+ execution_context=_context(WORKSPACE_B, BOT_A),
+ )
+
+
+class _ScopeOnlyPersistenceManager:
+ mode = SimpleNamespace(value='cloud_runtime')
+
+ def __init__(self):
+ self.active_workspace = None
+
+ @contextlib.asynccontextmanager
+ async def tenant_scope(self, workspace_uuid: str):
+ assert self.active_workspace is None
+ self.active_workspace = workspace_uuid
+ try:
+ yield
+ finally:
+ self.active_workspace = None
+
+ def current_session(self):
+ return None
+
+
+class _ListenerAdapter:
+ def __init__(self):
+ self.listeners = {}
+
+ def register_listener(self, event_type, listener):
+ self.listeners[event_type] = listener
+
+
+@pytest.mark.asyncio
+async def test_platform_callback_carries_scope_without_holding_database_session():
+ persistence_mgr = _ScopeOnlyPersistenceManager()
+ adapter = _ListenerAdapter()
+
+ async def push_person_message(*_args, **_kwargs):
+ assert persistence_mgr.active_workspace == WORKSPACE_A
+ assert persistence_mgr.current_session() is None
+ return True
+
+ application = SimpleNamespace(
+ persistence_mgr=persistence_mgr,
+ workspace_service=_WorkspaceService(),
+ webhook_pusher=SimpleNamespace(push_person_message=push_person_message),
+ )
+ entity = SimpleNamespace(
+ uuid=BOT_A,
+ workspace_uuid=WORKSPACE_A,
+ name='Bot',
+ enable=True,
+ pipeline_routing_rules=[],
+ use_pipeline_uuid=None,
+ )
+ logger = SimpleNamespace(info=AsyncMock(), error=AsyncMock())
+ runtime = RuntimeBot(
+ ap=application,
+ bot_entity=entity,
+ adapter=adapter,
+ logger=logger,
+ execution_context=_context(WORKSPACE_A, BOT_A),
+ )
+ await runtime.initialize()
+
+ listener = adapter.listeners[platform_events.FriendMessage]
+ event = SimpleNamespace(message_chain=[], sender=SimpleNamespace(id='user'))
+ await listener(event, adapter)
+
+ assert persistence_mgr.active_workspace is None
+ logger.info.assert_awaited()
diff --git a/tests/unit_tests/platform/test_dingtalk_adapter.py b/tests/unit_tests/platform/test_dingtalk_adapter.py
index df2087866..b2974837a 100644
--- a/tests/unit_tests/platform/test_dingtalk_adapter.py
+++ b/tests/unit_tests/platform/test_dingtalk_adapter.py
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
+from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources.dingtalk import (
DingTalkAdapter,
_dingtalk_card_markdown,
@@ -17,6 +18,17 @@ from langbot.pkg.platform.sources.dingtalk import (
)
+def test_dingtalk_auxiliary_tasks_are_bounded():
+ adapter = DingTalkAdapter.model_construct()
+ adapter._background_tasks = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
+
+ async def callback():
+ raise AssertionError('rejected callback must not run')
+
+ assert adapter._start_background_task(callback()) is False
+ assert len(adapter._background_tasks) == 100
+
+
def test_dingtalk_select_component_params_expose_options():
params = _dingtalk_form_component_params(
{
diff --git a/tests/unit_tests/platform/test_discord_limits.py b/tests/unit_tests/platform/test_discord_limits.py
new file mode 100644
index 000000000..57656b348
--- /dev/null
+++ b/tests/unit_tests/platform/test_discord_limits.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot.pkg.platform.sources import discord
+
+
+def test_discord_base64_decode_is_bounded(monkeypatch):
+ monkeypatch.setattr(discord, '_MAX_DISCORD_MEDIA_BYTES', 4)
+
+ with pytest.raises(ValueError, match='exceeds'):
+ discord._decode_discord_base64_limited('A' * 12)
diff --git a/tests/unit_tests/platform/test_http_bot_tenancy.py b/tests/unit_tests/platform/test_http_bot_tenancy.py
new file mode 100644
index 000000000..8e19d143c
--- /dev/null
+++ b/tests/unit_tests/platform/test_http_bot_tenancy.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+import asyncio
+import time
+from types import SimpleNamespace
+
+import pytest
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.platform.sources.http_bot import HttpBotAdapter
+from langbot.pkg.platform.sources import http_bot as http_bot_module
+
+
+def _session(key):
+ session = SimpleNamespace()
+ session._langbot_session_key = key
+ return session
+
+
+def _adapter(app, execution_context) -> HttpBotAdapter:
+ adapter = HttpBotAdapter.model_construct(
+ config={'signature_required': False},
+ logger=SimpleNamespace(execution_context=execution_context),
+ bot_uuid='bot-a',
+ outbound_states={},
+ idempotency_cache={},
+ sync_waiters={},
+ inbound_tasks=set(),
+ )
+ object.__setattr__(adapter, 'ap', app)
+ return adapter
+
+
+@pytest.mark.asyncio
+async def test_http_bot_reset_removes_only_exact_execution_scope():
+ context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+ bot_uuid='bot-a',
+ )
+ target_key = ('instance-a', 'workspace-a', 3, 'bot-a', 'person', 'shared-session')
+ retained_keys = [
+ ('instance-b', 'workspace-a', 3, 'bot-a', 'person', 'shared-session'),
+ ('instance-a', 'workspace-b', 3, 'bot-a', 'person', 'shared-session'),
+ ('instance-a', 'workspace-a', 4, 'bot-a', 'person', 'shared-session'),
+ ('instance-a', 'workspace-a', 3, 'bot-b', 'person', 'shared-session'),
+ ('instance-a', 'workspace-a', 3, 'bot-a', 'group', 'shared-session'),
+ ('instance-a', 'workspace-a', 3, 'bot-a', 'person', 'other-session'),
+ ]
+ sessions = [_session(target_key), *[_session(key) for key in retained_keys], SimpleNamespace()]
+ app = SimpleNamespace(sess_mgr=SimpleNamespace(session_list=sessions))
+ adapter = _adapter(app, context)
+
+ removed = await adapter._reset_session('person', 'shared-session')
+
+ assert removed is True
+ assert [getattr(session, '_langbot_session_key', None) for session in app.sess_mgr.session_list] == [
+ *retained_keys,
+ None,
+ ]
+
+
+@pytest.mark.asyncio
+async def test_http_bot_reset_fails_closed_without_trusted_scope():
+ app = SimpleNamespace(sess_mgr=SimpleNamespace(session_list=[]))
+ adapter = _adapter(app, None)
+
+ with pytest.raises(RuntimeError, match='trusted execution scope'):
+ await adapter._reset_session('person', 'shared-session')
+
+
+@pytest.mark.asyncio
+async def test_http_bot_bounds_inbound_listener_tasks(monkeypatch):
+ monkeypatch.setattr(http_bot_module, '_INBOUND_TASK_MAX', 1)
+ adapter = _adapter(SimpleNamespace(), None)
+ started = asyncio.Event()
+ release = asyncio.Event()
+
+ async def blocking_listener():
+ started.set()
+ await release.wait()
+
+ first = adapter._start_inbound_task(blocking_listener())
+ await started.wait()
+ rejected = adapter._start_inbound_task(blocking_listener())
+
+ assert first is not None
+ assert rejected is None
+ assert len(adapter.inbound_tasks) == 1
+
+ release.set()
+ await first
+ await asyncio.sleep(0)
+ assert adapter.inbound_tasks == set()
+
+
+def test_http_bot_outbound_state_has_a_hard_capacity(monkeypatch):
+ monkeypatch.setattr(http_bot_module, '_OUTBOUND_STATE_MAX', 2)
+ monkeypatch.setattr(http_bot_module, '_OUTBOUND_PRUNE_SCAN_MAX', 2)
+ adapter = _adapter(SimpleNamespace(), None)
+ first = adapter._outbound_state('first')
+ second = adapter._outbound_state('second')
+ first.queue.put_nowait({})
+ second.queue.put_nowait({})
+
+ with pytest.raises(RuntimeError, match='outbound session capacity reached'):
+ adapter._next_sequence('third', is_final=True)
+
+ assert len(adapter.outbound_states) == 2
+ assert adapter._next_sequence('first', is_final=True) == 1
+
+
+def test_http_bot_outbound_state_pruning_is_bounded_and_reclaims_stale(monkeypatch):
+ monkeypatch.setattr(http_bot_module, '_OUTBOUND_STATE_MAX', 2)
+ monkeypatch.setattr(http_bot_module, '_OUTBOUND_PRUNE_SCAN_MAX', 1)
+ monkeypatch.setattr(http_bot_module, '_OUTBOUND_IDLE_SECONDS', 10)
+ adapter = _adapter(SimpleNamespace(), None)
+ stale = adapter._outbound_state('stale')
+ stale.last_active = time.monotonic() - 11
+ adapter._outbound_state('active')
+
+ assert adapter._next_sequence('replacement', is_final=True) == 1
+ assert set(adapter.outbound_states) == {'active', 'replacement'}
+
+
+def test_http_bot_idempotency_cache_has_a_hard_capacity(monkeypatch):
+ monkeypatch.setattr(http_bot_module, '_IDEMPOTENCY_MAX', 2)
+ monkeypatch.setattr(http_bot_module, '_IDEMPOTENCY_PRUNE_SCAN_MAX', 1)
+ adapter = _adapter(SimpleNamespace(), None)
+
+ assert adapter._reserve_idempotency_key('first') == 'accepted'
+ assert adapter._reserve_idempotency_key('second') == 'accepted'
+ assert adapter._reserve_idempotency_key('third') == 'overloaded'
+ assert len(adapter.idempotency_cache) == 2
+ assert adapter._reserve_idempotency_key('first') == 'duplicate'
+
+
+def test_http_bot_idempotency_cache_reclaims_expired_oldest(monkeypatch):
+ monkeypatch.setattr(http_bot_module, '_IDEMPOTENCY_MAX', 2)
+ monkeypatch.setattr(http_bot_module, '_IDEMPOTENCY_PRUNE_SCAN_MAX', 1)
+ monkeypatch.setattr(http_bot_module, '_IDEMPOTENCY_TTL', 10)
+ adapter = _adapter(SimpleNamespace(), None)
+ adapter.idempotency_cache = {
+ 'expired': time.monotonic() - 11,
+ 'active': time.monotonic(),
+ }
+
+ assert adapter._reserve_idempotency_key('replacement') == 'accepted'
+ assert set(adapter.idempotency_cache) == {'active', 'replacement'}
diff --git a/tests/unit_tests/platform/test_kook_limits.py b/tests/unit_tests/platform/test_kook_limits.py
new file mode 100644
index 000000000..669ab4517
--- /dev/null
+++ b/tests/unit_tests/platform/test_kook_limits.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+import json
+import zlib
+
+import pytest
+
+from langbot.pkg.platform.sources import kook
+
+
+def test_kook_gateway_decoder_accepts_raw_and_compressed_json():
+ payload = {'s': 1, 'd': {'session_id': 'session-a'}}
+ encoded = json.dumps(payload).encode()
+
+ assert kook._decode_gateway_message(encoded) == payload
+ assert kook._decode_gateway_message(zlib.compress(encoded)) == payload
+
+
+def test_kook_gateway_decoder_rejects_decompression_bomb(monkeypatch):
+ monkeypatch.setattr(kook, '_KOOK_MAX_GATEWAY_MESSAGE_BYTES', 1024)
+ compressed = zlib.compress(b'x' * 1025)
+
+ with pytest.raises(ValueError, match='decompressed size limit'):
+ kook._decode_gateway_message(compressed)
diff --git a/tests/unit_tests/platform/test_lark_adapter.py b/tests/unit_tests/platform/test_lark_adapter.py
index 59b91ee60..a8e1fe3d9 100644
--- a/tests/unit_tests/platform/test_lark_adapter.py
+++ b/tests/unit_tests/platform/test_lark_adapter.py
@@ -1,7 +1,13 @@
"""Tests for Lark adapter helper behavior."""
+import threading
+from unittest.mock import MagicMock
+
+import pytest
+
from langbot.pkg.platform.sources.lark import (
LarkAdapter,
+ _decode_lark_base64_limited,
_lark_clean_form_content,
_lark_completed_input_lines,
_lark_current_input_defs,
@@ -11,6 +17,27 @@ from langbot.pkg.platform.sources.lark import (
)
+def test_lark_base64_decode_is_bounded(monkeypatch):
+ import langbot.pkg.platform.sources.lark as lark_module
+
+ monkeypatch.setattr(lark_module, '_MAX_LARK_MEDIA_BYTES', 4)
+
+ with pytest.raises(ValueError, match='exceeds'):
+ _decode_lark_base64_limited('A' * 12)
+
+
+def test_lark_threadsafe_callbacks_are_bounded():
+ adapter = LarkAdapter.model_construct()
+ adapter.threadsafe_event_lock = threading.Lock()
+ adapter.threadsafe_event_futures = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
+
+ async def callback():
+ raise AssertionError('rejected callback must not run')
+
+ assert adapter._schedule_threadsafe_event(callback()) is None
+ assert len(adapter.threadsafe_event_futures) == 100
+
+
def test_lark_current_input_defs_only_returns_active_stage():
input_defs = [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
diff --git a/tests/unit_tests/platform/test_line_limits.py b/tests/unit_tests/platform/test_line_limits.py
new file mode 100644
index 000000000..53ef12e44
--- /dev/null
+++ b/tests/unit_tests/platform/test_line_limits.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import MagicMock
+
+from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
+from langbot.pkg.platform.sources import line
+
+
+def test_line_media_content_accepts_limit_boundary(monkeypatch) -> None:
+ monkeypatch.setattr(line, 'MAX_LINE_MEDIA_BYTES', 4)
+ content = b'1234'
+
+ assert line._validate_line_media_content(content) is content
+
+
+def test_line_media_content_rejects_oversized_payload(monkeypatch) -> None:
+ monkeypatch.setattr(line, 'MAX_LINE_MEDIA_BYTES', 4)
+
+ with pytest.raises(ValueError, match='LINE media exceeds'):
+ line._validate_line_media_content(b'12345')
+
+
+@pytest.mark.asyncio
+async def test_line_kill_closes_api_client() -> None:
+ api_client = MagicMock()
+ adapter = line.LINEAdapter.model_construct(api_client=api_client)
+
+ assert await adapter.kill() is True
+ api_client.close.assert_called_once_with()
diff --git a/tests/unit_tests/platform/test_matrix_limits.py b/tests/unit_tests/platform/test_matrix_limits.py
new file mode 100644
index 000000000..1f8356b3f
--- /dev/null
+++ b/tests/unit_tests/platform/test_matrix_limits.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot.pkg.platform.sources import matrix
+
+
+def test_matrix_base64_decode_is_bounded(monkeypatch):
+ monkeypatch.setattr(matrix, '_MAX_MATRIX_MEDIA_BYTES', 4)
+
+ with pytest.raises(ValueError, match='exceeds'):
+ matrix._decode_matrix_base64_limited('A' * 12)
+
+
+def test_matrix_local_file_read_is_bounded(tmp_path, monkeypatch):
+ monkeypatch.setattr(matrix, '_MAX_MATRIX_MEDIA_BYTES', 4)
+ path = tmp_path / 'large.bin'
+ path.write_bytes(b'12345')
+
+ with pytest.raises(ValueError, match='exceeds'):
+ matrix._read_matrix_file_limited(str(path))
diff --git a/tests/unit_tests/platform/test_openclaw_weixin_client.py b/tests/unit_tests/platform/test_openclaw_weixin_client.py
new file mode 100644
index 000000000..6c8dcadc5
--- /dev/null
+++ b/tests/unit_tests/platform/test_openclaw_weixin_client.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot.libs.openclaw_weixin_api.client import (
+ MAX_CDN_MEDIA_BYTES,
+ OpenClawWeixinClient,
+ _decrypt_cdn_payload,
+ _encrypt_cdn_payload,
+)
+from langbot.libs.openclaw_weixin_api.types import ApiError
+
+
+def test_cdn_crypto_helpers_round_trip():
+ original = b'tenant-media' * 128
+
+ aes_key_hex, _encoded_key, encrypted, _raw_md5 = _encrypt_cdn_payload(original)
+
+ assert _decrypt_cdn_payload(encrypted, bytes.fromhex(aes_key_hex)) == original
+
+
+@pytest.mark.asyncio
+async def test_upload_media_rejects_oversized_input_before_network_access():
+ client = OpenClawWeixinClient('https://example.invalid', 'token')
+
+ with pytest.raises(ApiError, match='exceeds the size limit'):
+ await client.upload_media(
+ b'x' * (MAX_CDN_MEDIA_BYTES + 1),
+ 'recipient',
+ 3,
+ )
diff --git a/tests/unit_tests/platform/test_openclaw_weixin_tenancy.py b/tests/unit_tests/platform/test_openclaw_weixin_tenancy.py
new file mode 100644
index 000000000..8a2f53e9b
--- /dev/null
+++ b/tests/unit_tests/platform/test_openclaw_weixin_tenancy.py
@@ -0,0 +1,95 @@
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import langbot_plugin.api.entities.builtin.platform.message as platform_message
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.platform.sources import openclaw_weixin
+from langbot.pkg.platform.sources.openclaw_weixin import OpenClawWeixinAdapter
+
+
+def make_adapter(*, execution_context: ExecutionContext | None):
+ app = SimpleNamespace(
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock()),
+ workspace_service=SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ )
+ ),
+ )
+ logger = SimpleNamespace(
+ ap=app,
+ execution_context=execution_context,
+ warning=AsyncMock(),
+ )
+ adapter = OpenClawWeixinAdapter.model_construct(
+ config={'token': 'refreshed-token'},
+ logger=logger,
+ client=Mock(),
+ bot_account_id='',
+ listeners={},
+ name='openclaw-weixin',
+ )
+ adapter._bot_uuid = 'shared-bot-uuid'
+ return adapter, app, logger
+
+
+@pytest.mark.asyncio
+async def test_persist_config_scopes_duplicate_bot_uuid_to_workspace():
+ adapter, app, _ = make_adapter(
+ execution_context=ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ bot_uuid='shared-bot-uuid',
+ )
+ )
+
+ await adapter._persist_config()
+
+ app.workspace_service.get_execution_binding.assert_awaited_once_with(
+ 'workspace-a',
+ expected_generation=1,
+ )
+ statement = app.persistence_mgr.execute_async.await_args.args[0]
+ params = statement.compile().params
+ assert 'workspace-a' in params.values()
+ assert 'shared-bot-uuid' in params.values()
+ assert {'workspace_uuid', 'uuid'} <= {comparison.left.name for comparison in statement._where_criteria}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'execution_context',
+ [
+ None,
+ ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ bot_uuid='another-bot-uuid',
+ ),
+ ],
+ ids=['missing-context', 'mismatched-bot'],
+)
+async def test_persist_config_fails_closed_without_matching_execution_context(execution_context):
+ adapter, app, logger = make_adapter(execution_context=execution_context)
+
+ await adapter._persist_config()
+
+ app.persistence_mgr.execute_async.assert_not_awaited()
+ logger.warning.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_component_base64_decode_is_bounded(monkeypatch):
+ monkeypatch.setattr(openclaw_weixin, '_MAX_OPENCLAW_COMPONENT_BYTES', 4)
+ component = platform_message.File(base64='MTIzNDU=')
+
+ with pytest.raises(ValueError, match='exceeds'):
+ await OpenClawWeixinAdapter._get_component_bytes(component)
diff --git a/tests/unit_tests/platform/test_qqofficial_api.py b/tests/unit_tests/platform/test_qqofficial_api.py
index 0f791f612..704dd142c 100644
--- a/tests/unit_tests/platform/test_qqofficial_api.py
+++ b/tests/unit_tests/platform/test_qqofficial_api.py
@@ -10,6 +10,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.libs.qq_official_api.api import (
QQ_SELECT_ACTION_PREFIX,
+ QQOfficialClient,
build_keyboard_from_select_field,
get_select_field_options,
resolve_select_button_action,
@@ -49,6 +50,28 @@ def test_qq_select_button_resolves_field_and_value():
assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}99') is None
+@pytest.mark.asyncio
+async def test_qq_seed_rejects_empty_secret_without_spinning():
+ client = QQOfficialClient('', 'token', 'app-id', AsyncMock())
+
+ with pytest.raises(ValueError, match='must not be empty'):
+ await asyncio.wait_for(client.repeat_seed(''), timeout=0.1)
+
+
+def test_qq_auxiliary_tasks_are_bounded():
+ import langbot.pkg.core.app # noqa: F401
+ from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
+
+ adapter = QQOfficialAdapter.model_construct()
+ adapter._background_tasks = {MagicMock(done=MagicMock(return_value=False)) for _ in range(100)}
+
+ async def callback():
+ raise AssertionError('rejected callback must not run')
+
+ assert adapter._start_background_task(callback()) is False
+ assert len(adapter._background_tasks) == 100
+
+
def test_qq_select_keyboard_fits_twenty_five_options():
form_data = _select_form_data()
form_data['input_defs'][0]['option_source']['value'] = [f'Option {idx}' for idx in range(25)]
diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py
index 3928f6f11..359eab5b7 100644
--- a/tests/unit_tests/platform/test_routing_rules.py
+++ b/tests/unit_tests/platform/test_routing_rules.py
@@ -278,3 +278,19 @@ class TestResolvePipelineUuid:
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
assert uuid == 'default-uuid'
assert routed is False
+
+ def test_websocket_task_override_does_not_mutate_bot_default(self):
+ bot = self._make_bot('default-uuid', [])
+ adapter = Mock()
+ adapter.get_pipeline_uuid_override.return_value = 'connection-pipeline'
+
+ pipeline_uuid, routed = bot.resolve_event_pipeline_uuid(
+ adapter,
+ 'person',
+ 'launcher',
+ 'hello',
+ )
+
+ assert pipeline_uuid == 'connection-pipeline'
+ assert routed is False
+ assert bot.bot_entity.use_pipeline_uuid == 'default-uuid'
diff --git a/tests/unit_tests/platform/test_telegram_adapter.py b/tests/unit_tests/platform/test_telegram_adapter.py
index 6e2262283..885310914 100644
--- a/tests/unit_tests/platform/test_telegram_adapter.py
+++ b/tests/unit_tests/platform/test_telegram_adapter.py
@@ -11,11 +11,21 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.sources.telegram import (
TelegramAdapter,
+ _decode_telegram_base64_limited,
_telegram_form_action_from_callback,
_telegram_select_field_options,
)
+def test_telegram_base64_decode_is_bounded(monkeypatch):
+ import langbot.pkg.platform.sources.telegram as telegram_module
+
+ monkeypatch.setattr(telegram_module, '_MAX_TELEGRAM_MEDIA_BYTES', 4)
+
+ with pytest.raises(ValueError, match='exceeds'):
+ _decode_telegram_base64_limited('A' * 12)
+
+
def _select_form_data() -> dict:
return {
'_current_input_field': 'choice',
@@ -88,6 +98,18 @@ def test_telegram_form_callback_cache_preserves_pipeline_uuid():
)
+def test_telegram_form_callback_cache_is_bounded():
+ adapter = TelegramAdapter.model_construct()
+ adapter._form_action_titles = {}
+
+ adapter._cache_form_action_titles(
+ {f'callback-{index}': str(index) for index in range(5000)},
+ now=100.0,
+ )
+
+ assert len(adapter._form_action_titles) == adapter._MAX_FORM_ACTION_TITLES
+
+
@pytest.mark.asyncio
async def test_telegram_select_field_sends_two_column_inline_keyboard():
bot = MagicMock()
diff --git a/tests/unit_tests/platform/test_webhook_pusher.py b/tests/unit_tests/platform/test_webhook_pusher.py
new file mode 100644
index 000000000..696858a03
--- /dev/null
+++ b/tests/unit_tests/platform/test_webhook_pusher.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from langbot.pkg.platform.webhook_pusher import WebhookPusher
+
+
+pytestmark = pytest.mark.asyncio
+
+
+def _application(max_inflight_requests: object) -> SimpleNamespace:
+ return SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'webhooks': {
+ 'max_inflight_requests': max_inflight_requests,
+ }
+ }
+ ),
+ logger=logging.getLogger(__name__),
+ )
+
+
+async def test_delivery_admission_never_queues_above_instance_limit():
+ pusher = WebhookPusher(_application(2))
+ release = asyncio.Event()
+ both_started = asyncio.Event()
+ calls = 0
+ active = 0
+ peak_active = 0
+
+ async def fake_push(url: str, payload: dict) -> dict:
+ nonlocal calls, active, peak_active
+ calls += 1
+ active += 1
+ peak_active = max(peak_active, active)
+ if active == 2:
+ both_started.set()
+ try:
+ await release.wait()
+ return {'url': url}
+ finally:
+ active -= 1
+
+ pusher._push_to_webhook = fake_push
+ webhooks = [{'url': f'https://example.invalid/{index}'} for index in range(5)]
+
+ first_delivery = asyncio.create_task(pusher._push_to_webhooks(webhooks, {}))
+ await asyncio.wait_for(both_started.wait(), timeout=1)
+ second_results = await pusher._push_to_webhooks(webhooks, {})
+ release.set()
+ first_results = await first_delivery
+
+ assert len(first_results) == 2
+ assert second_results == []
+ assert calls == 2
+ assert peak_active == 2
+ assert pusher._inflight_requests == 0
+
+
+async def test_cancelled_delivery_reaps_children_and_releases_slots():
+ pusher = WebhookPusher(_application(1))
+ started = asyncio.Event()
+ never = asyncio.Event()
+
+ async def blocking_push(url: str, payload: dict) -> dict:
+ started.set()
+ await never.wait()
+ return {}
+
+ pusher._push_to_webhook = blocking_push
+ delivery = asyncio.create_task(
+ pusher._push_to_webhooks([{'url': 'https://example.invalid'}], {}),
+ )
+ await asyncio.wait_for(started.wait(), timeout=1)
+
+ delivery.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await delivery
+
+ assert pusher._inflight_requests == 0
+ pusher._push_to_webhook = AsyncMock(return_value={})
+ assert await pusher._push_to_webhooks([{'url': 'https://example.invalid'}], {}) == [{}]
+
+
+async def test_max_inflight_requests_clamps_config():
+ pusher = WebhookPusher(_application(999999))
+ assert pusher._max_inflight_requests() == 128
+
+ pusher.ap.instance_config.data['webhooks']['max_inflight_requests'] = 0
+ assert pusher._max_inflight_requests() == 1
+
+ pusher.ap.instance_config.data['webhooks']['max_inflight_requests'] = 'invalid'
+ assert pusher._max_inflight_requests() == 16
diff --git a/tests/unit_tests/platform/test_websocket_adapter_attachments.py b/tests/unit_tests/platform/test_websocket_adapter_attachments.py
index 18138383d..35a6bb644 100644
--- a/tests/unit_tests/platform/test_websocket_adapter_attachments.py
+++ b/tests/unit_tests/platform/test_websocket_adapter_attachments.py
@@ -4,89 +4,116 @@ The web debug client uploads Image / Voice / File components carrying a storage
key in ``path``. This helper resolves each to a base64 data URI (so multimodal
LLM input and the Box sandbox inbox have usable bytes), then deletes the
consumed storage object and clears ``path``. Covers mimetype selection per
-type and graceful error handling.
+type and fail-closed error handling.
"""
from __future__ import annotations
import base64
+from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
+from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ pipeline_uuid='pipeline-a',
+)
+_UPLOAD_PREFIX = 'v1/instance-a/workspace-a/1/upload_image/'
+
+
+def _make_connection():
+ return SimpleNamespace(execution_context=_CONTEXT)
+
+
def _make_adapter(load_return=b'hello', load_side_effect=None):
provider = Mock()
provider.load = AsyncMock(return_value=load_return, side_effect=load_side_effect)
- provider.delete = AsyncMock()
+ storage_mgr = Mock()
+ storage_mgr.storage_provider = provider
+ storage_mgr.load_scoped_object_key = AsyncMock(return_value=load_return, side_effect=load_side_effect)
+ storage_mgr.scoped_prefix.return_value = _UPLOAD_PREFIX
+ storage_mgr.is_scoped_object_key.return_value = True
+ storage_mgr.delete_scoped_object_key = AsyncMock()
ap = Mock()
- ap.storage_mgr.storage_provider = provider
+ ap.storage_mgr = storage_mgr
logger = Mock()
logger.error = AsyncMock()
+ logger.warning = AsyncMock()
# WebSocketAdapter is a pydantic model; bypass full __init__/validation.
adapter = WebSocketAdapter.model_construct(ap=ap, logger=logger)
- return adapter, provider
+ return adapter, storage_mgr, provider
@pytest.mark.asyncio
async def test_image_jpeg_mimetype_and_cleanup():
- adapter, provider = _make_adapter(load_return=b'\xff\xd8\xff')
- chain = [{'type': 'Image', 'path': 'storage://abc/photo.jpg'}]
+ adapter, storage_mgr, _ = _make_adapter(load_return=b'\xff\xd8\xff')
+ path = f'{_UPLOAD_PREFIX}photo.jpg'
+ chain = [{'type': 'Image', 'path': path}]
- await adapter._process_image_components(chain)
+ await adapter._process_image_components(_make_connection(), chain)
expected_b64 = base64.b64encode(b'\xff\xd8\xff').decode('utf-8')
assert chain[0]['base64'] == f'data:image/jpeg;base64,{expected_b64}'
assert chain[0]['path'] == '' # consumed
- provider.delete.assert_awaited_once_with('storage://abc/photo.jpg')
+ storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
+ _CONTEXT,
+ path,
+ expected_owner_type='upload_image',
+ )
@pytest.mark.asyncio
async def test_image_defaults_to_png():
- adapter, _ = _make_adapter()
- chain = [{'type': 'Image', 'path': 'storage://abc/blob'}]
- await adapter._process_image_components(chain)
+ adapter, _, _ = _make_adapter()
+ chain = [{'type': 'Image', 'path': f'{_UPLOAD_PREFIX}blob'}]
+ await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:image/png;base64,')
@pytest.mark.asyncio
async def test_voice_uses_guessed_or_wav_mimetype():
- adapter, _ = _make_adapter()
- chain = [{'type': 'Voice', 'path': 'storage://abc/clip.wav'}]
- await adapter._process_image_components(chain)
+ adapter, _, _ = _make_adapter()
+ chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}]
+ await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:audio/')
@pytest.mark.asyncio
async def test_file_uses_octet_stream_fallback():
- adapter, _ = _make_adapter()
- chain = [{'type': 'File', 'path': 'storage://abc/unknownblob'}]
- await adapter._process_image_components(chain)
+ adapter, _, _ = _make_adapter()
+ chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}unknownblob'}]
+ await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:application/octet-stream;base64,')
@pytest.mark.asyncio
async def test_skips_components_without_path_or_unknown_type():
- adapter, provider = _make_adapter()
+ adapter, storage_mgr, provider = _make_adapter()
chain = [
{'type': 'Image', 'path': ''}, # no path
{'type': 'Plain', 'path': 'storage://abc/x'}, # not a file component
{'type': 'At', 'target': '123'}, # no path key at all
]
- await adapter._process_image_components(chain)
+ await adapter._process_image_components(_make_connection(), chain)
provider.load.assert_not_awaited()
+ storage_mgr.load_scoped_object_key.assert_not_awaited()
assert 'base64' not in chain[0]
assert 'base64' not in chain[1]
@pytest.mark.asyncio
-async def test_load_failure_is_logged_not_raised():
- adapter, _ = _make_adapter(load_side_effect=RuntimeError('storage down'))
- chain = [{'type': 'File', 'path': 'storage://abc/doc.pdf'}]
+async def test_load_failure_is_logged_and_aborts_processing():
+ adapter, _, _ = _make_adapter(load_side_effect=RuntimeError('storage down'))
+ chain = [{'type': 'File', 'path': f'{_UPLOAD_PREFIX}doc.pdf'}]
- # must not raise
- await adapter._process_image_components(chain)
+ with pytest.raises(RuntimeError, match='storage down'):
+ await adapter._process_image_components(_make_connection(), chain)
assert 'base64' not in chain[0]
adapter.logger.error.assert_awaited_once()
diff --git a/tests/unit_tests/platform/test_websocket_session_isolation.py b/tests/unit_tests/platform/test_websocket_session_isolation.py
index d86580000..682a42184 100644
--- a/tests/unit_tests/platform/test_websocket_session_isolation.py
+++ b/tests/unit_tests/platform/test_websocket_session_isolation.py
@@ -9,7 +9,25 @@ import pytest
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession
-from langbot.pkg.platform.sources.websocket_manager import WebSocketConnectionManager, is_valid_session_id
+from langbot.pkg.platform.sources.websocket_manager import (
+ WebSocketConnectionManager,
+ WebSocketScope,
+ is_valid_session_id,
+)
+
+
+SCOPE_A = WebSocketScope('instance-a', 'workspace-a', 1)
+SCOPE_B = WebSocketScope('instance-a', 'workspace-b', 1)
+
+
+def _adapter_logger(scope: WebSocketScope = SCOPE_A):
+ logger = AsyncMock()
+ logger.execution_context = Mock(
+ instance_uuid=scope.instance_uuid,
+ workspace_uuid=scope.workspace_uuid,
+ placement_generation=scope.placement_generation,
+ )
+ return logger
@pytest.mark.asyncio
@@ -17,18 +35,21 @@ async def test_broadcast_only_reaches_connections_in_same_browser_session():
manager = WebSocketConnectionManager()
first = await manager.add_connection(
websocket=Mock(),
+ scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
session_id='session-a',
)
second = await manager.add_connection(
websocket=Mock(),
+ scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
session_id='session-b',
)
dashboard = await manager.add_connection(
websocket=Mock(),
+ scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
)
@@ -36,6 +57,7 @@ async def test_broadcast_only_reaches_connections_in_same_browser_session():
await manager.broadcast_to_pipeline(
'pipeline-1',
{'type': 'response'},
+ scope=SCOPE_A,
session_type='person',
session_id='session-a',
)
@@ -47,6 +69,7 @@ async def test_broadcast_only_reaches_connections_in_same_browser_session():
await manager.broadcast_to_pipeline(
'pipeline-1',
{'type': 'dashboard-response'},
+ scope=SCOPE_A,
session_type='person',
session_id=None,
)
@@ -56,19 +79,114 @@ async def test_broadcast_only_reaches_connections_in_same_browser_session():
assert second.send_queue.empty()
+@pytest.mark.asyncio
+async def test_pipeline_indexes_and_broadcasts_are_workspace_scoped():
+ manager = WebSocketConnectionManager()
+ workspace_a = await manager.add_connection(
+ websocket=Mock(),
+ scope=SCOPE_A,
+ pipeline_uuid='shared-pipeline',
+ session_type='person',
+ )
+ workspace_b = await manager.add_connection(
+ websocket=Mock(),
+ scope=SCOPE_B,
+ pipeline_uuid='shared-pipeline',
+ session_type='person',
+ )
+
+ await manager.broadcast_to_pipeline(
+ 'shared-pipeline',
+ {'type': 'workspace-a'},
+ scope=SCOPE_A,
+ )
+
+ assert await workspace_a.send_queue.get() == {'type': 'workspace-a'}
+ assert workspace_b.send_queue.empty()
+ assert await manager.get_connection(workspace_b.connection_id, scope=SCOPE_A) is None
+ assert await manager.get_connection(workspace_b.connection_id, scope=SCOPE_B) is workspace_b
+ assert manager.get_stats(scope=SCOPE_A)['total_connections'] == 1
+
+
+@pytest.mark.asyncio
+async def test_connection_admission_is_bounded_globally_and_per_workspace():
+ manager = WebSocketConnectionManager()
+ await manager.add_connection(
+ websocket=Mock(),
+ scope=SCOPE_A,
+ pipeline_uuid='pipeline-1',
+ session_type='person',
+ max_connections=2,
+ max_connections_per_workspace=1,
+ )
+
+ with pytest.raises(RuntimeError, match='Workspace WebSocket'):
+ await manager.add_connection(
+ websocket=Mock(),
+ scope=SCOPE_A,
+ pipeline_uuid='pipeline-2',
+ session_type='person',
+ max_connections=2,
+ max_connections_per_workspace=1,
+ )
+
+ await manager.add_connection(
+ websocket=Mock(),
+ scope=SCOPE_B,
+ pipeline_uuid='pipeline-1',
+ session_type='person',
+ max_connections=2,
+ max_connections_per_workspace=1,
+ )
+ with pytest.raises(RuntimeError, match='WebSocket connection capacity'):
+ await manager.add_connection(
+ websocket=Mock(),
+ scope=WebSocketScope('instance-a', 'workspace-c', 1),
+ pipeline_uuid='pipeline-1',
+ session_type='person',
+ max_connections=2,
+ max_connections_per_workspace=1,
+ )
+
+
+@pytest.mark.asyncio
+async def test_close_scope_closes_and_removes_only_matching_connections():
+ manager = WebSocketConnectionManager()
+ websocket_a = Mock(close=AsyncMock())
+ connection_a = await manager.add_connection(
+ websocket=websocket_a,
+ scope=SCOPE_A,
+ pipeline_uuid='pipeline-1',
+ session_type='person',
+ )
+ connection_b = await manager.add_connection(
+ websocket=Mock(close=AsyncMock()),
+ scope=SCOPE_B,
+ pipeline_uuid='pipeline-1',
+ session_type='person',
+ )
+
+ await manager.close_scope(SCOPE_A)
+
+ websocket_a.close.assert_awaited_once()
+ assert await manager.get_connection(connection_a.connection_id, scope=SCOPE_A) is None
+ assert await manager.get_connection(connection_b.connection_id, scope=SCOPE_B) is connection_b
+
+
@pytest.mark.asyncio
async def test_embed_event_uses_stable_session_launcher(monkeypatch):
manager = WebSocketConnectionManager()
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
connection = await manager.add_connection(
websocket=Mock(),
+ scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
- adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
+ adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
received = []
@@ -92,13 +210,14 @@ async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
connection = await manager.add_connection(
websocket=Mock(),
+ scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='group',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
- adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
+ adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
received = []
@@ -118,6 +237,7 @@ async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
dashboard = await manager.add_connection(
websocket=Mock(),
+ scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='group',
)
@@ -138,30 +258,46 @@ async def test_stable_session_launcher_resolves_to_active_connection(monkeypatch
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
await manager.add_connection(
websocket=Mock(),
+ scope=SCOPE_A,
pipeline_uuid='pipeline-2',
session_type='person',
session_id=session_id,
)
connection = await manager.add_connection(
websocket=Mock(),
+ scope=SCOPE_A,
pipeline_uuid='pipeline-1',
session_type='person',
session_id=session_id,
)
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
- adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
+ adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
message_source = Mock()
message_source.sender.id = f'websocket_pipeline-1:{session_id}'
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
assert await adapter._get_connection_from_target(f'websocketgroup_pipeline-1:{session_id}') is connection
- assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is connection
+ assert (
+ await manager.get_connection_by_session_id(
+ session_id,
+ scope=SCOPE_A,
+ pipeline_uuid='pipeline-1',
+ )
+ is connection
+ )
await manager.remove_connection(connection.connection_id)
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
- assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is None
+ assert (
+ await manager.get_connection_by_session_id(
+ session_id,
+ scope=SCOPE_A,
+ pipeline_uuid='pipeline-1',
+ )
+ is None
+ )
def test_session_ids_must_be_canonical_random_uuids():
@@ -171,7 +307,7 @@ def test_session_ids_must_be_canonical_random_uuids():
def test_history_read_does_not_allocate_unknown_session():
- adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
+ adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=_adapter_logger())
adapter.websocket_person_session = WebSocketSession(id='person')
adapter.websocket_group_session = WebSocketSession(id='group')
@@ -179,16 +315,75 @@ def test_history_read_does_not_allocate_unknown_session():
assert adapter.websocket_person_session.message_lists == {}
+@pytest.mark.asyncio
+async def test_attachment_key_must_belong_to_connection_upload_scope():
+ manager = WebSocketConnectionManager()
+ connection = await manager.add_connection(
+ websocket=Mock(),
+ scope=SCOPE_A,
+ pipeline_uuid='pipeline-1',
+ session_type='person',
+ )
+ storage_mgr = Mock()
+ storage_mgr.scoped_prefix.return_value = 'v1/current/upload_image/'
+ storage_mgr.is_scoped_object_key.return_value = True
+ storage_mgr.load_scoped_object_key = AsyncMock(return_value=b'image')
+ storage_mgr.delete_scoped_object_key = AsyncMock()
+ adapter = WebSocketAdapter.model_construct(
+ ap=Mock(storage_mgr=storage_mgr),
+ logger=_adapter_logger(),
+ )
+ message_chain = [{'type': 'Image', 'path': 'v1/current/upload_image/key.png'}]
+
+ await adapter._process_image_components(connection, message_chain)
+
+ assert message_chain[0]['base64'].startswith('data:image/png;base64,')
+ assert message_chain[0]['path'] == ''
+ storage_mgr.scoped_prefix.assert_called_once_with(
+ connection.execution_context,
+ owner_type='upload_image',
+ )
+ storage_mgr.is_scoped_object_key.assert_called_once_with(
+ 'v1/current/upload_image/key.png',
+ expected_owner_type='upload_image',
+ )
+ storage_mgr.load_scoped_object_key.assert_awaited_once_with(
+ connection.execution_context,
+ 'v1/current/upload_image/key.png',
+ expected_owner_type='upload_image',
+ )
+ storage_mgr.delete_scoped_object_key.assert_awaited_once_with(
+ connection.execution_context,
+ 'v1/current/upload_image/key.png',
+ expected_owner_type='upload_image',
+ )
+
+ with pytest.raises(ValueError, match='does not belong'):
+ await adapter._process_image_components(
+ connection,
+ [{'type': 'File', 'path': 'v1/other/upload/key.txt'}],
+ )
+
+
def test_history_and_reset_are_scoped_to_browser_session():
matching_provider_session = Mock(
+ instance_uuid=SCOPE_A.instance_uuid,
+ workspace_uuid=SCOPE_A.workspace_uuid,
+ placement_generation=SCOPE_A.placement_generation,
launcher_type=Mock(value='person'),
launcher_id='websocket_pipeline-1:session-a',
)
matching_group_provider_session = Mock(
+ instance_uuid=SCOPE_A.instance_uuid,
+ workspace_uuid=SCOPE_A.workspace_uuid,
+ placement_generation=SCOPE_A.placement_generation,
launcher_type=Mock(value='group'),
launcher_id='websocketgroup_pipeline-1:session-a',
)
other_session = Mock(
+ instance_uuid=SCOPE_A.instance_uuid,
+ workspace_uuid=SCOPE_A.workspace_uuid,
+ placement_generation=SCOPE_A.placement_generation,
launcher_type=Mock(value='person'),
launcher_id='websocket_pipeline-1:session-b',
)
@@ -200,7 +395,7 @@ def test_history_and_reset_are_scoped_to_browser_session():
]
adapter = WebSocketAdapter.model_construct(
ap=ap,
- logger=AsyncMock(),
+ logger=_adapter_logger(),
)
adapter.websocket_person_session = Mock()
adapter.websocket_group_session = Mock()
diff --git a/tests/unit_tests/platform/test_wechatpad_limits.py b/tests/unit_tests/platform/test_wechatpad_limits.py
new file mode 100644
index 000000000..621cf7b0e
--- /dev/null
+++ b/tests/unit_tests/platform/test_wechatpad_limits.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from langbot.libs.wechatpad_api.api import downloadpai
+from langbot.libs.wechatpad_api.util import http_util
+
+
+class _Response:
+ headers = {}
+
+ def __init__(self, chunks: list[bytes]):
+ self._chunks = chunks
+
+ def iter_content(self, chunk_size=None):
+ del chunk_size
+ yield from self._chunks
+
+
+def test_wechatpad_response_reader_is_bounded(monkeypatch):
+ monkeypatch.setattr(http_util, '_MAX_WECHATPAD_RESPONSE_BYTES', 4)
+
+ with pytest.raises(RuntimeError, match='exceeds the runtime limit'):
+ http_util._read_requests_response_limited(_Response([b'1234', b'5']))
+
+
+def test_wechatpad_response_reader_requires_json_object():
+ with pytest.raises(RuntimeError, match='non-object'):
+ http_util._read_requests_response_limited(_Response([b'[]']))
+
+
+@pytest.mark.asyncio
+async def test_wechatpad_media_reader_is_bounded(monkeypatch):
+ monkeypatch.setattr(downloadpai, '_MAX_WECHATPAD_MEDIA_BYTES', 4)
+ response = httpx.Response(200, content=b'oversized')
+
+ with pytest.raises(RuntimeError, match='exceeds'):
+ await downloadpai._read_media_limited(response)
diff --git a/tests/unit_tests/platform/test_wecom_api_limits.py b/tests/unit_tests/platform/test_wecom_api_limits.py
new file mode 100644
index 000000000..9a8b17f1f
--- /dev/null
+++ b/tests/unit_tests/platform/test_wecom_api_limits.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot.libs.wecom_api.api import (
+ _EXTENDED_HTTP_TIMEOUT_SECONDS,
+ _decode_media_base64_limited,
+ WecomClient,
+)
+
+
+@pytest.mark.asyncio
+async def test_wecom_extended_client_timeout_is_still_bounded() -> None:
+ client = object.__new__(WecomClient)
+ client._http_clients = {}
+
+ try:
+ async with client._http_client_context(unbounded_timeout=True) as http_client:
+ assert http_client.timeout.read == _EXTENDED_HTTP_TIMEOUT_SECONDS
+ finally:
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_wecom_base64_decode_is_bounded(monkeypatch) -> None:
+ import langbot.libs.wecom_api.api as wecom_api
+
+ monkeypatch.setattr(wecom_api, '_MAX_MEDIA_BYTES', 4)
+
+ with pytest.raises(ValueError, match='exceeds'):
+ await _decode_media_base64_limited('MTIzNDU=')
diff --git a/tests/unit_tests/platform/test_wecombot_template_card.py b/tests/unit_tests/platform/test_wecombot_template_card.py
index c7ebfbee3..03d8791b1 100644
--- a/tests/unit_tests/platform/test_wecombot_template_card.py
+++ b/tests/unit_tests/platform/test_wecombot_template_card.py
@@ -1,5 +1,6 @@
import sys
import types
+from unittest.mock import Mock
import pytest
@@ -24,6 +25,25 @@ from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
+def test_ws_callback_tasks_are_bounded():
+ client = WecomBotWsClient('bot-id', 'secret', object())
+ client._callback_tasks = {Mock(done=Mock(return_value=False)) for _ in range(100)}
+
+ async def callback():
+ raise AssertionError('rejected callback must not run')
+
+ assert client._start_callback_task(callback()) is False
+ assert len(client._callback_tasks) == 100
+
+
+def test_webhook_dispatch_tasks_are_bounded():
+ client = WecomBotClient('', '', '', object(), unified_mode=True)
+ client._dispatch_tasks = {Mock(done=Mock(return_value=False)) for _ in range(100)}
+
+ assert client._start_dispatch_task(Mock()) is False
+ assert len(client._dispatch_tasks) == 100
+
+
def test_extract_template_card_action_supports_nested_button_key():
task_id, event_key, card_type = extract_template_card_action(
{
@@ -287,16 +307,9 @@ async def test_webhook_stream_queues_cumulative_snapshots_for_followups():
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
- chunks = [
- await client.stream_sessions.consume(session.stream_id),
- await client.stream_sessions.consume(session.stream_id),
- await client.stream_sessions.consume(session.stream_id),
- ]
- assert [(chunk.content, chunk.is_final) for chunk in chunks] == [
- ('你', False),
- ('你好', False),
- ('你好', True),
- ]
+ assert session.queue.qsize() == 1
+ chunk = await client.stream_sessions.consume(session.stream_id)
+ assert (chunk.content, chunk.is_final) == ('你好', True)
def test_human_input_payload_keeps_action_select_stage_as_buttons():
diff --git a/tests/unit_tests/plugin/test_connector_methods.py b/tests/unit_tests/plugin/test_connector_methods.py
index 88de1608d..c5a2ffe15 100644
--- a/tests/unit_tests/plugin/test_connector_methods.py
+++ b/tests/unit_tests/plugin/test_connector_methods.py
@@ -9,11 +9,32 @@ Tests cover:
from __future__ import annotations
+from contextlib import nullcontext
+from types import SimpleNamespace
+
import pytest
-from unittest.mock import Mock, AsyncMock
+from unittest.mock import AsyncMock, Mock
from importlib import import_module
from tests.factories import text_query
+from langbot_plugin.entities.io.context import InstallationBinding
+
+from langbot.pkg.api.http.context import ExecutionContext
+
+
+TEST_EXECUTION_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+TEST_INSTALLATION_BINDING = InstallationBinding(
+ instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
+ workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
+ placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest='a' * 64,
+)
def get_connector_module():
@@ -29,6 +50,7 @@ def create_mock_app():
mock_app.instance_config.data = {'plugin': {'enable': True}}
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
+ mock_app.persistence_mgr.tenant_uow = None
return mock_app
@@ -39,7 +61,19 @@ def create_mock_connector():
async def mock_disconnect_callback(conn):
pass
- return connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
+ instance = connector.PluginRuntimeConnector(create_mock_app(), mock_disconnect_callback)
+ instance._execution_context.set(TEST_EXECUTION_CONTEXT)
+ instance._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
+ instance._target_binding = AsyncMock(return_value=TEST_INSTALLATION_BINDING)
+ instance._load_workspace_settings = AsyncMock(return_value=[])
+ instance.require_workspace_context = AsyncMock(side_effect=lambda context: context)
+ return instance
+
+
+def configure_handler(connector, runtime_handler):
+ runtime_handler.installation_scope = Mock(side_effect=lambda _binding: nullcontext())
+ connector.handler = runtime_handler
+ return runtime_handler
class TestListPlugins:
@@ -87,7 +121,7 @@ class TestListPlugins:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.list_plugins = AsyncMock(
return_value=[{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
)
@@ -96,6 +130,7 @@ class TestListPlugins:
connector.handler.list_plugins.assert_called_once()
assert result == [{'manifest': {'manifest': {'metadata': {'author': 'test', 'name': 'plugin'}}}}]
+ connector._load_workspace_settings.assert_awaited_once_with(TEST_EXECUTION_CONTEXT)
@pytest.mark.asyncio
async def test_filters_by_component_kinds(self):
@@ -103,7 +138,7 @@ class TestListPlugins:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.list_plugins = AsyncMock(
return_value=[
{
@@ -130,7 +165,7 @@ class TestListPlugins:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.list_plugins = AsyncMock(
return_value=[
{
@@ -177,7 +212,7 @@ class TestPluginDiagnostics:
'response_sources': response_sources,
}
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
fake_event_ctx = Mock()
@@ -221,7 +256,7 @@ class TestPluginDiagnostics:
],
}
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
fake_event_ctx = Mock()
@@ -244,7 +279,7 @@ class TestPluginDiagnostics:
connector_module.context.EventContext.from_event = original_from_event
connector_module.context.EventContext.model_validate = original_model_validate
- assert '_response_sources' not in vars(event_ctx)
+ assert event_ctx._response_sources == []
assert event_ctx._emitted_plugins == [
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
]
@@ -259,7 +294,7 @@ class TestPluginDiagnostics:
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
@@ -268,7 +303,7 @@ class TestPluginDiagnostics:
@pytest.mark.asyncio
async def test_notify_plugin_diagnostic_is_best_effort(self):
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.notify_plugin_diagnostic = AsyncMock(side_effect=RuntimeError('action not found'))
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
@@ -303,7 +338,7 @@ class TestListKnowledgeEngines:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.list_knowledge_engines = AsyncMock(
return_value=[{'plugin_id': 'author/engine', 'name': 'Engine'}]
)
@@ -346,7 +381,7 @@ class TestListParsers:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.list_parsers = AsyncMock(
return_value=[{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
)
@@ -372,7 +407,7 @@ class TestCallParser:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.parse_document = AsyncMock(return_value={'content': 'parsed'})
result = await connector.call_parser(
@@ -399,7 +434,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.rag_ingest_document = AsyncMock(return_value={'status': 'success'})
result = await connector.call_rag_ingest('author/engine', {'file': 'test.pdf'})
@@ -413,7 +448,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.retrieve_knowledge = AsyncMock(
return_value={
'results': [
@@ -442,7 +477,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.get_rag_creation_schema = AsyncMock(return_value={'properties': {'name': {'type': 'string'}}})
result = await connector.get_rag_creation_schema('author/engine')
@@ -456,7 +491,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.get_rag_retrieval_schema = AsyncMock(
return_value={'properties': {'top_k': {'type': 'integer'}}}
)
@@ -472,7 +507,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.rag_on_kb_create = AsyncMock(return_value={'status': 'ok'})
await connector.rag_on_kb_create('author/engine', 'kb-uuid', {'model': 'test'})
@@ -485,7 +520,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.rag_on_kb_delete = AsyncMock(return_value={'status': 'ok'})
await connector.rag_on_kb_delete('author/engine', 'kb-uuid')
@@ -498,7 +533,7 @@ class TestRAGMethods:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.rag_delete_document = AsyncMock(return_value=True)
result = await connector.call_rag_delete_document('author/engine', 'doc-uuid', 'kb-uuid')
@@ -592,7 +627,7 @@ class TestGetPluginInfo:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.get_plugin_info = AsyncMock(return_value={'manifest': {'metadata': {'name': 'plugin'}}})
result = await connector.get_plugin_info('author', 'plugin')
@@ -605,17 +640,39 @@ class TestSetPluginConfig:
"""Tests for set_plugin_config method."""
@pytest.mark.asyncio
- async def test_calls_handler_set_plugin_config(self):
- """Test that handler.set_plugin_config is called."""
+ async def test_updates_revision_then_applies_desired_state(self):
+ """Config changes are fenced by a new runtime revision."""
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
- connector.handler.set_plugin_config = AsyncMock(return_value={'status': 'ok'})
+ configure_handler(connector, AsyncMock())
+ connector.handler.register_installation_binding = Mock()
+ connector.handler.apply_plugin_installation = AsyncMock(return_value={'state': 'running'})
+ setting = SimpleNamespace(
+ installation_uuid=TEST_INSTALLATION_BINDING.installation_uuid,
+ runtime_revision=1,
+ artifact_digest=TEST_INSTALLATION_BINDING.artifact_digest,
+ enabled=True,
+ install_info={'_artifact_storage': 'tenant_binary_storage_v1'},
+ )
+ connector._setting_for_plugin = AsyncMock(return_value=(TEST_EXECUTION_CONTEXT, setting))
+ connector.ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=1))
await connector.set_plugin_config('author', 'plugin', {'setting': 'value'})
- connector.handler.set_plugin_config.assert_called_once_with('author', 'plugin', {'setting': 'value'})
+ applied_binding = connector.handler.apply_plugin_installation.await_args.args[0]
+ assert applied_binding.runtime_revision == 2
+ assert applied_binding.installation_uuid == TEST_INSTALLATION_BINDING.installation_uuid
+ connector.handler.register_installation_binding.assert_called_once_with(
+ applied_binding,
+ plugin_author='author',
+ plugin_name='plugin',
+ )
+ connector.handler.apply_plugin_installation.assert_awaited_once_with(
+ applied_binding,
+ artifact_package=None,
+ enabled=True,
+ )
class TestPingPluginRuntime:
@@ -639,7 +696,7 @@ class TestPingPluginRuntime:
get_connector_module()
connector = create_mock_connector()
- connector.handler = AsyncMock()
+ configure_handler(connector, AsyncMock())
connector.handler.ping = AsyncMock(return_value={'status': 'ok'})
await connector.ping_plugin_runtime()
diff --git a/tests/unit_tests/plugin/test_connector_ping.py b/tests/unit_tests/plugin/test_connector_ping.py
index 0a8e74207..b398840c7 100644
--- a/tests/unit_tests/plugin/test_connector_ping.py
+++ b/tests/unit_tests/plugin/test_connector_ping.py
@@ -6,14 +6,34 @@ from unittest.mock import AsyncMock, Mock
import pytest
+from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.plugin import connector as connector_module
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
+from langbot_plugin.runtime.security import (
+ PLUGIN_RUNTIME_CONTROL_TOKEN_ENV,
+ PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER,
+)
def make_connector() -> PluginRuntimeConnector:
app = SimpleNamespace(
logger=Mock(),
- instance_config=SimpleNamespace(data={'plugin': {'enable': True}, 'space': {'url': ''}}),
+ instance_config=SimpleNamespace(
+ data={
+ 'plugin': {
+ 'enable': True,
+ 'worker': {
+ 'max_cpus': 1.0,
+ 'max_memory_mb': 512,
+ 'max_pids': 128,
+ 'max_open_files': 256,
+ 'max_file_size_mb': 512,
+ 'require_hard_limits': False,
+ },
+ },
+ 'space': {'url': ''},
+ }
+ ),
)
return PluginRuntimeConnector(app, AsyncMock())
@@ -57,7 +77,9 @@ async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr(
monkeypatch: pytest.MonkeyPatch,
):
connector = make_connector()
+ connector._prepare_connected_runtime = AsyncMock()
created = {}
+ monkeypatch.setattr(connector_module.constants, 'instance_id', 'instance-a')
class FakeRuntimeHandler:
def __init__(self, connection, disconnect_callback, ap):
@@ -106,6 +128,7 @@ async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr(
assert created['capture_stderr'] is False
assert connector._connected.is_set()
+ connector._prepare_connected_runtime.assert_awaited_once()
await connector.aclose()
@@ -115,6 +138,8 @@ async def test_runtime_disconnect_notifies_once_and_clears_handler(
):
disconnect = AsyncMock()
connector = PluginRuntimeConnector(make_connector().ap, disconnect)
+ connector._prepare_connected_runtime = AsyncMock()
+ monkeypatch.setattr(connector_module.constants, 'instance_id', 'instance-a')
class FakeRuntimeHandler:
def __init__(self, connection, disconnect_callback, ap):
@@ -165,3 +190,212 @@ async def test_runtime_disconnect_notifies_once_and_clears_handler(
disconnect.assert_awaited_once_with(connector)
assert not hasattr(connector, 'handler')
await connector.aclose()
+
+
+@pytest.mark.asyncio
+async def test_disabled_connector_validates_workspace_without_runtime_handler():
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': False}}),
+ workspace_service=SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+ )
+ )
+ ),
+ )
+ connector = PluginRuntimeConnector(app, AsyncMock())
+ request_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+ )
+
+ result = await connector.require_workspace_context(request_context)
+
+ assert result == request_context
+ app.workspace_service.get_execution_binding.assert_awaited_once_with(
+ 'workspace-a',
+ expected_generation=3,
+ )
+
+
+@pytest.mark.asyncio
+async def test_enabled_connector_reports_not_connected_after_workspace_validation():
+ connector = make_connector()
+ connector.ap.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+ )
+ )
+ )
+ request_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+ )
+
+ with pytest.raises(PluginRuntimeNotConnectedError, match='Plugin runtime is not connected'):
+ await connector.require_workspace_context(request_context)
+
+
+@pytest.mark.asyncio
+async def test_oss_connector_resolves_singleton_only_for_legacy_callers():
+ connector = make_connector()
+ connector.ap.workspace_service = SimpleNamespace(
+ get_local_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+ )
+ )
+ )
+
+ assert await connector._current_execution_context() == ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=3,
+ )
+
+
+def test_edition_metadata_cannot_enable_shared_runtime_profile():
+ connector = make_connector()
+ connector.ap.instance_config.data['system'] = {'edition': 'cloud'}
+
+ assert connector.runtime_profile == 'oss_dev'
+
+
+def test_closed_deployment_selects_instance_scoped_shared_profile():
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
+ deployment=SimpleNamespace(mode='cloud'),
+ )
+
+ connector = PluginRuntimeConnector(app, AsyncMock())
+
+ assert connector.runtime_profile == 'shared'
+
+
+def test_external_runtime_control_headers_require_strong_secret(monkeypatch):
+ monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
+ connector = make_connector()
+
+ with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
+ connector._control_headers(allow_generate=False)
+
+
+def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
+ monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
+ connector = make_connector()
+
+ headers = connector._control_headers(allow_generate=True)
+
+ assert len(headers[PLUGIN_RUNTIME_CONTROL_TOKEN_HEADER]) >= 32
+
+
+@pytest.mark.asyncio
+async def test_oss_legacy_fallback_fails_without_workspace_service():
+ connector = make_connector()
+
+ with pytest.raises(AttributeError):
+ await connector._current_execution_context()
+
+
+@pytest.mark.asyncio
+async def test_cloud_connector_never_falls_back_to_ghost_local_workspace():
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
+ deployment=SimpleNamespace(mode='cloud'),
+ )
+ get_local_binding = AsyncMock()
+ app.workspace_service = SimpleNamespace(
+ get_local_execution_binding=get_local_binding,
+ )
+ connector = PluginRuntimeConnector(app, AsyncMock())
+
+ with pytest.raises(Exception, match='Plugin resource not found'):
+ await connector._current_execution_context()
+
+ get_local_binding.assert_not_awaited()
+
+
+def test_worker_policy_is_loaded_only_from_instance_configuration():
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'plugin': {
+ 'enable': True,
+ 'worker': {
+ 'max_cpus': 1.5,
+ 'max_memory_mb': 768,
+ 'max_pids': 64,
+ 'max_open_files': 128,
+ 'max_file_size_mb': 32,
+ 'max_concurrent_restarts': 2,
+ 'restart_failure_threshold': 12,
+ 'restart_failure_window_seconds': 45,
+ 'restart_circuit_open_seconds': 90,
+ 'require_hard_limits': True,
+ },
+ # A plugin-controlled value at any other path is ignored.
+ 'manifest': {'max_memory_mb': 99999},
+ }
+ }
+ )
+ )
+ connector = PluginRuntimeConnector(app, AsyncMock())
+
+ policy = connector._load_worker_policy()
+
+ assert policy.max_cpus == 1.5
+ assert policy.max_memory_mb == 768
+ assert policy.max_pids == 64
+ assert policy.max_open_files == 128
+ assert policy.max_file_size_mb == 32
+ assert policy.max_concurrent_restarts == 2
+ assert policy.restart_failure_threshold == 12
+ assert policy.restart_failure_window_seconds == 45
+ assert policy.restart_circuit_open_seconds == 90
+ assert policy.require_hard_limits is True
+
+
+@pytest.mark.asyncio
+async def test_explicit_cloud_binding_is_revalidated_against_projection():
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(
+ data={
+ 'plugin': {'enable': True},
+ }
+ ),
+ deployment=SimpleNamespace(mode='cloud'),
+ )
+ app.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-cloud-a',
+ placement_generation=7,
+ )
+ ),
+ )
+ connector = PluginRuntimeConnector(app, AsyncMock())
+ connector.handler = SimpleNamespace()
+ connector._synchronize_workspace = AsyncMock()
+ configured = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-cloud-a',
+ placement_generation=7,
+ )
+
+ assert await connector.require_workspace_context(configured) == configured
+ app.workspace_service.get_execution_binding.assert_awaited_once_with(
+ 'workspace-cloud-a',
+ expected_generation=7,
+ )
+ connector._synchronize_workspace.assert_awaited_once_with(configured)
diff --git a/tests/unit_tests/plugin/test_connector_pure.py b/tests/unit_tests/plugin/test_connector_pure.py
index 13ba29b50..5cc863940 100644
--- a/tests/unit_tests/plugin/test_connector_pure.py
+++ b/tests/unit_tests/plugin/test_connector_pure.py
@@ -12,6 +12,7 @@ import zipfile
from types import SimpleNamespace
from unittest.mock import MagicMock
+import httpx
import pytest
@@ -123,6 +124,17 @@ class TestExtractDepsMetadata:
# Should find requirements.txt in subdirectory
assert task_context.metadata['deps_total'] == 2
+ def test_archive_preview_rejects_extreme_compression_ratio(self):
+ from langbot.pkg.plugin.connector import inspect_plugin_archive_metadata
+
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr('manifest.yaml', 'kind: Plugin\nmetadata: {}\n')
+ zf.writestr('bomb.py', b'A' * (1024 * 1024))
+
+ with pytest.raises(ValueError, match='compression-ratio limit'):
+ inspect_plugin_archive_metadata(zip_buffer.getvalue())
+
class TestParsePluginId:
"""Tests for _parse_plugin_id static method."""
@@ -141,3 +153,13 @@ class TestParsePluginId:
with pytest.raises(ValueError):
PluginRuntimeConnector._parse_plugin_id('')
+
+
+@pytest.mark.asyncio
+async def test_marketplace_response_reader_is_bounded():
+ from langbot.pkg.plugin.connector import _read_httpx_response_limited
+
+ response = httpx.Response(200, content=b'oversized')
+
+ with pytest.raises(ValueError, match='exceeds'):
+ await _read_httpx_response_limited(response, max_bytes=4)
diff --git a/tests/unit_tests/plugin/test_connector_reconcile.py b/tests/unit_tests/plugin/test_connector_reconcile.py
new file mode 100644
index 000000000..b22e8e13e
--- /dev/null
+++ b/tests/unit_tests/plugin/test_connector_reconcile.py
@@ -0,0 +1,499 @@
+from __future__ import annotations
+
+import datetime
+import hashlib
+from contextlib import nullcontext
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+from langbot_plugin.entities.io.context import InstallationBinding
+from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.plugin.connector import (
+ PluginInstallationFailedError,
+ PluginRuntimeConnector,
+)
+
+
+def connection_result_connector(execute_async: AsyncMock) -> PluginRuntimeConnector:
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
+ deployment=SimpleNamespace(mode='cloud'),
+ persistence_mgr=SimpleNamespace(
+ tenant_uow=None,
+ execute_async=execute_async,
+ ),
+ logger=Mock(),
+ )
+ return PluginRuntimeConnector(app, AsyncMock())
+
+
+def execution_binding(workspace_uuid: str, generation: int = 1) -> SimpleNamespace:
+ return SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ )
+
+
+def plugin_setting(
+ workspace_suffix: str,
+ artifact_digest: str,
+ *,
+ durable: bool = True,
+) -> SimpleNamespace:
+ return SimpleNamespace(
+ plugin_author='author',
+ plugin_name=f'plugin-{workspace_suffix}',
+ installation_uuid=f'00000000-0000-4000-8000-0000000000{workspace_suffix}',
+ runtime_revision=1,
+ artifact_digest=artifact_digest,
+ enabled=True,
+ priority=0,
+ created_at=datetime.datetime(2026, 1, 1),
+ install_source='local',
+ install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if durable else {},
+ )
+
+
+def runtime_handler(
+ *,
+ missing_artifacts: list[str] | None = None,
+ failed_installations: list[dict[str, str]] | None = None,
+) -> SimpleNamespace:
+ return SimpleNamespace(
+ register_installation_binding=Mock(),
+ unregister_installation_binding=Mock(),
+ reconcile_plugin_installations=AsyncMock(
+ return_value={
+ 'applied': [],
+ 'removed': [],
+ 'missing_artifacts': missing_artifacts or [],
+ 'failed_installations': failed_installations or [],
+ }
+ ),
+ apply_plugin_installation=AsyncMock(return_value={'state': 'starting'}),
+ installation_scope=Mock(side_effect=lambda _binding: nullcontext()),
+ list_plugins=AsyncMock(return_value=[]),
+ )
+
+
+def shared_connector(
+ projected_bindings: list[list[SimpleNamespace]],
+ settings: dict[str, list[SimpleNamespace]],
+) -> PluginRuntimeConnector:
+ async def get_execution_binding(workspace_uuid: str, *, expected_generation: int | None = None):
+ for binding_set in projected_bindings:
+ for binding in binding_set:
+ if binding.workspace_uuid == workspace_uuid:
+ assert expected_generation in (None, binding.placement_generation)
+ return binding
+ raise AssertionError(f'unexpected Workspace {workspace_uuid}')
+
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
+ deployment=SimpleNamespace(mode='cloud'),
+ workspace_service=SimpleNamespace(
+ list_active_execution_bindings=AsyncMock(side_effect=projected_bindings),
+ get_execution_binding=AsyncMock(side_effect=get_execution_binding),
+ ),
+ persistence_mgr=SimpleNamespace(tenant_uow=None),
+ logger=Mock(),
+ )
+ connector = PluginRuntimeConnector(app, AsyncMock())
+ connector._load_workspace_settings = AsyncMock(side_effect=lambda context: settings[context.workspace_uuid])
+ return connector
+
+
+@pytest.mark.asyncio
+async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
+ binding_a = execution_binding('workspace-a')
+ binding_b = execution_binding('workspace-b')
+ setting_a = plugin_setting('01', 'a' * 64)
+ setting_b = plugin_setting('02', 'b' * 64)
+ connector = shared_connector(
+ [[binding_a, binding_b], [binding_a]],
+ {'workspace-a': [setting_a], 'workspace-b': [setting_b]},
+ )
+
+ first_handler = runtime_handler()
+ connector.handler = first_handler
+ await connector._prepare_connected_runtime()
+
+ first_desired = first_handler.reconcile_plugin_installations.await_args.args[0]
+ assert {state.binding.workspace_uuid for state in first_desired} == {'workspace-a', 'workspace-b'}
+
+ second_handler = runtime_handler()
+ connector.handler = second_handler
+ await connector._prepare_connected_runtime()
+
+ second_desired = second_handler.reconcile_plugin_installations.await_args.args[0]
+ assert [state.binding.workspace_uuid for state in second_desired] == ['workspace-a']
+ second_handler.unregister_installation_binding.assert_called_once_with(first_desired[1].binding)
+ assert set(connector._workspace_installations) == {'workspace-a'}
+ assert set(connector._known_desired_states) == {setting_a.installation_uuid}
+
+
+@pytest.mark.asyncio
+async def test_empty_projected_workspaces_do_not_retain_installation_sets():
+ binding_a = execution_binding('workspace-a')
+ binding_b = execution_binding('workspace-b')
+ connector = shared_connector(
+ [[binding_a, binding_b]],
+ {'workspace-a': [], 'workspace-b': []},
+ )
+ connector.handler = runtime_handler()
+
+ await connector._prepare_connected_runtime()
+
+ assert connector._workspace_installations == {}
+ assert connector._known_desired_states == {}
+ connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
+
+
+@pytest.mark.asyncio
+async def test_fresh_shared_runtime_cache_replays_persisted_local_package():
+ package = b'local-lbpkg-bytes'
+ digest = hashlib.sha256(package).hexdigest()
+ binding = execution_binding('workspace-a')
+ setting = plugin_setting('01', digest)
+ connector = shared_connector([[binding]], {'workspace-a': [setting]})
+ connector.handler = runtime_handler(missing_artifacts=[setting.installation_uuid])
+ connector._load_artifact_package = AsyncMock(return_value=package)
+
+ await connector._prepare_connected_runtime()
+
+ desired = connector.handler.reconcile_plugin_installations.await_args.args[0][0]
+ connector._load_artifact_package.assert_awaited_once()
+ connector.handler.apply_plugin_installation.assert_awaited_once_with(
+ desired.binding,
+ artifact_package=package,
+ enabled=True,
+ )
+
+
+@pytest.mark.asyncio
+async def test_oss_upgrade_keeps_legacy_data_plugins_when_no_lbpkg_was_backfilled():
+ binding = execution_binding('workspace-a')
+ setting = plugin_setting('01', hashlib.sha256(b'legacy-installation').hexdigest(), durable=False)
+ legacy_plugin = {
+ 'debug': False,
+ 'manifest': {'manifest': {'metadata': {'author': 'author', 'name': 'plugin-01'}}},
+ 'components': [],
+ }
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
+ deployment=SimpleNamespace(mode='oss'),
+ workspace_service=SimpleNamespace(get_local_execution_binding=AsyncMock(return_value=binding)),
+ persistence_mgr=SimpleNamespace(tenant_uow=None),
+ logger=Mock(),
+ )
+ connector = PluginRuntimeConnector(app, AsyncMock())
+ connector.handler = runtime_handler(missing_artifacts=[setting.installation_uuid])
+ connector.handler.list_plugins = AsyncMock(return_value=[legacy_plugin])
+ connector.handler.apply_plugin_installation = AsyncMock(return_value={'state': 'artifact_missing'})
+ connector._load_workspace_settings = AsyncMock(return_value=[setting])
+ connector._load_artifact_package = AsyncMock(return_value=None)
+
+ await connector._prepare_connected_runtime()
+ plugins = await connector.list_plugins()
+
+ assert plugins == [legacy_plugin]
+ assert connector.handler.list_plugins.await_count >= 2
+ connector._load_artifact_package.assert_awaited_once()
+ assert not hasattr(connector.handler, 'delete_plugin')
+
+
+@pytest.mark.asyncio
+async def test_local_install_persists_verified_package_before_runtime_apply():
+ package = b'local-lbpkg-bytes'
+ digest = hashlib.sha256(package).hexdigest()
+ execution_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ binding = InstallationBinding(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest=digest,
+ )
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
+ deployment=SimpleNamespace(mode='cloud'),
+ logger=Mock(),
+ )
+ connector = PluginRuntimeConnector(app, AsyncMock())
+ connector.handler = runtime_handler()
+ connector._current_execution_context = AsyncMock(return_value=execution_context)
+ connector._inspect_plugin_package = Mock(return_value=('author', 'plugin'))
+ connector._store_artifact_package = AsyncMock()
+ connector._persist_installation_package = AsyncMock(return_value=(binding, None, False))
+ connector._wait_for_installed_plugin_ready = AsyncMock()
+
+ await connector.install_plugin(
+ PluginInstallSource.LOCAL,
+ {'plugin_file': package},
+ )
+
+ connector._store_artifact_package.assert_awaited_once_with(execution_context, digest, package)
+ connector.handler.apply_plugin_installation.assert_awaited_once_with(
+ binding,
+ artifact_package=package,
+ enabled=True,
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)])
+async def test_artifact_cleanup_is_reference_counted_within_workspace(
+ remaining_references: int,
+ statement_count: int,
+):
+ app = SimpleNamespace(
+ instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
+ deployment=SimpleNamespace(mode='cloud'),
+ )
+ connector = PluginRuntimeConnector(app, AsyncMock())
+ execution_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ statements = []
+
+ async def execute(statement):
+ statements.append(statement)
+ return SimpleNamespace(scalar_one=lambda: remaining_references)
+
+ await connector._delete_artifact_if_unreferenced(
+ execution_context,
+ 'a' * 64,
+ execute=execute,
+ )
+
+ assert len(statements) == statement_count
+
+
+@pytest.mark.asyncio
+async def test_artifact_store_and_load_accept_connection_scalar_results():
+ package = b'persisted-lbpkg'
+ digest = hashlib.sha256(package).hexdigest()
+ execution_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ execute_async = AsyncMock(return_value=SimpleNamespace(scalar_one_or_none=lambda: package))
+ connector = connection_result_connector(execute_async)
+
+ await connector._store_artifact_package(execution_context, digest, package)
+ loaded = await connector._load_artifact_package(execution_context, digest)
+
+ assert loaded == package
+ assert execute_async.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_workspace_settings_accept_connection_mapping_results():
+ created_at = datetime.datetime(2026, 1, 1)
+ row = {
+ 'workspace_uuid': 'workspace-a',
+ 'plugin_author': 'author',
+ 'plugin_name': 'plugin',
+ 'installation_uuid': '00000000-0000-4000-8000-000000000001',
+ 'artifact_digest': 'a' * 64,
+ 'runtime_revision': 2,
+ 'enabled': True,
+ 'priority': 3,
+ 'config': {'key': 'value'},
+ 'install_source': 'local',
+ 'install_info': {'_artifact_storage': 'tenant_binary_storage_v1'},
+ 'created_at': created_at,
+ 'updated_at': created_at,
+ }
+ mapped_result = SimpleNamespace(mappings=lambda: SimpleNamespace(all=lambda: [row]))
+ connector = connection_result_connector(AsyncMock(return_value=mapped_result))
+
+ settings = await connector._load_workspace_settings(
+ ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ )
+
+ assert len(settings) == 1
+ assert settings[0].installation_uuid == row['installation_uuid']
+ assert settings[0].runtime_revision == 2
+ assert settings[0].created_at == created_at
+
+
+@pytest.mark.asyncio
+async def test_installation_update_accepts_connection_row_result():
+ old_digest = 'a' * 64
+ new_digest = 'b' * 64
+ existing = SimpleNamespace(
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=4,
+ artifact_digest=old_digest,
+ install_info={'_artifact_storage': 'tenant_binary_storage_v1'},
+ )
+ execute_async = AsyncMock(
+ side_effect=[
+ SimpleNamespace(first=lambda: existing),
+ SimpleNamespace(rowcount=1),
+ ]
+ )
+ connector = connection_result_connector(execute_async)
+ execution_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=7,
+ )
+
+ binding, previous_digest, previous_was_durable = await connector._persist_installation_package(
+ execution_context,
+ plugin_author='author',
+ plugin_name='plugin',
+ install_source=PluginInstallSource.LOCAL,
+ install_info={},
+ artifact_digest=new_digest,
+ )
+
+ assert binding.installation_uuid == existing.installation_uuid
+ assert binding.runtime_revision == 5
+ assert binding.artifact_digest == new_digest
+ assert previous_digest == old_digest
+ assert previous_was_durable is True
+
+
+@pytest.mark.asyncio
+async def test_apply_dependency_failure_raises_stable_observable_error():
+ binding = execution_binding('workspace-a')
+ setting = plugin_setting('01', 'a' * 64)
+ connector = shared_connector([[binding]], {'workspace-a': [setting]})
+ connector.handler = runtime_handler()
+ desired = (
+ await connector._load_workspace_desired_states(
+ ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ )
+ )[0]
+ connector.handler.apply_plugin_installation.return_value = {
+ 'installation_uuid': setting.installation_uuid,
+ 'state': 'failed',
+ 'error_code': 'dependency_prepare_failed',
+ 'message': 'Plugin dependency installer exited with code 1',
+ }
+
+ with pytest.raises(PluginInstallationFailedError) as exc_info:
+ await connector._apply_desired_state(desired)
+
+ error = exc_info.value
+ assert error.installation_uuid == setting.installation_uuid
+ assert error.error_code == 'dependency_prepare_failed'
+ assert '[dependency_prepare_failed]' in str(error)
+ assert connector._installation_failures[setting.installation_uuid] == {
+ 'installation_uuid': setting.installation_uuid,
+ 'error_code': 'dependency_prepare_failed',
+ 'message': 'Plugin dependency installer exited with code 1',
+ }
+ connector.ap.logger.error.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_shared_reconcile_records_one_failure_without_blocking_other_state():
+ binding_a = execution_binding('workspace-a')
+ binding_b = execution_binding('workspace-b')
+ setting_a = plugin_setting('01', 'a' * 64)
+ setting_b = plugin_setting('02', 'b' * 64)
+ failure = {
+ 'installation_uuid': setting_a.installation_uuid,
+ 'error_code': 'dependency_prepare_failed',
+ 'message': 'Plugin dependency installer exited with code 1',
+ }
+ connector = shared_connector(
+ [[binding_a, binding_b]],
+ {'workspace-a': [setting_a], 'workspace-b': [setting_b]},
+ )
+ connector.handler = runtime_handler(failed_installations=[failure])
+
+ await connector._prepare_connected_runtime()
+
+ desired = connector.handler.reconcile_plugin_installations.await_args.args[0]
+ assert {item.binding.installation_uuid for item in desired} == {
+ setting_a.installation_uuid,
+ setting_b.installation_uuid,
+ }
+ assert connector._installation_failures == {
+ setting_a.installation_uuid: failure,
+ }
+ assert set(connector._known_desired_states) == {
+ setting_a.installation_uuid,
+ setting_b.installation_uuid,
+ }
+ connector.ap.logger.error.assert_called_once_with(
+ 'Plugin installation %s failed during reconcile [%s]: %s',
+ setting_a.installation_uuid,
+ 'dependency_prepare_failed',
+ 'Plugin dependency installer exited with code 1',
+ )
+
+
+@pytest.mark.asyncio
+async def test_missing_artifact_repair_adds_dependency_failure_and_continues():
+ package_a = b'package-a'
+ package_b = b'package-b'
+ binding = execution_binding('workspace-a')
+ setting_a = plugin_setting('01', hashlib.sha256(package_a).hexdigest())
+ setting_b = plugin_setting('02', hashlib.sha256(package_b).hexdigest())
+ connector = shared_connector(
+ [[binding]],
+ {'workspace-a': [setting_a, setting_b]},
+ )
+ connector.handler = runtime_handler(
+ missing_artifacts=[
+ setting_a.installation_uuid,
+ setting_b.installation_uuid,
+ ]
+ )
+ connector._load_artifact_package = AsyncMock(side_effect=[package_a, package_b])
+ connector.handler.apply_plugin_installation.side_effect = [
+ {
+ 'installation_uuid': setting_a.installation_uuid,
+ 'state': 'failed',
+ 'error_code': 'dependency_prepare_failed',
+ 'message': 'Plugin dependency installer exited with code 1',
+ },
+ {'installation_uuid': setting_b.installation_uuid, 'state': 'starting'},
+ ]
+
+ result = await connector.reconcile_projected_workspaces(
+ [
+ ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ ]
+ )
+
+ assert connector.handler.apply_plugin_installation.await_count == 2
+ assert result['failed_installations'] == [
+ {
+ 'installation_uuid': setting_a.installation_uuid,
+ 'error_code': 'dependency_prepare_failed',
+ 'message': 'Plugin dependency installer exited with code 1',
+ }
+ ]
+ assert setting_a.installation_uuid in connector._installation_failures
+ assert setting_b.installation_uuid not in connector._installation_failures
diff --git a/tests/unit_tests/plugin/test_connector_static.py b/tests/unit_tests/plugin/test_connector_static.py
index 8c88b9707..2db33b708 100644
--- a/tests/unit_tests/plugin/test_connector_static.py
+++ b/tests/unit_tests/plugin/test_connector_static.py
@@ -53,3 +53,10 @@ class TestParsePluginId:
author, name = connector.PluginRuntimeConnector._parse_plugin_id('lang-bot/my_rag_engine')
assert author == 'lang-bot'
assert name == 'my_rag_engine'
+
+
+def test_runtime_id_is_stable_across_core_restarts(monkeypatch):
+ connector = get_connector_module()
+ monkeypatch.setattr(connector.constants, 'instance_id', 'instance-a')
+
+ assert connector.PluginRuntimeConnector._build_runtime_id() == 'instance-a:plugin-runtime'
diff --git a/tests/unit_tests/plugin/test_github_install_security.py b/tests/unit_tests/plugin/test_github_install_security.py
new file mode 100644
index 000000000..af1c71c34
--- /dev/null
+++ b/tests/unit_tests/plugin/test_github_install_security.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+import httpx
+import pytest
+
+from langbot.pkg.plugin import connector as connector_module
+
+from .test_connector_methods import create_mock_connector
+
+
+TRUSTED_LEGACY_REQUEST = {
+ 'owner': 'langbot-app',
+ 'repo': 'demo-plugin',
+ 'release_tag': 'v1.0.0',
+ 'asset_url': 'https://github.com/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
+}
+
+
+def _patch_client(monkeypatch, handler):
+ real_async_client = httpx.AsyncClient
+ observed: list[dict] = []
+
+ def client_factory(*args, **kwargs):
+ observed.append(dict(kwargs))
+ return real_async_client(
+ transport=httpx.MockTransport(handler),
+ follow_redirects=kwargs.get('follow_redirects', False),
+ trust_env=kwargs.get('trust_env', True),
+ timeout=kwargs.get('timeout'),
+ )
+
+ monkeypatch.setattr(connector_module.httpx, 'AsyncClient', client_factory)
+ return observed
+
+
+@pytest.mark.parametrize(
+ 'asset_url',
+ [
+ 'http://127.0.0.1/internal.lbpkg',
+ 'https://169.254.169.254/latest/meta-data',
+ 'https://github.com@127.0.0.1/internal.lbpkg',
+ 'https://evil.example/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
+ ],
+)
+@pytest.mark.asyncio
+async def test_github_install_rejects_internal_or_untrusted_asset_url_before_network(
+ monkeypatch,
+ asset_url,
+):
+ connector = create_mock_connector()
+ monkeypatch.setattr(
+ connector_module.httpx,
+ 'AsyncClient',
+ lambda *_args, **_kwargs: pytest.fail('network client must not be created'),
+ )
+
+ with pytest.raises(ValueError, match='GitHub release asset URL'):
+ await connector._download_github_package(
+ {**TRUSTED_LEGACY_REQUEST, 'asset_url': asset_url},
+ None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_github_asset_id_is_resolved_server_side_and_redirect_escape_is_rejected(
+ monkeypatch,
+):
+ connector = create_mock_connector()
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith('/releases/42'):
+ return httpx.Response(
+ 200,
+ json={
+ 'id': 42,
+ 'tag_name': 'v1.0.0',
+ 'assets': [{'id': 99, 'size': 128, 'state': 'uploaded'}],
+ },
+ )
+ if request.url.path.endswith('/releases/assets/99'):
+ return httpx.Response(
+ 302,
+ headers={'location': 'http://169.254.169.254/latest/meta-data'},
+ )
+ raise AssertionError(f'unexpected request: {request.url}')
+
+ observed = _patch_client(monkeypatch, handler)
+ with pytest.raises(ValueError, match='untrusted host'):
+ await connector._download_github_package(
+ {
+ 'owner': 'langbot-app',
+ 'repo': 'demo-plugin',
+ 'release_tag': 'v1.0.0',
+ 'release_id': 42,
+ 'asset_id': 99,
+ 'asset_url': 'https://attacker.invalid/ignored',
+ },
+ None,
+ )
+
+ assert len(observed) == 1
+ assert observed[0]['trust_env'] is False
+ assert observed[0]['follow_redirects'] is False
+
+
+@pytest.mark.asyncio
+async def test_github_download_rejects_oversized_content_length(monkeypatch):
+ connector = create_mock_connector()
+ monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
+
+ def handler(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, headers={'content-length': '9'}, content=b'')
+
+ _patch_client(monkeypatch, handler)
+ with pytest.raises(ValueError, match='10 MiB download limit'):
+ await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
+
+
+@pytest.mark.asyncio
+async def test_github_download_counts_chunked_stream_bytes(monkeypatch):
+ connector = create_mock_connector()
+ monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
+
+ class ChunkedBody(httpx.AsyncByteStream):
+ async def __aiter__(self):
+ yield b'1234'
+ yield b'56789'
+
+ def handler(_request: httpx.Request) -> httpx.Response:
+ return httpx.Response(200, stream=ChunkedBody())
+
+ _patch_client(monkeypatch, handler)
+ with pytest.raises(ValueError, match='10 MiB download limit'):
+ await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
+
+
+@pytest.mark.asyncio
+async def test_github_asset_id_download_allows_github_object_redirect(monkeypatch):
+ connector = create_mock_connector()
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith('/releases/42'):
+ return httpx.Response(
+ 200,
+ json={
+ 'id': 42,
+ 'tag_name': 'v1.0.0',
+ 'assets': [{'id': 99, 'size': 7, 'state': 'uploaded'}],
+ },
+ )
+ if request.url.path.endswith('/releases/assets/99'):
+ return httpx.Response(
+ 302,
+ headers={
+ 'location': 'https://release-assets.githubusercontent.com/github-production-release-asset/demo'
+ },
+ )
+ if request.url.host == 'release-assets.githubusercontent.com':
+ return httpx.Response(200, content=b'package')
+ raise AssertionError(f'unexpected request: {request.url}')
+
+ _patch_client(monkeypatch, handler)
+ package = await connector._download_github_package(
+ {
+ 'owner': 'langbot-app',
+ 'repo': 'demo-plugin',
+ 'release_tag': 'v1.0.0',
+ 'release_id': 42,
+ 'asset_id': 99,
+ },
+ None,
+ )
+
+ assert package == b'package'
diff --git a/tests/unit_tests/plugin/test_handler.py b/tests/unit_tests/plugin/test_handler.py
index a2fdddd33..c0339d3c4 100644
--- a/tests/unit_tests/plugin/test_handler.py
+++ b/tests/unit_tests/plugin/test_handler.py
@@ -10,13 +10,61 @@ from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
+from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
def make_handler(app):
"""Create a RuntimeConnectionHandler with mocked external connection."""
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
- return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app)
+ workspace_context = ActionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ app.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid=workspace_context.instance_uuid,
+ workspace_uuid=workspace_context.workspace_uuid,
+ placement_generation=workspace_context.placement_generation,
+ )
+ )
+ )
+ runtime_handler = RuntimeConnectionHandler(
+ Mock(),
+ AsyncMock(return_value=True),
+ app,
+ )
+ installation_binding = InstallationBinding(
+ **workspace_context.model_dump(exclude_none=True),
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest='a' * 64,
+ )
+ runtime_handler.register_installation_binding(
+ installation_binding,
+ plugin_author='test-author',
+ plugin_name='test-plugin',
+ )
+ runtime_handler._current_action_context.set(installation_binding)
+ query_pool = getattr(app, 'query_pool', None)
+ if query_pool is not None and hasattr(query_pool, 'cached_queries'):
+
+ def scoped_query(query):
+ if query is not None:
+ query.instance_uuid = workspace_context.instance_uuid
+ query.workspace_uuid = workspace_context.workspace_uuid
+ query.placement_generation = workspace_context.placement_generation
+ return query
+
+ query_pool.get_query = AsyncMock(
+ side_effect=lambda workspace_uuid, query_uuid: scoped_query(query_pool.cached_queries.get(query_uuid))
+ )
+ query_pool.get_query_by_legacy_id = AsyncMock(
+ side_effect=lambda workspace_uuid, query_id: scoped_query(query_pool.cached_queries.get(query_id))
+ )
+ return runtime_handler
class TestHandlerQueryVariables:
diff --git a/tests/unit_tests/plugin/test_handler_actions.py b/tests/unit_tests/plugin/test_handler_actions.py
index 2dbc1f4f9..4089e23be 100644
--- a/tests/unit_tests/plugin/test_handler_actions.py
+++ b/tests/unit_tests/plugin/test_handler_actions.py
@@ -8,13 +8,80 @@ from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
+from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.storage.mgr import StorageMgr
-def make_handler(app):
+TEST_EXECUTION_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+
+
+def canonical_binary_key(owner_type: str, owner: str, key: str) -> str:
+ return StorageMgr.canonical_binary_storage_key(
+ TEST_EXECUTION_CONTEXT,
+ owner_type=owner_type,
+ owner=owner,
+ key=key,
+ )
+
+
+def make_handler(app, workspace_context: ActionContext | None = None):
"""Create a RuntimeConnectionHandler with mocked external connection."""
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
- return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app)
+ workspace_context = workspace_context or ActionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ app.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid=workspace_context.instance_uuid,
+ workspace_uuid=workspace_context.workspace_uuid,
+ placement_generation=workspace_context.placement_generation,
+ )
+ )
+ )
+ runtime_handler = RuntimeConnectionHandler(
+ Mock(),
+ AsyncMock(return_value=True),
+ app,
+ )
+ installation_binding = InstallationBinding(
+ **workspace_context.model_dump(exclude_none=True),
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest='a' * 64,
+ )
+ runtime_handler.register_installation_binding(
+ installation_binding,
+ plugin_author='test-author',
+ plugin_name='test-plugin',
+ )
+ runtime_handler._current_action_context.set(installation_binding)
+ query_pool = getattr(app, 'query_pool', None)
+ if query_pool is not None and hasattr(query_pool, 'cached_queries'):
+
+ def scoped_query(query):
+ if query is not None:
+ query.instance_uuid = workspace_context.instance_uuid
+ query.workspace_uuid = workspace_context.workspace_uuid
+ query.placement_generation = workspace_context.placement_generation
+ return query
+
+ query_pool.get_query = AsyncMock(
+ side_effect=lambda workspace_uuid, query_uuid: scoped_query(query_pool.cached_queries.get(query_uuid))
+ )
+ query_pool.get_query_by_legacy_id = AsyncMock(
+ side_effect=lambda workspace_uuid, query_id: scoped_query(query_pool.cached_queries.get(query_id))
+ )
+ return runtime_handler
def make_result(first_item=None):
@@ -34,6 +101,8 @@ class TestRagRerankAction:
def app(self):
mock_app = Mock()
mock_app.model_mgr = Mock()
+ mock_app.persistence_mgr = Mock()
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result(SimpleNamespace(uuid='rerank-1')))
mock_app.logger = Mock()
return mock_app
@@ -63,12 +132,16 @@ class TestRagRerankAction:
assert response.code == 0
assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}]
- app.model_mgr.get_rerank_model_by_uuid.assert_awaited_once_with('rerank-1')
+ app.model_mgr.get_rerank_model_by_uuid.assert_awaited_once_with(
+ TEST_EXECUTION_CONTEXT,
+ 'rerank-1',
+ )
provider.invoke_rerank.assert_awaited_once_with(
model=rerank_model,
query='hello',
documents=['a', 'b'],
extra_args={'return_documents': False},
+ execution_context=TEST_EXECUTION_CONTEXT,
)
@pytest.mark.asyncio
@@ -101,13 +174,10 @@ class TestInitializePluginSettings:
return mock_app
@pytest.mark.asyncio
- async def test_creates_new_setting_when_not_exists(self, app):
- """New plugin settings use default enabled, priority and config values."""
+ async def test_rejects_desired_installation_when_setting_not_exists(self, app):
+ """A desired-state worker cannot create an unowned Core setting row."""
runtime_handler = make_handler(app)
- app.persistence_mgr.execute_async.side_effect = [
- make_result(),
- Mock(),
- ]
+ app.persistence_mgr.execute_async.return_value = make_result()
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
@@ -118,33 +188,23 @@ class TestInitializePluginSettings:
}
)
- assert response.code == 0
- assert app.persistence_mgr.execute_async.await_count == 2
- insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
- assert insert_params == {
- 'plugin_author': 'test-author',
- 'plugin_name': 'test-plugin',
- 'install_source': 'local',
- 'install_info': {'path': '/test'},
- 'enabled': True,
- 'priority': 0,
- 'config': {},
- }
+ assert response.code != 0
+ assert 'Plugin installation setting was not found' in response.message
+ app.persistence_mgr.execute_async.assert_awaited_once()
@pytest.mark.asyncio
- async def test_inherits_values_from_existing_setting(self, app):
- """Existing settings are replaced while preserving user-controlled values."""
+ async def test_existing_desired_setting_remains_core_owned(self, app):
+ """Runtime initialization validates identity without rewriting Core state."""
runtime_handler = make_handler(app)
existing_setting = SimpleNamespace(
enabled=False,
priority=5,
config={'key': 'value'},
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest='a' * 64,
)
- app.persistence_mgr.execute_async.side_effect = [
- make_result(existing_setting),
- Mock(),
- Mock(),
- ]
+ app.persistence_mgr.execute_async.return_value = make_result(existing_setting)
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
@@ -156,13 +216,7 @@ class TestInitializePluginSettings:
)
assert response.code == 0
- assert app.persistence_mgr.execute_async.await_count == 3
- insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
- assert insert_params['enabled'] is False
- assert insert_params['priority'] == 5
- assert insert_params['config'] == {'key': 'value'}
- assert insert_params['install_source'] == 'github'
- assert insert_params['install_info'] == {'repo': 'author/name'}
+ app.persistence_mgr.execute_async.assert_awaited_once()
class TestSetBinaryStorage:
@@ -203,7 +257,7 @@ class TestSetBinaryStorage:
)
assert response.code != 0
- assert '2048 > 1024 bytes' in response.message
+ assert '1024-byte limit' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
@@ -218,7 +272,12 @@ class TestSetBinaryStorage:
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
- assert insert_params['unique_key'] == 'plugin:test-owner:test-key'
+ assert insert_params['workspace_uuid'] == 'workspace-a'
+ assert insert_params['unique_key'] == canonical_binary_key(
+ 'plugin',
+ 'test-author/test-plugin',
+ 'test-key',
+ )
assert insert_params['value'] == b'x' * 512
@pytest.mark.asyncio
@@ -231,7 +290,15 @@ class TestSetBinaryStorage:
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2
+ select_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[0].args[0])
update_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
+ expected_key = canonical_binary_key(
+ 'plugin',
+ 'test-author/test-plugin',
+ 'test-key',
+ )
+ assert expected_key in select_params.values()
+ assert expected_key in update_params.values()
assert update_params['value'] == b'new'
@pytest.mark.asyncio
@@ -245,21 +312,25 @@ class TestSetBinaryStorage:
)
assert response.code != 0
- assert '10485761 > 10485760 bytes' in response.message
+ assert '10485760' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
- async def test_negative_limit_disables_size_check(self, app):
- """Negative max_value_bytes allows values larger than the normal default."""
+ async def test_negative_limit_falls_back_to_bounded_default(self, app, monkeypatch):
+ """Negative max_value_bytes cannot disable the process memory boundary."""
+ import langbot.pkg.plugin.handler as handler_module
+
runtime_handler = make_handler(app)
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1
+ monkeypatch.setattr(handler_module, '_DEFAULT_BINARY_STORAGE_VALUE_BYTES', 1024)
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'x' * 2048)
)
- assert response.code == 0
- assert app.persistence_mgr.execute_async.await_count == 2
+ assert response.code != 0
+ assert '1024-byte limit' in response.message
+ app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
async def test_zero_limit_rejects_non_empty_values(self, app):
@@ -285,26 +356,18 @@ class TestGetPluginSettings:
return mock_app
@pytest.mark.asyncio
- async def test_returns_defaults_when_setting_not_found(self, app):
- """Default plugin settings are returned when no persisted row exists."""
+ async def test_rejects_desired_installation_when_setting_not_found(self, app):
+ """A desired-state worker cannot synthesize settings for a missing row."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result()
- response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
- {
- 'plugin_author': 'test-author',
- 'plugin_name': 'test-plugin',
- }
- )
-
- assert response.code == 0
- assert response.data == {
- 'enabled': True,
- 'priority': 0,
- 'plugin_config': {},
- 'install_source': 'local',
- 'install_info': {},
- }
+ with pytest.raises(ValueError, match='Plugin installation setting was not found'):
+ await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
+ {
+ 'plugin_author': 'test-author',
+ 'plugin_name': 'test-plugin',
+ }
+ )
@pytest.mark.asyncio
async def test_returns_actual_values_when_setting_exists(self, app):
@@ -316,6 +379,9 @@ class TestGetPluginSettings:
config={'custom': 'config'},
install_source='github',
install_info={'repo': 'test/repo'},
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest='a' * 64,
)
app.persistence_mgr.execute_async.return_value = make_result(setting)
@@ -333,9 +399,93 @@ class TestGetPluginSettings:
'plugin_config': {'custom': 'config'},
'install_source': 'github',
'install_info': {'repo': 'test/repo'},
+ 'installation_uuid': '00000000-0000-4000-8000-000000000001',
+ 'runtime_revision': 1,
+ 'artifact_digest': 'a' * 64,
}
+class TestGetConfigFile:
+ """Plugin config files remain bound to the trusted runtime placement."""
+
+ WORKSPACE_A = '11111111-1111-4111-8111-111111111111'
+ WORKSPACE_B = '22222222-2222-4222-8222-222222222222'
+
+ @pytest.fixture
+ def app(self):
+ mock_app = Mock()
+ mock_app.persistence_mgr = Mock()
+ mock_app.persistence_mgr.execute_async = AsyncMock()
+ mock_app.storage_mgr = StorageMgr(mock_app)
+ mock_app.storage_mgr.storage_provider = SimpleNamespace(
+ load_bounded=AsyncMock(return_value=b'plugin config bytes')
+ )
+ mock_app.logger = Mock()
+ return mock_app
+
+ @staticmethod
+ def object_key(
+ *,
+ workspace_uuid: str = WORKSPACE_A,
+ placement_generation: int = 1,
+ owner_type: str = 'plugin_config',
+ ) -> str:
+ return StorageMgr.scoped_object_key(
+ ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid=workspace_uuid,
+ placement_generation=placement_generation,
+ ),
+ owner_type=owner_type,
+ owner=workspace_uuid,
+ key='config.json',
+ )
+
+ async def invoke(self, app, file_key: str):
+ runtime_handler = make_handler(
+ app,
+ ActionContext(
+ instance_uuid='instance-a',
+ workspace_uuid=self.WORKSPACE_A,
+ placement_generation=1,
+ ),
+ )
+ app.persistence_mgr.execute_async.return_value = make_result(
+ SimpleNamespace(config={'uploaded_file': file_key})
+ )
+ return await runtime_handler.actions[PluginToRuntimeAction.GET_CONFIG_FILE.value]({'file_key': file_key})
+
+ @pytest.mark.asyncio
+ async def test_loads_config_file_from_same_workspace_generation(self, app):
+ file_key = self.object_key()
+
+ response = await self.invoke(app, file_key)
+
+ assert response.code == 0
+ assert base64.b64decode(response.data['file_base64']) == b'plugin config bytes'
+ app.storage_mgr.storage_provider.load_bounded.assert_awaited_once_with(
+ file_key,
+ max_bytes=10 * 1024 * 1024,
+ )
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ 'file_key',
+ [
+ object_key.__func__(workspace_uuid=WORKSPACE_B),
+ object_key.__func__(placement_generation=2),
+ object_key.__func__(owner_type='upload_document'),
+ ],
+ ids=['other-workspace', 'stale-generation', 'wrong-owner-type'],
+ )
+ async def test_rejects_config_file_outside_trusted_scope(self, app, file_key):
+ response = await self.invoke(app, file_key)
+
+ assert response.code != 0
+ assert 'Failed to load config file' in response.message
+ app.storage_mgr.storage_provider.load_bounded.assert_not_awaited()
+
+
class TestGetBinaryStorage:
"""Tests for get_binary_storage action handler."""
@@ -364,6 +514,16 @@ class TestGetBinaryStorage:
assert response.data == {
'value_base64': base64.b64encode(b'test binary content').decode('utf-8'),
}
+ statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
+ assert 'workspace-a' in statement_params.values()
+ assert (
+ canonical_binary_key(
+ 'plugin',
+ 'test-author/test-plugin',
+ 'test-key',
+ )
+ in statement_params.values()
+ )
@pytest.mark.asyncio
async def test_returns_error_when_not_found(self, app):
@@ -383,6 +543,63 @@ class TestGetBinaryStorage:
assert 'Storage with key test-key not found' in response.message
+class TestDeleteAndListBinaryStorage:
+ """Delete/list remain fenced to the trusted canonical owner scope."""
+
+ @pytest.fixture
+ def app(self):
+ mock_app = Mock()
+ mock_app.persistence_mgr = Mock()
+ mock_app.persistence_mgr.execute_async = AsyncMock()
+ return mock_app
+
+ @pytest.mark.asyncio
+ async def test_delete_uses_workspace_and_canonical_unique_key(self, app):
+ runtime_handler = make_handler(app)
+
+ response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
+ {
+ 'key': 'test-key',
+ 'owner_type': 'plugin',
+ 'owner': 'forged-owner',
+ }
+ )
+
+ assert response.code == 0
+ statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
+ assert 'workspace-a' in statement_params.values()
+ assert (
+ canonical_binary_key(
+ 'plugin',
+ 'test-author/test-plugin',
+ 'test-key',
+ )
+ in statement_params.values()
+ )
+ assert 'forged-owner' not in statement_params.values()
+
+ @pytest.mark.asyncio
+ async def test_list_keys_uses_trusted_plugin_owner(self, app):
+ result = Mock()
+ result.scalars.return_value.all.return_value = ['first', 'second']
+ app.persistence_mgr.execute_async.return_value = result
+ runtime_handler = make_handler(app)
+
+ response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS.value](
+ {
+ 'owner_type': 'plugin',
+ 'owner': 'forged-owner',
+ }
+ )
+
+ assert response.code == 0
+ assert response.data == {'keys': ['first', 'second']}
+ statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
+ assert 'workspace-a' in statement_params.values()
+ assert 'test-author/test-plugin' in statement_params.values()
+ assert 'forged-owner' not in statement_params.values()
+
+
class TestHandlerQueryLookup:
"""Tests for query lookup in cached_queries."""
diff --git a/tests/unit_tests/plugin/test_handler_tenancy.py b/tests/unit_tests/plugin/test_handler_tenancy.py
new file mode 100644
index 000000000..43856b9cf
--- /dev/null
+++ b/tests/unit_tests/plugin/test_handler_tenancy.py
@@ -0,0 +1,446 @@
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
+from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
+from langbot_plugin.entities.io.resp import ActionResponse
+from langbot_plugin.runtime.io.connection import Connection
+
+from langbot.pkg.plugin.handler import RuntimeConnectionHandler
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
+
+
+class EmptyResult:
+ def first(self):
+ return None
+
+ def all(self):
+ return []
+
+
+class RecordingConnection(Connection):
+ def __init__(self):
+ self.sent: list[str] = []
+
+ async def send(self, message: str) -> None:
+ self.sent.append(message)
+
+ async def receive(self) -> str:
+ raise NotImplementedError
+
+ async def close(self) -> None:
+ return None
+
+
+def workspace_context(workspace_uuid: str = 'workspace-a') -> ActionContext:
+ return ActionContext(
+ instance_uuid='instance-a',
+ workspace_uuid=workspace_uuid,
+ placement_generation=7,
+ )
+
+
+def make_handler(workspace_uuid: str = 'workspace-a'):
+ context = workspace_context(workspace_uuid)
+ app = SimpleNamespace(
+ deployment=SimpleNamespace(mode='cloud'),
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=EmptyResult())),
+ logger=Mock(),
+ workspace_service=SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid=context.instance_uuid,
+ workspace_uuid=context.workspace_uuid,
+ placement_generation=context.placement_generation,
+ )
+ )
+ ),
+ )
+ runtime_handler = RuntimeConnectionHandler(
+ Mock(),
+ AsyncMock(return_value=True),
+ app,
+ )
+ installation_context = InstallationBinding(
+ **context.model_dump(exclude_none=True),
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest='a' * 64,
+ )
+ runtime_handler.register_installation_binding(
+ installation_context,
+ plugin_author='author-a',
+ plugin_name='plugin-a',
+ )
+ return runtime_handler, app, installation_context
+
+
+async def invoke_with_context(
+ runtime_handler: RuntimeConnectionHandler,
+ action_context: ActionContext,
+ action: PluginToRuntimeAction,
+ data: dict,
+):
+ token = runtime_handler._current_action_context.set(action_context)
+ try:
+ return await runtime_handler.actions[action.value](data)
+ finally:
+ runtime_handler._current_action_context.reset(token)
+
+
+@pytest.mark.asyncio
+async def test_plugin_action_requires_installation_capability():
+ runtime_handler, _app, _installation_context = make_handler()
+
+ with pytest.raises(ValueError, match='trusted Workspace context'):
+ await runtime_handler.actions[PluginToRuntimeAction.GET_LANGBOT_VERSION.value]({})
+
+
+@pytest.mark.asyncio
+async def test_runtime_action_enters_trusted_workspace_scope():
+ runtime_handler, app, installation_context = make_handler()
+ scope_events: list[tuple[str, str]] = []
+
+ @contextlib.asynccontextmanager
+ async def tenant_scope(workspace_uuid: str):
+ scope_events.append(('enter', workspace_uuid))
+ try:
+ yield SimpleNamespace()
+ finally:
+ scope_events.append(('exit', workspace_uuid))
+
+ class RecordingPersistenceManager:
+ def __init__(self, execute_async):
+ self.execute_async = execute_async
+
+ def tenant_scope(self, workspace_uuid: str):
+ return tenant_scope(workspace_uuid)
+
+ app.persistence_mgr = RecordingPersistenceManager(app.persistence_mgr.execute_async)
+
+ response = await invoke_with_context(
+ runtime_handler,
+ installation_context,
+ PluginToRuntimeAction.GET_LANGBOT_VERSION,
+ {},
+ )
+
+ assert response.code == 0
+ assert scope_events == [('enter', 'workspace-a'), ('exit', 'workspace-a')]
+
+
+@pytest.mark.asyncio
+async def test_blocked_llm_provider_does_not_hold_tenant_database_session():
+ entered = asyncio.Event()
+ release = asyncio.Event()
+ observations: list[bool] = []
+ engine = create_async_engine('sqlite+aiosqlite:///:memory:')
+ manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
+ manager.db = SimpleNamespace(get_engine=lambda: engine)
+ runtime_handler, app, installation_context = make_handler()
+ app.persistence_mgr = manager
+
+ async def invoke_llm(**_kwargs):
+ observations.append(manager.current_session() is None)
+ entered.set()
+ await release.wait()
+ observations.append(manager.current_session() is None)
+ return SimpleNamespace(model_dump=lambda: {'role': 'assistant', 'content': 'ok'})
+
+ app.model_mgr = SimpleNamespace(
+ get_model_by_uuid=AsyncMock(
+ return_value=SimpleNamespace(
+ model_entity=SimpleNamespace(workspace_uuid='workspace-a'),
+ provider=SimpleNamespace(invoke_llm=invoke_llm),
+ )
+ )
+ )
+ runtime_handler._require_plugin_action_context = AsyncMock(return_value=(installation_context, SimpleNamespace()))
+ runtime_handler._require_active_action_context = AsyncMock()
+ runtime_handler._resource_exists = AsyncMock(return_value=True)
+
+ action = asyncio.create_task(
+ invoke_with_context(
+ runtime_handler,
+ installation_context,
+ PluginToRuntimeAction.INVOKE_LLM,
+ {
+ 'llm_model_uuid': 'model-a',
+ 'messages': [],
+ },
+ )
+ )
+ try:
+ await entered.wait()
+ assert observations == [True]
+ release.set()
+ response = await action
+ assert response.code == 0
+ assert observations == [True, True]
+ finally:
+ release.set()
+ if not action.done():
+ await action
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_plugin_action_rejects_forged_installation_capability():
+ runtime_handler, _app, installation_context = make_handler()
+ forged = installation_context.model_copy(update={'installation_uuid': 'forged-installation'})
+
+ with pytest.raises(
+ ValueError,
+ match='installation is not registered in this Workspace',
+ ):
+ await invoke_with_context(
+ runtime_handler,
+ forged,
+ PluginToRuntimeAction.GET_LANGBOT_VERSION,
+ {},
+ )
+
+
+@pytest.mark.asyncio
+async def test_plugin_action_rejects_stale_workspace_generation():
+ runtime_handler, app, installation_context = make_handler()
+ app.workspace_service.get_execution_binding.side_effect = ValueError('generation is fenced')
+
+ with pytest.raises(ValueError, match='generation is fenced'):
+ await invoke_with_context(
+ runtime_handler,
+ installation_context,
+ PluginToRuntimeAction.GET_LANGBOT_VERSION,
+ {},
+ )
+
+ app.workspace_service.get_execution_binding.assert_awaited_once_with(
+ 'workspace-a',
+ expected_generation=7,
+ )
+
+
+@pytest.mark.asyncio
+async def test_query_uuid_and_forged_payload_workspace_cannot_cross_tenants():
+ runtime_handler, app, installation_context = make_handler()
+ query_a = SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=7,
+ bot_uuid='bot-a',
+ )
+ query_b = SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-b',
+ placement_generation=7,
+ bot_uuid='bot-b',
+ )
+
+ async def get_query(workspace_uuid, query_uuid):
+ return {
+ ('workspace-a', 'query-a'): query_a,
+ ('workspace-b', 'query-b'): query_b,
+ }.get((workspace_uuid, query_uuid))
+
+ app.query_pool = SimpleNamespace(
+ get_query=AsyncMock(side_effect=get_query),
+ get_query_by_legacy_id=AsyncMock(),
+ )
+
+ response = await invoke_with_context(
+ runtime_handler,
+ installation_context,
+ PluginToRuntimeAction.GET_BOT_UUID,
+ {
+ 'query_id': 2,
+ 'query_uuid': 'query-b',
+ 'workspace_uuid': 'workspace-b',
+ },
+ )
+
+ assert response.code != 0
+ app.query_pool.get_query.assert_awaited_once_with('workspace-a', 'query-b')
+
+
+@pytest.mark.asyncio
+async def test_legacy_query_id_fallback_is_workspace_scoped():
+ runtime_handler, app, installation_context = make_handler()
+ query = SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=7,
+ bot_uuid='bot-a',
+ )
+ app.query_pool = SimpleNamespace(
+ get_query=AsyncMock(),
+ get_query_by_legacy_id=AsyncMock(return_value=query),
+ )
+
+ response = await invoke_with_context(
+ runtime_handler,
+ installation_context,
+ PluginToRuntimeAction.GET_BOT_UUID,
+ {'query_id': 19},
+ )
+
+ assert response.code == 0
+ assert response.data == {'bot_uuid': 'bot-a'}
+ app.query_pool.get_query_by_legacy_id.assert_awaited_once_with(
+ 'workspace-a',
+ 19,
+ )
+
+
+def test_runtime_connection_is_instance_scoped_and_unbound():
+ runtime_handler, _app, _installation_context = make_handler()
+ assert runtime_handler.bound_action_context is None
+
+
+def test_inbound_tenant_action_requires_complete_installation_envelope():
+ runtime_handler, _app, installation_context = make_handler()
+
+ assert (
+ runtime_handler.validate_inbound_action_context(
+ PluginToRuntimeAction.GET_BOTS.value,
+ installation_context,
+ )
+ == installation_context
+ )
+ with pytest.raises(ValueError, match='complete InstallationBinding'):
+ runtime_handler.validate_inbound_action_context(
+ PluginToRuntimeAction.GET_BOTS.value,
+ workspace_context('workspace-b').for_installation('installation-b'),
+ )
+
+
+@pytest.mark.asyncio
+async def test_legacy_oss_worker_capability_remains_usable_after_identity_migration():
+ runtime_handler, app, installation_context = make_handler()
+ app.deployment = SimpleNamespace(mode='oss')
+ setting = SimpleNamespace(
+ plugin_author='author-a',
+ plugin_name='plugin-a',
+ installation_uuid=installation_context.installation_uuid,
+ runtime_revision=installation_context.runtime_revision,
+ artifact_digest=installation_context.artifact_digest,
+ )
+ result = Mock()
+ result.first.return_value = setting
+ app.persistence_mgr.execute_async.return_value = result
+ legacy_context = workspace_context().for_installation(installation_context.installation_uuid)
+
+ assert (
+ runtime_handler.validate_inbound_action_context(
+ PluginToRuntimeAction.GET_LANGBOT_VERSION.value,
+ legacy_context,
+ )
+ == legacy_context
+ )
+ response = await invoke_with_context(
+ runtime_handler,
+ legacy_context,
+ PluginToRuntimeAction.GET_LANGBOT_VERSION,
+ {},
+ )
+
+ assert response.code == 0
+
+
+def test_installation_uuid_cannot_move_between_workspaces():
+ runtime_handler, _app, binding = make_handler()
+ moved = binding.model_copy(update={'workspace_uuid': 'workspace-b', 'runtime_revision': 2})
+
+ with pytest.raises(ValueError, match='cannot move between Workspaces'):
+ runtime_handler.register_installation_binding(
+ moved,
+ plugin_author='author-a',
+ plugin_name='plugin-a',
+ )
+
+
+@pytest.mark.asyncio
+async def test_newer_revision_fences_old_binding():
+ runtime_handler, _app, binding = make_handler()
+ newer = binding.model_copy(update={'runtime_revision': 2, 'artifact_digest': 'b' * 64})
+ runtime_handler.register_installation_binding(
+ newer,
+ plugin_author='author-a',
+ plugin_name='plugin-a',
+ )
+ with pytest.raises(ValueError, match='revision or artifact is stale'):
+ await runtime_handler._resolve_installation_identity(binding)
+
+
+@pytest.mark.asyncio
+async def test_plugin_vector_action_forwards_trusted_context_and_logical_collection():
+ runtime_handler, app, installation_context = make_handler()
+ app.rag_runtime_service = SimpleNamespace(vector_upsert=AsyncMock())
+
+ response = await invoke_with_context(
+ runtime_handler,
+ installation_context,
+ PluginToRuntimeAction.VECTOR_UPSERT,
+ {
+ 'workspace_uuid': 'workspace-forged',
+ 'collection_id': 'plugin-supplied-name',
+ 'vectors': [[0.1]],
+ 'ids': ['point-a'],
+ },
+ )
+
+ assert response.code == 0
+ execution_context = app.rag_runtime_service.vector_upsert.await_args.args[0]
+ assert execution_context == ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=7,
+ )
+ assert app.rag_runtime_service.vector_upsert.await_args.args[1] == 'plugin-supplied-name'
+
+
+@pytest.mark.asyncio
+async def test_host_to_runtime_action_carries_trusted_connector_context():
+ app = SimpleNamespace(logger=Mock())
+ connection = RecordingConnection()
+ runtime_handler = RuntimeConnectionHandler(
+ connection,
+ AsyncMock(return_value=True),
+ app,
+ )
+
+ task = asyncio.create_task(
+ runtime_handler.set_runtime_config(
+ runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='runtime-a'),
+ worker_policy=PluginWorkerPolicy(
+ max_cpus=1.0,
+ max_memory_mb=256,
+ max_pids=32,
+ max_open_files=64,
+ max_file_size_mb=128,
+ ),
+ runtime_profile='oss_dev',
+ cloud_service_url=None,
+ )
+ )
+ for _ in range(10):
+ if connection.sent:
+ break
+ await asyncio.sleep(0)
+ request = json.loads(connection.sent[0])
+ runtime_handler.resp_waiters[request['seq_id']].set_result(ActionResponse.success({}))
+ await task
+
+ assert request['data']['runtime_identity'] == {
+ 'instance_uuid': 'instance-a',
+ 'runtime_id': 'runtime-a',
+ }
+ assert request.get('context') is None
diff --git a/tests/unit_tests/plugin/test_plugin_component_filtering.py b/tests/unit_tests/plugin/test_plugin_component_filtering.py
index da8991dcd..9138c04a7 100644
--- a/tests/unit_tests/plugin/test_plugin_component_filtering.py
+++ b/tests/unit_tests/plugin/test_plugin_component_filtering.py
@@ -1,7 +1,34 @@
"""Test plugin list filtering by component kinds."""
+from contextlib import nullcontext
from unittest.mock import AsyncMock, MagicMock
+
import pytest
+from langbot_plugin.entities.io.context import InstallationBinding
+
+from langbot.pkg.api.http.context import ExecutionContext
+
+
+TEST_EXECUTION_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+TEST_INSTALLATION_BINDING = InstallationBinding(
+ instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
+ workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
+ placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest='a' * 64,
+)
+
+
+def configure_connector(connector) -> None:
+ connector._execution_context.set(TEST_EXECUTION_CONTEXT)
+ connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
+ connector._load_workspace_settings = AsyncMock(return_value=[])
+ connector.handler.installation_scope = MagicMock(side_effect=lambda _binding: nullcontext())
@pytest.mark.asyncio
@@ -17,6 +44,7 @@ async def test_plugin_list_filter_by_component_kinds():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
+ configure_connector(connector)
# Mock plugin data with different component kinds
mock_plugins = [
@@ -90,7 +118,7 @@ async def test_plugin_list_filter_by_component_kinds():
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
- mock_result.__iter__ = lambda self: iter([])
+ mock_result.scalars.return_value.all.return_value = []
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
@@ -123,6 +151,7 @@ async def test_plugin_list_filter_no_filter():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
+ configure_connector(connector)
# Mock plugin data with different component kinds
mock_plugins = [
@@ -157,7 +186,7 @@ async def test_plugin_list_filter_no_filter():
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
- mock_result.__iter__ = lambda self: iter([])
+ mock_result.scalars.return_value.all.return_value = []
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
@@ -184,6 +213,7 @@ async def test_plugin_list_filter_empty_result():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
+ configure_connector(connector)
# Mock plugin data - only KnowledgeEngine plugins
mock_plugins = [
@@ -206,7 +236,7 @@ async def test_plugin_list_filter_empty_result():
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
- mock_result.__iter__ = lambda self: iter([])
+ mock_result.scalars.return_value.all.return_value = []
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
@@ -230,6 +260,7 @@ async def test_plugin_list_filter_plugin_without_components():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
+ configure_connector(connector)
# Mock plugin data - one with components, one without
mock_plugins = [
@@ -264,7 +295,7 @@ async def test_plugin_list_filter_plugin_without_components():
# Mock database query
async def mock_execute_async(query):
mock_result = MagicMock()
- mock_result.__iter__ = lambda self: iter([])
+ mock_result.scalars.return_value.all.return_value = []
return mock_result
mock_app.persistence_mgr.execute_async = mock_execute_async
diff --git a/tests/unit_tests/plugin/test_plugin_list_sorting.py b/tests/unit_tests/plugin/test_plugin_list_sorting.py
index 2d26aec3e..9a771c722 100644
--- a/tests/unit_tests/plugin/test_plugin_list_sorting.py
+++ b/tests/unit_tests/plugin/test_plugin_list_sorting.py
@@ -1,8 +1,34 @@
"""Test plugin list sorting functionality."""
+from contextlib import nullcontext
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
+
import pytest
+from langbot_plugin.entities.io.context import InstallationBinding
+
+from langbot.pkg.api.http.context import ExecutionContext
+
+
+TEST_EXECUTION_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+TEST_INSTALLATION_BINDING = InstallationBinding(
+ instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
+ workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
+ placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
+ installation_uuid='00000000-0000-4000-8000-000000000001',
+ runtime_revision=1,
+ artifact_digest='a' * 64,
+)
+
+
+def configure_connector(connector) -> None:
+ connector._execution_context.set(TEST_EXECUTION_CONTEXT)
+ connector._operation_bindings = AsyncMock(return_value=[TEST_INSTALLATION_BINDING])
+ connector.handler.installation_scope = MagicMock(side_effect=lambda _binding: nullcontext())
@pytest.mark.asyncio
@@ -18,6 +44,7 @@ async def test_plugin_list_sorting_debug_first():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
+ configure_connector(connector)
# Mock plugin data with different debug states and timestamps
now = datetime.now()
@@ -60,9 +87,7 @@ async def test_plugin_list_sorting_debug_first():
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
# Mock database query to return all timestamps in a single batch
- async def mock_execute_async(query):
- mock_result = MagicMock()
-
+ async def mock_load_workspace_settings(_execution_context):
# Create mock rows for all plugins with timestamps
mock_rows = []
@@ -85,12 +110,9 @@ async def test_plugin_list_sorting_debug_first():
mock_row3.created_at = now
mock_rows.append(mock_row3)
- # Make the result iterable
- mock_result.__iter__ = lambda self: iter(mock_rows)
+ return mock_rows
- return mock_result
-
- mock_app.persistence_mgr.execute_async = mock_execute_async
+ connector._load_workspace_settings = AsyncMock(side_effect=mock_load_workspace_settings)
# Call list_plugins
result = await connector.list_plugins()
@@ -120,6 +142,7 @@ async def test_plugin_list_sorting_by_installation_time():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
+ configure_connector(connector)
# Mock plugin data - all non-debug with different installation times
now = datetime.now()
@@ -162,9 +185,7 @@ async def test_plugin_list_sorting_by_installation_time():
connector.handler.list_plugins = AsyncMock(return_value=mock_plugins)
# Mock database query to return all timestamps in a single batch
- async def mock_execute_async(query):
- mock_result = MagicMock()
-
+ async def mock_load_workspace_settings(_execution_context):
# Create mock rows for all plugins with timestamps
mock_rows = []
@@ -187,12 +208,9 @@ async def test_plugin_list_sorting_by_installation_time():
mock_row3.created_at = now
mock_rows.append(mock_row3)
- # Make the result iterable
- mock_result.__iter__ = lambda self: iter(mock_rows)
+ return mock_rows
- return mock_result
-
- mock_app.persistence_mgr.execute_async = mock_execute_async
+ connector._load_workspace_settings = AsyncMock(side_effect=mock_load_workspace_settings)
# Call list_plugins
result = await connector.list_plugins()
@@ -217,6 +235,7 @@ async def test_plugin_list_empty():
# Create connector
connector = PluginRuntimeConnector(mock_app, AsyncMock())
connector.handler = MagicMock()
+ configure_connector(connector)
# Mock empty plugin list
connector.handler.list_plugins = AsyncMock(return_value=[])
diff --git a/tests/unit_tests/provider/conftest.py b/tests/unit_tests/provider/conftest.py
index 13b44fd14..be56eb09f 100644
--- a/tests/unit_tests/provider/conftest.py
+++ b/tests/unit_tests/provider/conftest.py
@@ -16,6 +16,18 @@ from langbot.pkg.provider.modelmgr import token
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.discover import engine as discover_engine
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
+
+
+TEST_INSTANCE_UUID = 'test-instance'
+TEST_WORKSPACE_UUID = 'test-workspace'
+TEST_GENERATION = 1
+TEST_EXECUTION_CONTEXT = ExecutionContext(
+ instance_uuid=TEST_INSTANCE_UUID,
+ workspace_uuid=TEST_WORKSPACE_UUID,
+ placement_generation=TEST_GENERATION,
+)
class FakeProviderAPIRequester(requester.ProviderAPIRequester):
@@ -157,6 +169,26 @@ def mock_app_for_modelmgr():
app.llm_model_service = AsyncMock()
app.embedding_models_service = AsyncMock()
app.monitoring_service = AsyncMock()
+ app.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=WorkspaceExecutionBinding(
+ instance_uuid=TEST_INSTANCE_UUID,
+ workspace_uuid=TEST_WORKSPACE_UUID,
+ placement_generation=TEST_GENERATION,
+ write_fenced=False,
+ state='active',
+ )
+ ),
+ get_local_execution_binding=AsyncMock(
+ return_value=WorkspaceExecutionBinding(
+ instance_uuid=TEST_INSTANCE_UUID,
+ workspace_uuid=TEST_WORKSPACE_UUID,
+ placement_generation=TEST_GENERATION,
+ write_fenced=False,
+ state='active',
+ )
+ ),
+ )
return app
@@ -184,6 +216,7 @@ def fake_persistence_data():
providers = [
persistence_model.ModelProvider(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid=provider_uuid,
name='Test Provider',
requester='fake-requester',
@@ -191,6 +224,7 @@ def fake_persistence_data():
api_keys=['test-api-key-1', 'test-api-key-2'],
),
persistence_model.ModelProvider(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid=provider_uuid2,
name='Test Provider 2',
requester='another-fake-requester',
@@ -201,6 +235,7 @@ def fake_persistence_data():
llm_models = [
persistence_model.LLMModel(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='test-llm-uuid-1',
name='TestLLM-1',
provider_uuid=provider_uuid,
@@ -208,6 +243,7 @@ def fake_persistence_data():
extra_args={'temperature': 0.7},
),
persistence_model.LLMModel(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='test-llm-uuid-2',
name='TestLLM-2',
provider_uuid=provider_uuid,
@@ -218,6 +254,7 @@ def fake_persistence_data():
embedding_models = [
persistence_model.EmbeddingModel(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='test-embedding-uuid-1',
name='TestEmbedding-1',
provider_uuid=provider_uuid,
@@ -227,6 +264,7 @@ def fake_persistence_data():
rerank_models = [
persistence_model.RerankModel(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='test-rerank-uuid-1',
name='TestRerank-1',
provider_uuid=provider_uuid2,
@@ -252,6 +290,7 @@ def runtime_provider(fake_persistence_data, mock_app_for_modelmgr):
requester_inst = FakeProviderAPIRequester(mock_app_for_modelmgr, {'base_url': provider_entity.base_url})
return requester.RuntimeProvider(
+ execution_context=TEST_EXECUTION_CONTEXT,
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
@@ -263,6 +302,7 @@ def runtime_llm_model(fake_persistence_data, runtime_provider):
"""Provides a RuntimeLLMModel instance for testing."""
model_entity = fake_persistence_data['llm_models'][0]
return requester.RuntimeLLMModel(
+ execution_context=TEST_EXECUTION_CONTEXT,
model_entity=model_entity,
provider=runtime_provider,
)
@@ -273,6 +313,7 @@ def runtime_embedding_model(fake_persistence_data, runtime_provider):
"""Provides a RuntimeEmbeddingModel instance for testing."""
model_entity = fake_persistence_data['embedding_models'][0]
return requester.RuntimeEmbeddingModel(
+ execution_context=TEST_EXECUTION_CONTEXT,
model_entity=model_entity,
provider=runtime_provider,
)
@@ -286,6 +327,7 @@ def runtime_rerank_model(fake_persistence_data, mock_app_for_modelmgr):
requester_inst = AnotherFakeRequester(mock_app_for_modelmgr, {'base_url': provider_entity.base_url})
provider = requester.RuntimeProvider(
+ execution_context=TEST_EXECUTION_CONTEXT,
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
@@ -293,6 +335,7 @@ def runtime_rerank_model(fake_persistence_data, mock_app_for_modelmgr):
model_entity = fake_persistence_data['rerank_models'][0]
return requester.RuntimeRerankModel(
+ execution_context=TEST_EXECUTION_CONTEXT,
model_entity=model_entity,
provider=provider,
)
diff --git a/tests/unit_tests/provider/runners/test_difysvapi_runner.py b/tests/unit_tests/provider/runners/test_difysvapi_runner.py
index f75938209..028d278be 100644
--- a/tests/unit_tests/provider/runners/test_difysvapi_runner.py
+++ b/tests/unit_tests/provider/runners/test_difysvapi_runner.py
@@ -6,6 +6,7 @@ Tests the helper methods that don't require real Dify API calls.
from __future__ import annotations
import pytest
+import time
from unittest.mock import AsyncMock, MagicMock
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -18,13 +19,12 @@ class TestDifyWorkflowSubmitClient:
class FakeResponse:
status_code = 503
+ headers = {}
- async def aread(self):
- return b''
-
- async def aiter_lines(self):
- raise AssertionError('error responses must not enter the SSE loop')
- yield
+ async def aiter_bytes(self, chunk_size=None):
+ del chunk_size
+ if False:
+ yield b''
class FakeStreamContext:
async def __aenter__(self):
@@ -66,6 +66,33 @@ class TestDifyWorkflowSubmitClient:
)
)
+ @pytest.mark.asyncio
+ async def test_sse_parser_rejects_an_unbounded_line(self):
+ from langbot.libs.dify_service_api.v1 import client, errors
+
+ class FakeResponse:
+ async def aiter_bytes(self, chunk_size=None):
+ del chunk_size
+ for _ in range(129):
+ yield b'x' * 8192
+
+ with pytest.raises(errors.DifyAPIError, match='SSE event exceeds'):
+ await anext(client._iter_sse_json(FakeResponse()))
+
+ @pytest.mark.asyncio
+ async def test_upload_rejects_oversized_local_file(self, tmp_path):
+ from langbot.libs.dify_service_api.v1 import client
+
+ file_path = tmp_path / 'large.bin'
+ file_path.write_bytes(b'x' * (client._MAX_DIFY_UPLOAD_BYTES + 1))
+ dify_client = client.AsyncDifyServiceClient(
+ 'test-key',
+ 'https://dify.example/v1',
+ )
+
+ with pytest.raises(ValueError, match='exceeds the size limit'):
+ await dify_client.upload_file(file_path, 'person_user-1')
+
class TestDifyExtractTextOutput:
"""Tests for _extract_dify_text_output method."""
@@ -252,42 +279,65 @@ class TestDifyHumanInputForms:
runner.dify_client.upload_file = AsyncMock(return_value={'id': 'upload-1'})
return runner
- def test_pending_forms_are_isolated_by_bot_and_pipeline(self):
+ def test_pending_forms_are_isolated_by_workspace_generation_bot_and_pipeline(self):
from langbot.pkg.provider.runners import difysvapi
query_a = MagicMock()
+ query_a.instance_uuid = 'instance-a'
+ query_a.workspace_uuid = 'workspace-a'
+ query_a.placement_generation = 1
query_a.bot_uuid = 'bot-a'
query_a.pipeline_uuid = 'pipeline-a'
query_a.session.launcher_type.value = 'person'
query_a.session.launcher_id = 'shared-user'
query_b = MagicMock()
+ query_b.instance_uuid = 'instance-a'
+ query_b.workspace_uuid = 'workspace-a'
+ query_b.placement_generation = 1
query_b.bot_uuid = 'bot-b'
query_b.pipeline_uuid = 'pipeline-a'
query_b.session.launcher_type.value = 'person'
query_b.session.launcher_id = 'shared-user'
query_c = MagicMock()
+ query_c.instance_uuid = 'instance-a'
+ query_c.workspace_uuid = 'workspace-a'
+ query_c.placement_generation = 1
query_c.bot_uuid = 'bot-a'
query_c.pipeline_uuid = 'pipeline-b'
query_c.session.launcher_type.value = 'person'
query_c.session.launcher_id = 'shared-user'
+ query_d = MagicMock()
+ query_d.instance_uuid = 'instance-a'
+ query_d.workspace_uuid = 'workspace-b'
+ query_d.placement_generation = 2
+ query_d.bot_uuid = 'bot-a'
+ query_d.pipeline_uuid = 'pipeline-a'
+ query_d.session.launcher_type.value = 'person'
+ query_d.session.launcher_id = 'shared-user'
+
key_a = difysvapi._session_key_from_query(query_a)
key_b = difysvapi._session_key_from_query(query_b)
key_c = difysvapi._session_key_from_query(query_c)
+ key_d = difysvapi._session_key_from_query(query_d)
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(key_a, {'form_token': 'token-a', 'workflow_run_id': 'run-a'})
difysvapi._set_pending_form(key_b, {'form_token': 'token-b', 'workflow_run_id': 'run-b'})
difysvapi._set_pending_form(key_c, {'form_token': 'token-c', 'workflow_run_id': 'run-c'})
+ difysvapi._set_pending_form(key_d, {'form_token': 'token-d', 'workflow_run_id': 'run-d'})
assert key_a != key_b
assert key_a != key_c
+ assert key_a != key_d
assert difysvapi._get_pending_form_by_token(key_a, 'token-a') is not None
assert difysvapi._get_pending_form_by_token(key_a, 'token-b') is None
assert difysvapi._get_pending_form_by_token(key_a, 'token-c') is None
+ assert difysvapi._get_pending_form_by_token(key_a, 'token-d') is None
assert difysvapi._get_pending_form_by_token(key_b, 'token-b') is not None
assert difysvapi._get_pending_form_by_token(key_c, 'token-c') is not None
+ assert difysvapi._get_pending_form_by_token(key_d, 'token-d') is not None
assert difysvapi._get_latest_pending_form(key_a)['workflow_run_id'] == 'run-a'
assert difysvapi._get_latest_pending_form(key_b)['workflow_run_id'] == 'run-b'
assert difysvapi._get_latest_pending_form(key_c)['workflow_run_id'] == 'run-c'
@@ -297,6 +347,130 @@ class TestDifyHumanInputForms:
assert difysvapi._dify_user_from_query(query_a) == difysvapi._dify_user_from_query(query_c)
difysvapi._PENDING_FORMS.clear()
+ def test_pending_form_lookup_does_not_scan_unrelated_sessions(self, monkeypatch):
+ from langbot.pkg.provider.runners import difysvapi
+
+ def session_key(index: int):
+ return (
+ 'instance',
+ f'workspace-{index}',
+ 1,
+ 'bot',
+ 'pipeline',
+ 'adapter',
+ 'person',
+ f'user-{index}',
+ )
+
+ difysvapi._PENDING_FORMS.clear()
+ for index in range(512):
+ difysvapi._set_pending_form(
+ session_key(index),
+ {
+ 'form_token': f'token-{index}',
+ 'workflow_run_id': f'run-{index}',
+ },
+ )
+
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('pending form lookup scanned all sessions')
+
+ def keys(self):
+ raise AssertionError('pending form lookup scanned all sessions')
+
+ def items(self):
+ raise AssertionError('pending form lookup scanned all sessions')
+
+ def values(self):
+ raise AssertionError('pending form lookup scanned all sessions')
+
+ guarded_forms = NoGlobalIterationDict(difysvapi._PENDING_FORMS)
+ monkeypatch.setattr(difysvapi, '_PENDING_FORMS', guarded_forms)
+
+ assert difysvapi._get_pending_form_by_token(session_key(511), 'token-511')['workflow_run_id'] == 'run-511'
+ difysvapi._set_pending_form(
+ session_key(512),
+ {'form_token': 'token-512', 'workflow_run_id': 'run-512'},
+ )
+ assert len(guarded_forms) == 513
+
+ def test_pending_form_expiry_heap_ignores_stale_overwrite_and_stays_bounded(self):
+ from langbot.pkg.provider.runners import difysvapi
+
+ session_key = (
+ 'instance',
+ 'workspace',
+ 1,
+ 'bot',
+ 'pipeline',
+ 'adapter',
+ 'person',
+ 'user',
+ )
+ difysvapi._PENDING_FORMS.clear()
+ now = time.time()
+ difysvapi._set_pending_form(
+ session_key,
+ {
+ 'form_token': 'token',
+ 'workflow_run_id': 'stale',
+ 'expiration_time': now + 1,
+ },
+ )
+ for revision in range(500):
+ difysvapi._set_pending_form(
+ session_key,
+ {
+ 'form_token': 'token',
+ 'workflow_run_id': f'current-{revision}',
+ 'expiration_time': now + 3600 + revision,
+ },
+ )
+
+ difysvapi._prune_pending_forms(now + 2)
+
+ assert difysvapi._get_pending_form_by_token(session_key, 'token')['workflow_run_id'] == 'current-499'
+ assert difysvapi._PENDING_FORM_ACTIVE_COUNT == 1
+ assert len(difysvapi._PENDING_FORM_EXPIRY_HEAP) <= max(
+ difysvapi._PENDING_FORM_HEAP_COMPACT_FLOOR,
+ difysvapi._PENDING_FORM_ACTIVE_COUNT * difysvapi._PENDING_FORM_HEAP_MAX_MULTIPLIER,
+ )
+
+ def test_pending_form_capacity_evicts_earliest_session_without_full_scan(
+ self,
+ monkeypatch,
+ ):
+ from langbot.pkg.provider.runners import difysvapi
+
+ def session_key(index: int):
+ return (
+ 'instance',
+ f'workspace-{index}',
+ 1,
+ 'bot',
+ 'pipeline',
+ 'adapter',
+ 'person',
+ f'user-{index}',
+ )
+
+ difysvapi._PENDING_FORMS.clear()
+ monkeypatch.setattr(difysvapi, '_PENDING_FORM_MAX_SESSIONS', 2)
+ now = time.time()
+ for index, expires_in in ((1, 300), (2, 100), (3, 200)):
+ difysvapi._set_pending_form(
+ session_key(index),
+ {
+ 'form_token': f'token-{index}',
+ 'expiration_time': now + expires_in,
+ },
+ )
+
+ assert session_key(1) in difysvapi._PENDING_FORMS
+ assert session_key(2) not in difysvapi._PENDING_FORMS
+ assert session_key(3) in difysvapi._PENDING_FORMS
+
def test_interactive_form_data_preserves_pipeline_uuid(self):
from langbot.pkg.provider.runners import difysvapi
diff --git a/tests/unit_tests/provider/runners/test_remote_stream_limits.py b/tests/unit_tests/provider/runners/test_remote_stream_limits.py
new file mode 100644
index 000000000..6791999d6
--- /dev/null
+++ b/tests/unit_tests/provider/runners/test_remote_stream_limits.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot.libs.deerflow_api.client import (
+ ERROR_BODY_MAX_BYTES,
+ _read_error_body,
+)
+from langbot.libs.deerflow_api.errors import DeerFlowAPIError
+from langbot.pkg.provider.runners.langflowapi import (
+ _MAX_LANGFLOW_LINE_CHARS,
+ _MAX_LANGFLOW_RESPONSE_BYTES,
+ _iter_limited_lines,
+ _read_limited_response,
+)
+
+
+class _ChunkedResponse:
+ def __init__(self, chunks: list[bytes]):
+ self._chunks = chunks
+
+ async def aiter_bytes(self, chunk_size=None):
+ del chunk_size
+ for chunk in self._chunks:
+ yield chunk
+
+
+@pytest.mark.asyncio
+async def test_langflow_rejects_oversized_stream_event():
+ response = _ChunkedResponse([b'x' * (_MAX_LANGFLOW_LINE_CHARS + 1)])
+
+ with pytest.raises(ValueError, match='event exceeds'):
+ await anext(_iter_limited_lines(response))
+
+
+@pytest.mark.asyncio
+async def test_langflow_rejects_oversized_blocking_response():
+ response = _ChunkedResponse([b'x' * (_MAX_LANGFLOW_RESPONSE_BYTES + 1)])
+
+ with pytest.raises(ValueError, match='response exceeds'):
+ await _read_limited_response(response)
+
+
+@pytest.mark.asyncio
+async def test_deerflow_rejects_oversized_error_body():
+ response = _ChunkedResponse([b'x' * (ERROR_BODY_MAX_BYTES + 1)])
+
+ with pytest.raises(DeerFlowAPIError, match='response exceeds'):
+ await _read_error_body(response)
diff --git a/tests/unit_tests/provider/runners/test_runner_resource_limits.py b/tests/unit_tests/provider/runners/test_runner_resource_limits.py
new file mode 100644
index 000000000..2aeebdee8
--- /dev/null
+++ b/tests/unit_tests/provider/runners/test_runner_resource_limits.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import asyncio
+import threading
+from unittest.mock import AsyncMock
+
+import pytest
+
+from langbot.pkg.provider import runner
+from langbot.pkg.provider.runners import (
+ cozeapi,
+ dashscopeapi,
+ tboxapi,
+ weknoraapi,
+)
+
+
+@pytest.mark.asyncio
+async def test_blocking_provider_iterator_runs_outside_event_loop():
+ release = threading.Event()
+
+ def values():
+ release.wait(timeout=2)
+ yield 'ready'
+
+ task = asyncio.create_task(anext(runner.iterate_sync(values())))
+ await asyncio.sleep(0)
+ assert not task.done()
+
+ release.set()
+ assert await asyncio.wait_for(task, timeout=1) == 'ready'
+
+
+@pytest.mark.asyncio
+async def test_sync_provider_iterator_has_event_limit():
+ with pytest.raises(RuntimeError, match='event limit'):
+ async for _ in runner.iterate_sync(iter([1, 2]), max_items=1):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_coze_runner_closes_request_scoped_client():
+ request_runner = object.__new__(cozeapi.CozeAPIRunner)
+ request_runner.coze = AsyncMock()
+
+ await request_runner.aclose()
+
+ request_runner.coze.close.assert_awaited_once()
+
+
+@pytest.mark.parametrize(
+ ('append', 'exception_type'),
+ [
+ (cozeapi._append_bounded, ValueError),
+ (dashscopeapi._append_bounded, dashscopeapi.DashscopeAPIError),
+ (tboxapi._append_bounded, tboxapi.TboxAPIError),
+ (weknoraapi._append_bounded, weknoraapi.errors.WeKnoraAPIError),
+ ],
+)
+def test_provider_accumulators_reject_oversized_output(append, exception_type):
+ with pytest.raises(exception_type, match='exceeds the runtime limit'):
+ append('x' * (1024 * 1024), 'y')
diff --git a/tests/unit_tests/provider/test_localagent_no_duplicate.py b/tests/unit_tests/provider/test_localagent_no_duplicate.py
index f047dfcbc..7e75b9f42 100644
--- a/tests/unit_tests/provider/test_localagent_no_duplicate.py
+++ b/tests/unit_tests/provider/test_localagent_no_duplicate.py
@@ -10,6 +10,7 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
+from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
@@ -97,7 +98,7 @@ def make_query() -> pipeline_query.Query:
adapter = AsyncMock()
adapter.is_stream_output_supported = AsyncMock(return_value=False)
- return pipeline_query.Query.model_construct(
+ query = pipeline_query.Query.model_construct(
query_id='no-dup-query',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -124,6 +125,17 @@ def make_query() -> pipeline_query.Query:
use_llm_model_uuid='test-model-uuid',
variables={},
)
+ object.__setattr__(
+ query,
+ '_execution_context',
+ ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
+ ),
+ )
+ return query
def _make_app(provider) -> SimpleNamespace:
diff --git a/tests/unit_tests/provider/test_localagent_sandbox_exec.py b/tests/unit_tests/provider/test_localagent_sandbox_exec.py
index 9bc155343..e912d4e27 100644
--- a/tests/unit_tests/provider/test_localagent_sandbox_exec.py
+++ b/tests/unit_tests/provider/test_localagent_sandbox_exec.py
@@ -10,6 +10,7 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
+from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.provider.runners.localagent import LocalAgentRunner, _StreamAccumulator
@@ -95,7 +96,7 @@ def make_query() -> pipeline_query.Query:
adapter = AsyncMock()
adapter.is_stream_output_supported = AsyncMock(return_value=False)
- return pipeline_query.Query.model_construct(
+ query = pipeline_query.Query.model_construct(
query_id='avg-query',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -122,6 +123,20 @@ def make_query() -> pipeline_query.Query:
use_llm_model_uuid='test-model-uuid',
variables={},
)
+ object.__setattr__(
+ query,
+ '_execution_context',
+ ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ bot_uuid='bot-uuid',
+ pipeline_uuid='pipeline-uuid',
+ query_uuid='query-avg',
+ ),
+ )
+ object.__setattr__(query, 'query_uuid', 'query-avg')
+ return query
def test_stream_accumulator_merges_fragmented_tool_call_arguments():
diff --git a/tests/unit_tests/provider/test_mcp_box_integration.py b/tests/unit_tests/provider/test_mcp_box_integration.py
index caf06bf6d..e8f5cd7fb 100644
--- a/tests/unit_tests/provider/test_mcp_box_integration.py
+++ b/tests/unit_tests/provider/test_mcp_box_integration.py
@@ -154,7 +154,17 @@ def mcp_module():
def _make_ap():
ap = Mock()
ap.logger = Mock()
+ ap.instance_config = SimpleNamespace(data={'mcp': {'stdio': {'enabled': True}}})
+ ap.workspace_service = Mock()
+ ap.workspace_service.get_execution_binding = AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ )
ap.box_service = Mock()
+ ap.box_service.get_managed_process_websocket_connection = AsyncMock(return_value=('ws://box.example/process', {}))
return ap
@@ -166,6 +176,11 @@ def _make_session(mcp_module, server_config: dict, ap=None):
server_config=server_config,
enable=True,
ap=ap,
+ execution_context=mcp_module.ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ ),
)
@@ -591,6 +606,26 @@ class TestGetRuntimeInfoDict:
assert info['status'] == 'connecting'
assert 'box_session_id' not in info
+ def test_runtime_error_detail_never_echoes_secret_config(self, mcp_module):
+ s = _make_session(
+ mcp_module,
+ {
+ 'name': 'test',
+ 'uuid': 'test-uuid',
+ 'mode': 'invalid',
+ 'headers': {'Authorization': 'Bearer TOPSECRET'},
+ 'env': {'API_KEY': 'TOPSECRET'},
+ },
+ )
+ s.status = mcp_module.MCPSessionStatus.ERROR
+ s.error_message = f'Unknown MCP server mode: {s.server_config}'
+
+ info = s.get_runtime_info_dict()
+
+ assert info['error_message'] == 'MCP runtime failed'
+ assert info['error_code'] == 'runtime_error'
+ assert 'TOPSECRET' not in str(info)
+
def test_runtime_tools_include_parameters(self, mcp_module):
s = _make_session(
mcp_module,
@@ -794,7 +829,7 @@ class TestGetRuntimeInfoDict:
assert ap.box_service.available is True
@pytest.mark.asyncio
- async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module, monkeypatch):
+ async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module):
ap = _make_ap()
ap.box_service.available = False
ap.box_service.enabled = True
@@ -819,14 +854,13 @@ class TestGetRuntimeInfoDict:
raise RuntimeError('Box runtime is not available after 1 seconds')
session._lifecycle_loop = lifecycle
- sleep = AsyncMock()
- monkeypatch.setattr(mcp_module.asyncio, 'sleep', sleep)
+ session._sleep_with_execution_fence = AsyncMock()
await session._lifecycle_loop_with_retry()
assert attempts == 2
assert session.retry_count == 0
- sleep.assert_awaited_once_with(1)
+ session._sleep_with_execution_fence.assert_awaited_once_with(1)
@pytest.mark.asyncio
async def test_disabled_box_still_stops_mcp_retry_loop(self, mcp_module):
@@ -914,6 +948,31 @@ class TestBoxConfigParsing:
assert s.box_config.host_path_mode == 'ro'
+@pytest.mark.asyncio
+async def test_stdio_instance_gate_runs_before_box_transport(mcp_module):
+ ap = _make_ap()
+ ap.instance_config.data['mcp']['stdio']['enabled'] = False
+ ap.box_service.available = True
+ session = _make_session(
+ mcp_module,
+ {
+ 'name': 'blocked',
+ 'uuid': 'blocked-uuid',
+ 'mode': 'stdio',
+ 'command': 'python',
+ 'args': [],
+ 'env': {},
+ },
+ ap=ap,
+ )
+ session._box_stdio_runtime.initialize = AsyncMock()
+
+ with pytest.raises(RuntimeError, match='disabled by instance policy'):
+ await session._init_stdio_python_server()
+
+ session._box_stdio_runtime.initialize.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_module, tmp_path):
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
@@ -931,12 +990,16 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
async def initialize(self):
return None
+ captured_transport = {}
+
@asynccontextmanager
- async def fake_websocket_client(_url: str):
+ async def fake_authenticated_websocket_client(url: str, headers: dict[str, str]):
+ captured_transport['url'] = url
+ captured_transport['headers'] = headers
yield ('read-stream', 'write-stream')
mcp_stdio_module.ClientSession = FakeClientSession
- mcp_stdio_module.websocket_client = fake_websocket_client
+ mcp_stdio_module.authenticated_websocket_client = fake_authenticated_websocket_client
ap = _make_ap()
ap.box_service.available = True
@@ -947,7 +1010,17 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
execute=AsyncMock(return_value=SimpleNamespace(ok=True, stderr='', exit_code=0))
)
ap.box_service.start_managed_process = AsyncMock(return_value={})
- ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box.example/process')
+ ap.box_service.get_managed_process_websocket_connection = AsyncMock(
+ return_value=(
+ 'ws://box.example/process',
+ {
+ 'X-LangBot-Box-Control-Token': 'secret-token',
+ 'X-LangBot-Instance-Id': 'instance-a',
+ 'X-LangBot-Workspace-Id': 'workspace-a',
+ 'X-LangBot-Placement-Generation': '1',
+ },
+ )
+ )
host_path = tmp_path / 'mcp-source'
host_path.mkdir()
@@ -971,7 +1044,8 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
await session.exit_stack.aclose()
assert ap.box_service.create_session.await_count == 1
- session_payload = ap.box_service.create_session.await_args.args[0]
+ assert ap.box_service.create_session.await_args.args[0] == session.execution_context
+ session_payload = ap.box_service.create_session.await_args.args[1]
assert session_payload['session_id'] == 'mcp-shared'
assert 'host_path' not in session_payload
assert ap.box_service.build_spec.call_count == 1
@@ -981,11 +1055,22 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
staged_file = tmp_path / 'shared-box-workspace' / '.mcp' / 'u1' / 'workspace' / 'server.py'
assert staged_file.read_text(encoding='utf-8') == 'print("hello")\n'
- process_payload = ap.box_service.start_managed_process.await_args.args[1]
+ assert ap.box_service.start_managed_process.await_args.args[0] == session.execution_context
+ process_payload = ap.box_service.start_managed_process.await_args.args[2]
assert process_payload['process_id'] == 'u1'
assert process_payload['command'] == 'python'
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
+ assert captured_transport == {
+ 'url': 'ws://box.example/process',
+ 'headers': {
+ 'X-LangBot-Box-Control-Token': 'secret-token',
+ 'X-LangBot-Instance-Id': 'instance-a',
+ 'X-LangBot-Workspace-Id': 'workspace-a',
+ 'X-LangBot-Placement-Generation': '1',
+ },
+ }
+ assert 'secret-token' not in captured_transport['url']
@pytest.mark.asyncio
@@ -1024,7 +1109,7 @@ async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_mo
ap.box_service.available = True
ap.box_service.create_session = AsyncMock(return_value={})
ap.box_service.start_managed_process = AsyncMock(return_value={})
- ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
+ ap.box_service.get_managed_process_websocket_connection = AsyncMock(return_value=('ws://box/p', {}))
session = _make_session(
mcp_module,
@@ -1090,7 +1175,7 @@ async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_
ap.box_service.available = True
ap.box_service.create_session = AsyncMock(return_value={})
ap.box_service.start_managed_process = AsyncMock(return_value={})
- ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
+ ap.box_service.get_managed_process_websocket_connection = AsyncMock(return_value=('ws://box/p', {}))
session = _make_session(
mcp_module,
diff --git a/tests/unit_tests/provider/test_mcp_remote_transport.py b/tests/unit_tests/provider/test_mcp_remote_transport.py
index b7761ec8a..7abe8afea 100644
--- a/tests/unit_tests/provider/test_mcp_remote_transport.py
+++ b/tests/unit_tests/provider/test_mcp_remote_transport.py
@@ -12,9 +12,17 @@ import pytest
from aiohttp import web
from mcp import types as mcp_types
+from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.provider.tools.loaders.mcp import MCPToolCallTimeoutError, RuntimeMCPSession
+TEST_EXECUTION_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+
+
class _TransportProbe:
def __init__(self, streamable_status: int | None) -> None:
self.streamable_status = streamable_status
@@ -151,7 +159,21 @@ def _session(
timeout: float = 2,
tool_call_timeout_sec: float = 300,
) -> RuntimeMCPSession:
- app = cast(Any, SimpleNamespace(logger=Mock()))
+ app = cast(
+ Any,
+ SimpleNamespace(
+ logger=Mock(),
+ workspace_service=SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
+ workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
+ placement_generation=TEST_EXECUTION_CONTEXT.placement_generation,
+ )
+ )
+ ),
+ ),
+ )
return RuntimeMCPSession(
'remote-transport-test',
{
@@ -163,6 +185,7 @@ def _session(
},
True,
app,
+ TEST_EXECUTION_CONTEXT,
)
diff --git a/tests/unit_tests/provider/test_mcp_resources.py b/tests/unit_tests/provider/test_mcp_resources.py
index cee241b75..62d179a55 100644
--- a/tests/unit_tests/provider/test_mcp_resources.py
+++ b/tests/unit_tests/provider/test_mcp_resources.py
@@ -11,6 +11,7 @@ import pytest
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
+from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.provider.tools.loaders.mcp import (
MCP_RESOURCE_CONTEXT_QUERY_KEY,
MCP_RESOURCE_TRACE_QUERY_KEY,
@@ -23,10 +24,30 @@ from langbot.pkg.provider.tools.loaders.mcp import (
RuntimeMCPSession,
)
from langbot.pkg.telemetry import features as telemetry_features
+from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
+
+
+TEST_EXECUTION_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ query_uuid='query-a',
+)
def _app() -> SimpleNamespace:
- return SimpleNamespace(logger=Mock())
+ return SimpleNamespace(
+ logger=Mock(),
+ workspace_service=SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ )
+ ),
+ )
def _connected_session(
@@ -35,8 +56,15 @@ def _connected_session(
uuid: str = 'srv-1',
resources: list[dict] | None = None,
templates: list[dict] | None = None,
+ execution_context: ExecutionContext = TEST_EXECUTION_CONTEXT,
) -> RuntimeMCPSession:
- session = RuntimeMCPSession(name, {'uuid': uuid, 'mode': 'remote'}, True, _app())
+ session = RuntimeMCPSession(
+ name,
+ {'uuid': uuid, 'mode': 'remote'},
+ True,
+ _app(),
+ execution_context,
+ )
session.status = MCPSessionStatus.CONNECTED
session.session = SimpleNamespace(read_resource=AsyncMock())
session.resources = resources or [
@@ -56,8 +84,24 @@ def _connected_session(
return session
-def _query() -> SimpleNamespace:
- return SimpleNamespace(variables={})
+def _query(variables: dict | None = None, context: ExecutionContext = TEST_EXECUTION_CONTEXT) -> SimpleNamespace:
+ return SimpleNamespace(
+ instance_uuid=context.instance_uuid,
+ workspace_uuid=context.workspace_uuid,
+ placement_generation=context.placement_generation,
+ bot_uuid=context.bot_uuid,
+ pipeline_uuid=context.pipeline_uuid,
+ query_uuid=context.query_uuid,
+ variables=variables or {},
+ )
+
+
+def _register_session(loader: MCPLoader, session: RuntimeMCPSession) -> None:
+ loader._register_session(
+ session.execution_context,
+ session.server_name,
+ session,
+ )
def _http_status_error(status_code: int) -> httpx.HTTPStatusError:
@@ -84,6 +128,7 @@ async def test_invoke_mcp_tool_uses_configurable_request_timeout():
},
True,
_app(),
+ TEST_EXECUTION_CONTEXT,
)
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
@@ -108,6 +153,7 @@ async def test_invoke_mcp_tool_zero_timeout_disables_request_deadline():
},
True,
_app(),
+ TEST_EXECUTION_CONTEXT,
)
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
@@ -131,6 +177,7 @@ async def test_invoke_mcp_tool_timeout_is_not_retried_and_session_remains_usable
},
True,
_app(),
+ TEST_EXECUTION_CONTEXT,
)
timeout = McpError(
mcp_types.ErrorData(
@@ -165,6 +212,7 @@ def test_invalid_tool_call_timeout_falls_back_to_default(invalid_timeout):
},
True,
ap,
+ TEST_EXECUTION_CONTEXT,
)
assert session.tool_call_timeout_sec == MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
@@ -178,6 +226,7 @@ async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_
{'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'},
True,
_app(),
+ TEST_EXECUTION_CONTEXT,
)
session._init_streamable_http_server = AsyncMock(
side_effect=ExceptionGroup('transport failed', [_http_status_error(405)])
@@ -197,6 +246,7 @@ async def test_remote_transport_does_not_fallback_for_auth_http_status():
{'uuid': 'srv-1', 'mode': 'remote', 'url': 'https://example.com/mcp'},
True,
_app(),
+ TEST_EXECUTION_CONTEXT,
)
error = _http_status_error(403)
session._init_streamable_http_server = AsyncMock(side_effect=error)
@@ -354,10 +404,18 @@ def test_resource_uri_allowed_supports_listed_templates_conservatively():
async def test_mcp_loader_can_hide_synthetic_resource_tools():
loader = MCPLoader(_app())
session = _connected_session()
- loader.sessions = {'docs': session}
+ _register_session(loader, session)
- with_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=True)
- without_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=False)
+ with_resource_tools = await loader.get_tools(
+ TEST_EXECUTION_CONTEXT,
+ ['srv-1'],
+ include_resource_tools=True,
+ )
+ without_resource_tools = await loader.get_tools(
+ TEST_EXECUTION_CONTEXT,
+ ['srv-1'],
+ include_resource_tools=False,
+ )
assert {tool.name for tool in with_resource_tools} == {
MCP_TOOL_LIST_RESOURCES,
@@ -370,9 +428,9 @@ async def test_mcp_loader_can_hide_synthetic_resource_tools():
async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled():
loader = MCPLoader(_app())
session = _connected_session()
- loader.sessions = {'docs': session}
- query = SimpleNamespace(
- variables={
+ _register_session(loader, session)
+ query = _query(
+ {
'_pipeline_bound_mcp_servers': ['srv-1'],
'_pipeline_mcp_resource_agent_read_enabled': False,
}
@@ -411,9 +469,10 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re
)
]
)
- loader.sessions = {'docs': docs, 'other': other}
- query = SimpleNamespace(
- variables={
+ _register_session(loader, docs)
+ _register_session(loader, other)
+ query = _query(
+ {
'_pipeline_bound_mcp_servers': ['srv-1'],
'_pipeline_mcp_resource_attachments': [
{'server_uuid': 'srv-1', 'server_name': 'docs', 'uri': 'file:///README.md', 'mode': 'pinned'},
@@ -433,6 +492,140 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re
other.session.read_resource.assert_not_called()
+def test_mcp_loader_session_keys_do_not_collide_between_workspaces():
+ loader = MCPLoader(_app())
+ workspace_b = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-b',
+ placement_generation=1,
+ query_uuid='query-b',
+ )
+ session_a = _connected_session(name='docs', uuid='srv-a')
+ session_b = _connected_session(
+ name='docs',
+ uuid='srv-b',
+ execution_context=workspace_b,
+ )
+ _register_session(loader, session_a)
+ _register_session(loader, session_b)
+
+ assert len(loader.sessions) == 2
+ assert loader.get_session(TEST_EXECUTION_CONTEXT, 'docs') is session_a
+ assert loader.get_session(workspace_b, 'docs') is session_b
+ assert loader.get_session(TEST_EXECUTION_CONTEXT, 'docs') is not loader.get_session(workspace_b, 'docs')
+
+
+@pytest.mark.asyncio
+async def test_mcp_tool_result_is_discarded_when_generation_changes_during_call():
+ session = _connected_session()
+ binding = SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ session.ap.workspace_service.get_execution_binding.side_effect = [
+ binding,
+ binding,
+ WorkspaceGenerationMismatchError('generation changed during tool call'),
+ ]
+ session.session = SimpleNamespace(call_tool=AsyncMock(return_value=SimpleNamespace(isError=False, content=[])))
+
+ with pytest.raises(WorkspaceGenerationMismatchError):
+ await session.invoke_mcp_tool('side_effecting_tool', {})
+
+ session.session.call_tool.assert_awaited_once_with(
+ 'side_effecting_tool',
+ {},
+ read_timeout_seconds=timedelta(seconds=MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS),
+ )
+
+
+@pytest.mark.asyncio
+async def test_mcp_resource_cache_is_not_served_to_stale_generation():
+ session = _connected_session()
+ session._resource_cache[('file:///README.md', 10, None, False)] = {
+ 'cached_at': 0,
+ 'envelope': {'contents': [{'type': 'text', 'text': 'stale'}]},
+ }
+ session.ap.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError(
+ 'stale generation'
+ )
+
+ with pytest.raises(WorkspaceGenerationMismatchError):
+ await session.read_resource_envelope('file:///README.md', max_bytes=10)
+
+ session.session.read_resource.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_directory_projection_retires_idle_mcp_scope_without_db_poll():
+ loader = MCPLoader(_app())
+ sessions = []
+ for index in range(100):
+ context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid=f'workspace-{index}',
+ placement_generation=1,
+ )
+ session = RuntimeMCPSession(
+ f'server-{index}',
+ {'uuid': f'srv-{index}', 'mode': 'remote'},
+ True,
+ loader.ap,
+ context,
+ )
+ session.shutdown = AsyncMock()
+ loader._register_session(context, session.server_name, session)
+ sessions.append(session)
+
+ loader.reconcile_execution_projection('instance-a', {})
+ reconcile_task = loader._projection_reconcile_task
+ assert reconcile_task is not None
+ assert len(loader._pending_projection_retirements) == 100
+
+ # A second projection coalesces into the same worker instead of creating
+ # one timer or task per Workspace.
+ loader.reconcile_execution_projection('instance-a', {})
+ assert loader._projection_reconcile_task is reconcile_task
+
+ await asyncio.wait_for(reconcile_task, timeout=1)
+
+ assert loader.sessions == {}
+ assert loader._scope_generations == {}
+ assert loader._pending_projection_retirements == set()
+ assert sum(session.shutdown.await_count for session in sessions) == 100
+ loader.ap.workspace_service.get_execution_binding.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_directory_projection_keeps_matching_and_unaffected_mcp_scopes():
+ loader = MCPLoader(_app())
+ matching = _connected_session()
+ other_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-b',
+ placement_generation=1,
+ )
+ unaffected = _connected_session(
+ name='other',
+ uuid='srv-2',
+ execution_context=other_context,
+ )
+ _register_session(loader, matching)
+ _register_session(loader, unaffected)
+
+ loader.reconcile_execution_projection(
+ 'instance-a',
+ {'workspace-a': 1},
+ affected_workspace_uuids={'workspace-a'},
+ )
+ await asyncio.sleep(0)
+
+ assert loader.get_session(TEST_EXECUTION_CONTEXT, 'docs') is matching
+ assert loader.get_session(other_context, 'other') is unaffected
+ assert loader._projection_reconcile_task is None
+
+
@pytest.mark.asyncio
async def test_mcp_loader_shutdown_cancels_startup_tasks_and_closes_sessions_concurrently():
loader = MCPLoader(_app())
@@ -454,6 +647,7 @@ async def test_mcp_loader_shutdown_cancels_startup_tasks_and_closes_sessions_con
class Session:
def __init__(self, name: str):
self.name = name
+ self.server_name = name
async def shutdown(self):
started.add(self.name)
@@ -470,3 +664,165 @@ async def test_mcp_loader_shutdown_cancels_startup_tasks_and_closes_sessions_con
assert started == {'one', 'two'}
assert loader._hosted_mcp_tasks == []
assert loader.sessions == {}
+
+
+@pytest.mark.asyncio
+async def test_completed_mcp_host_tasks_do_not_accumulate():
+ loader = MCPLoader(_app())
+ task = asyncio.create_task(asyncio.sleep(0))
+
+ loader.track_hosted_task(task, TEST_EXECUTION_CONTEXT)
+ await task
+ await asyncio.sleep(0)
+
+ assert loader._hosted_mcp_tasks == []
+ assert loader._hosted_mcp_tasks_by_scope == {}
+ assert loader._scope_generations == {}
+
+
+@pytest.mark.asyncio
+async def test_generation_advance_cancels_host_tasks_and_closes_old_sessions():
+ loader = MCPLoader(_app())
+ old_session = SimpleNamespace(
+ server_name='old',
+ shutdown=AsyncMock(),
+ )
+ loader._register_session(
+ TEST_EXECUTION_CONTEXT,
+ old_session.server_name,
+ old_session,
+ )
+
+ async def pending_host():
+ await asyncio.Event().wait()
+
+ hosted_task = asyncio.create_task(pending_host())
+ loader.track_hosted_task(hosted_task, TEST_EXECUTION_CONTEXT)
+ await asyncio.sleep(0)
+ next_context = ExecutionContext(
+ instance_uuid=TEST_EXECUTION_CONTEXT.instance_uuid,
+ workspace_uuid=TEST_EXECUTION_CONTEXT.workspace_uuid,
+ placement_generation=2,
+ )
+ loader.ap.workspace_service.get_execution_binding = AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid=next_context.instance_uuid,
+ workspace_uuid=next_context.workspace_uuid,
+ placement_generation=next_context.placement_generation,
+ )
+ )
+
+ await loader._assert_execution_active(next_context)
+
+ assert hosted_task.cancelled()
+ old_session.shutdown.assert_awaited_once_with()
+ assert loader.sessions == {}
+ assert loader._session_keys_by_scope == {}
+ assert loader._hosted_mcp_tasks_by_scope == {}
+ assert loader._scope_generations == {}
+
+
+def test_session_lookup_uses_scope_index_without_global_iteration():
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('MCP lookup scanned every tenant session')
+
+ def items(self):
+ raise AssertionError('MCP lookup scanned every tenant session')
+
+ def values(self):
+ raise AssertionError('MCP lookup scanned every tenant session')
+
+ loader = MCPLoader(_app())
+ target_context = None
+ target_session = None
+ for index in range(1_000):
+ context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid=f'workspace-{index}',
+ placement_generation=1,
+ )
+ session = SimpleNamespace(server_name=f'server-{index}')
+ loader._register_session(context, session.server_name, session)
+ if index == 777:
+ target_context = context
+ target_session = session
+ loader._sessions = NoGlobalIterationDict(loader._sessions)
+
+ assert loader._sessions_for_context(target_context) == [target_session]
+
+
+@pytest.mark.asyncio
+async def test_mcp_startup_concurrency_is_instance_bounded():
+ app = _app()
+ app.instance_config = SimpleNamespace(data={'mcp': {'lifecycle_concurrency': 2}})
+ loader = MCPLoader(app)
+ active = 0
+ maximum_active = 0
+ release = asyncio.Event()
+
+ async def fake_host(_context, _config):
+ nonlocal active, maximum_active
+ active += 1
+ maximum_active = max(maximum_active, active)
+ if maximum_active == 2:
+ release.set()
+ await release.wait()
+ await asyncio.sleep(0)
+ active -= 1
+
+ loader._host_mcp_server = fake_host
+
+ await asyncio.gather(
+ *(
+ loader.host_mcp_server(
+ TEST_EXECUTION_CONTEXT,
+ {'name': f'server-{index}'},
+ )
+ for index in range(20)
+ )
+ )
+
+ assert loader._lifecycle_concurrency == 2
+ assert maximum_active == 2
+
+
+@pytest.mark.asyncio
+async def test_mcp_startup_dispatcher_does_not_create_every_server_task_at_once():
+ app = _app()
+ app.instance_config = SimpleNamespace(data={'mcp': {'lifecycle_concurrency': 2}})
+ loader = MCPLoader(app)
+ started = 0
+ first_batch_started = asyncio.Event()
+ release = asyncio.Event()
+
+ async def fake_host(_context, _config):
+ nonlocal started
+ started += 1
+ if started == 2:
+ first_batch_started.set()
+ await release.wait()
+
+ loader.host_mcp_server = fake_host
+ configs = [(TEST_EXECUTION_CONTEXT, {'name': f'server-{index}'}) for index in range(20)]
+
+ dispatch_task = asyncio.create_task(loader._host_server_configs_bounded(configs))
+ await asyncio.wait_for(first_batch_started.wait(), timeout=1)
+
+ assert started == 2
+ assert len(loader._hosted_mcp_tasks) == 2
+
+ release.set()
+ await dispatch_task
+ await asyncio.sleep(0)
+
+ assert started == 20
+ assert loader._hosted_mcp_tasks == []
+ assert loader._hosted_mcp_tasks_by_scope == {}
+
+
+def test_invalid_mcp_lifecycle_concurrency_uses_safe_default():
+ app = _app()
+ app.instance_config = SimpleNamespace(data={'mcp': {'lifecycle_concurrency': True}})
+
+ assert MCPLoader(app)._lifecycle_concurrency == 16
diff --git a/tests/unit_tests/provider/test_mcp_stdio_policy.py b/tests/unit_tests/provider/test_mcp_stdio_policy.py
new file mode 100644
index 000000000..57012bb9c
--- /dev/null
+++ b/tests/unit_tests/provider/test_mcp_stdio_policy.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+
+from langbot.pkg.provider.tools.loaders.mcp_policy import (
+ MCPStdioDisabledError,
+ require_stdio_mcp_enabled,
+ stdio_mcp_enabled,
+)
+from langbot.pkg.provider.tools.loaders.mcp import MCPLoader
+
+
+def _app(config: dict) -> SimpleNamespace:
+ return SimpleNamespace(instance_config=SimpleNamespace(data=config))
+
+
+def test_oss_default_remains_enabled_when_key_is_absent():
+ assert stdio_mcp_enabled(_app({})) is True
+
+
+@pytest.mark.parametrize(
+ 'value',
+ [False, 'false', 0, None, {}, []],
+)
+def test_disabled_or_invalid_values_fail_closed(value):
+ ap = _app({'mcp': {'stdio': {'enabled': value}}})
+
+ assert stdio_mcp_enabled(ap) is False
+ with pytest.raises(MCPStdioDisabledError, match='disabled by instance policy'):
+ require_stdio_mcp_enabled(ap, {'mode': 'stdio'})
+
+
+def test_remote_transport_is_independent_of_stdio_gate():
+ ap = _app({'mcp': {'stdio': {'enabled': False}}})
+
+ require_stdio_mcp_enabled(ap, {'mode': 'remote'})
+
+
+@pytest.mark.asyncio
+async def test_bootstrap_retains_but_does_not_launch_disabled_stdio_rows():
+ server = SimpleNamespace(uuid='server-a', workspace_uuid='workspace-a')
+ result = Mock()
+ result.all.return_value = [server]
+ ap = _app({'mcp': {'stdio': {'enabled': False}}})
+ ap.logger = Mock()
+ ap.persistence_mgr = SimpleNamespace(
+ execute_async=AsyncMock(return_value=result),
+ serialize_model=Mock(
+ return_value={
+ 'uuid': 'server-a',
+ 'workspace_uuid': 'workspace-a',
+ 'name': 'local',
+ 'mode': 'stdio',
+ 'enable': True,
+ 'extra_args': {},
+ }
+ ),
+ )
+ ap.workspace_service = SimpleNamespace(get_execution_binding=AsyncMock())
+ loader = MCPLoader(ap)
+ loader.host_mcp_server = AsyncMock()
+
+ await loader.load_mcp_servers_from_db()
+
+ loader.host_mcp_server.assert_not_awaited()
+ ap.workspace_service.get_execution_binding.assert_not_awaited()
+ assert loader.sessions == {}
diff --git a/tests/unit_tests/provider/test_model_manager.py b/tests/unit_tests/provider/test_model_manager.py
index 9e7a5c09a..1185e55d5 100644
--- a/tests/unit_tests/provider/test_model_manager.py
+++ b/tests/unit_tests/provider/test_model_manager.py
@@ -7,6 +7,7 @@ and error handling without calling real LLM APIs.
from __future__ import annotations
+import dataclasses
import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -16,7 +17,15 @@ from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.entity.errors import provider as provider_errors
from langbot.pkg.provider.modelmgr import token
-from tests.unit_tests.provider.conftest import _make_mock_result, _make_row_mock
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
+from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError, WorkspaceInvariantError
+from tests.unit_tests.provider.conftest import (
+ TEST_EXECUTION_CONTEXT,
+ TEST_WORKSPACE_UUID,
+ _make_mock_result,
+ _make_row_mock,
+)
# ============================================================================
@@ -63,6 +72,20 @@ async def test_model_manager_skips_space_sync_when_disabled(mock_app_for_modelmg
app.space_service.get_models.assert_not_called()
+@pytest.mark.asyncio
+async def test_model_manager_skips_legacy_space_sync_in_cloud_runtime(mock_app_for_modelmgr):
+ """Cloud startup must not resolve an OSS-local Workspace for legacy model sync."""
+ app = mock_app_for_modelmgr
+ app.instance_config.data = {'space': {'disable_models_service': False}}
+ app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
+
+ model_mgr = ModelManager(app)
+ model_mgr.load_models_from_db = AsyncMock()
+ await model_mgr.initialize()
+
+ app.workspace_service.get_local_execution_binding.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_modelmgr):
"""Space rerank entries are discovered and persisted under the shared provider."""
@@ -91,9 +114,10 @@ async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_mod
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
model_mgr = ModelManager(app)
- await model_mgr.sync_new_models_from_space()
+ await model_mgr.sync_new_models_from_space(TEST_EXECUTION_CONTEXT)
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
+ TEST_EXECUTION_CONTEXT,
{
'uuid': 'rerank-model-uuid',
'name': 'Qwen3-Reranker-8B',
@@ -134,13 +158,33 @@ async def test_model_manager_load_models_from_db(fake_requester_registry, fake_p
# Check providers loaded
assert len(model_mgr.provider_dict) == 2
- assert fake_persistence_data['provider_uuid'] in model_mgr.provider_dict
- assert fake_persistence_data['provider_uuid2'] in model_mgr.provider_dict
+ assert {provider.provider_entity.uuid for provider in model_mgr.provider_dict.values()} == {
+ fake_persistence_data['provider_uuid'],
+ fake_persistence_data['provider_uuid2'],
+ }
# Check models loaded
- assert len(model_mgr.llm_models) == 2
- assert len(model_mgr.embedding_models) == 1
- assert len(model_mgr.rerank_models) == 1
+ assert len(model_mgr.llm_model_dict) == 2
+ assert len(model_mgr.embedding_model_dict) == 1
+ assert len(model_mgr.rerank_model_dict) == 1
+
+
+@pytest.mark.asyncio
+async def test_empty_cloud_workspace_does_not_retain_generation(
+ mock_app_for_modelmgr,
+):
+ model_mgr = ModelManager(mock_app_for_modelmgr)
+
+ await model_mgr._load_workspace_models(TEST_EXECUTION_CONTEXT)
+
+ assert model_mgr.provider_dict == {}
+ assert model_mgr.llm_model_dict == {}
+ assert model_mgr.embedding_model_dict == {}
+ assert model_mgr.rerank_model_dict == {}
+ assert model_mgr._scope_generations == {}
+
+ await model_mgr.resolve_execution_context(TEST_EXECUTION_CONTEXT)
+ assert model_mgr._scope_generations == {}
@pytest.mark.asyncio
@@ -161,7 +205,7 @@ async def test_model_manager_load_provider_unknown_requester(mock_app_for_modelm
}
with pytest.raises(provider_errors.RequesterNotFoundError) as exc_info:
- await model_mgr.load_provider(provider_info)
+ await model_mgr.load_provider(TEST_EXECUTION_CONTEXT, provider_info)
assert exc_info.value.requester_name == 'non-existent-requester'
@@ -180,7 +224,7 @@ async def test_model_manager_load_provider_from_dict(fake_requester_registry):
'api_keys': ['dict-key'],
}
- runtime_provider = await model_mgr.load_provider(provider_info)
+ runtime_provider = await model_mgr.load_provider(TEST_EXECUTION_CONTEXT, provider_info)
assert runtime_provider.provider_entity.uuid == 'dict-provider-uuid'
assert runtime_provider.provider_entity.name == 'Dict Provider'
@@ -197,7 +241,7 @@ async def test_model_manager_load_provider_from_entity(fake_requester_registry,
provider_entity = fake_persistence_data['providers'][0]
- runtime_provider = await model_mgr.load_provider(provider_entity)
+ runtime_provider = await model_mgr.load_provider(TEST_EXECUTION_CONTEXT, provider_entity)
assert runtime_provider.provider_entity.uuid == provider_entity.uuid
assert runtime_provider.requester is not None
@@ -224,7 +268,7 @@ async def test_model_manager_get_model_by_uuid(fake_requester_registry, fake_per
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- model = await model_mgr.get_model_by_uuid('test-llm-uuid-1')
+ model = await model_mgr.get_model_by_uuid(TEST_EXECUTION_CONTEXT, 'test-llm-uuid-1')
assert model.model_entity.uuid == 'test-llm-uuid-1'
assert model.model_entity.name == 'TestLLM-1'
@@ -237,7 +281,7 @@ async def test_model_manager_get_model_by_uuid_not_found(fake_requester_registry
await model_mgr.initialize()
with pytest.raises(ValueError) as exc_info:
- await model_mgr.get_model_by_uuid('unknown-model-uuid')
+ await model_mgr.get_model_by_uuid(TEST_EXECUTION_CONTEXT, 'unknown-model-uuid')
assert 'unknown-model-uuid' in str(exc_info.value)
@@ -258,7 +302,10 @@ async def test_model_manager_get_embedding_model_by_uuid(fake_requester_registry
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- model = await model_mgr.get_embedding_model_by_uuid('test-embedding-uuid-1')
+ model = await model_mgr.get_embedding_model_by_uuid(
+ TEST_EXECUTION_CONTEXT,
+ 'test-embedding-uuid-1',
+ )
assert model.model_entity.uuid == 'test-embedding-uuid-1'
@@ -270,7 +317,10 @@ async def test_model_manager_get_embedding_model_by_uuid_not_found(fake_requeste
await model_mgr.initialize()
with pytest.raises(ValueError):
- await model_mgr.get_embedding_model_by_uuid('unknown-embedding-uuid')
+ await model_mgr.get_embedding_model_by_uuid(
+ TEST_EXECUTION_CONTEXT,
+ 'unknown-embedding-uuid',
+ )
@pytest.mark.asyncio
@@ -289,7 +339,7 @@ async def test_model_manager_get_rerank_model_by_uuid(fake_requester_registry, f
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- model = await model_mgr.get_rerank_model_by_uuid('test-rerank-uuid-1')
+ model = await model_mgr.get_rerank_model_by_uuid(TEST_EXECUTION_CONTEXT, 'test-rerank-uuid-1')
assert model.model_entity.uuid == 'test-rerank-uuid-1'
@@ -301,7 +351,7 @@ async def test_model_manager_get_rerank_model_by_uuid_not_found(fake_requester_r
await model_mgr.initialize()
with pytest.raises(ValueError):
- await model_mgr.get_rerank_model_by_uuid('unknown-rerank-uuid')
+ await model_mgr.get_rerank_model_by_uuid(TEST_EXECUTION_CONTEXT, 'unknown-rerank-uuid')
# ============================================================================
@@ -325,12 +375,12 @@ async def test_model_manager_remove_llm_model(fake_requester_registry, fake_pers
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- assert len(model_mgr.llm_models) == 2
+ assert len(model_mgr.llm_model_dict) == 2
- await model_mgr.remove_llm_model('test-llm-uuid-1')
+ await model_mgr.remove_llm_model(TEST_EXECUTION_CONTEXT, 'test-llm-uuid-1')
- assert len(model_mgr.llm_models) == 1
- assert model_mgr.llm_models[0].model_entity.uuid == 'test-llm-uuid-2'
+ assert len(model_mgr.llm_model_dict) == 1
+ assert next(iter(model_mgr.llm_model_dict.values())).model_entity.uuid == 'test-llm-uuid-2'
@pytest.mark.asyncio
@@ -349,12 +399,12 @@ async def test_model_manager_remove_llm_model_not_found(fake_requester_registry,
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- original_count = len(model_mgr.llm_models)
+ original_count = len(model_mgr.llm_model_dict)
# Removing unknown model should do nothing (no error)
- await model_mgr.remove_llm_model('unknown-model-uuid')
+ await model_mgr.remove_llm_model(TEST_EXECUTION_CONTEXT, 'unknown-model-uuid')
- assert len(model_mgr.llm_models) == original_count
+ assert len(model_mgr.llm_model_dict) == original_count
@pytest.mark.asyncio
@@ -373,11 +423,11 @@ async def test_model_manager_remove_embedding_model(fake_requester_registry, fak
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- assert len(model_mgr.embedding_models) == 1
+ assert len(model_mgr.embedding_model_dict) == 1
- await model_mgr.remove_embedding_model('test-embedding-uuid-1')
+ await model_mgr.remove_embedding_model(TEST_EXECUTION_CONTEXT, 'test-embedding-uuid-1')
- assert len(model_mgr.embedding_models) == 0
+ assert len(model_mgr.embedding_model_dict) == 0
@pytest.mark.asyncio
@@ -396,11 +446,11 @@ async def test_model_manager_remove_rerank_model(fake_requester_registry, fake_p
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- assert len(model_mgr.rerank_models) == 1
+ assert len(model_mgr.rerank_model_dict) == 1
- await model_mgr.remove_rerank_model('test-rerank-uuid-1')
+ await model_mgr.remove_rerank_model(TEST_EXECUTION_CONTEXT, 'test-rerank-uuid-1')
- assert len(model_mgr.rerank_models) == 0
+ assert len(model_mgr.rerank_model_dict) == 0
@pytest.mark.asyncio
@@ -419,11 +469,17 @@ async def test_model_manager_remove_provider(fake_requester_registry, fake_persi
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- assert fake_persistence_data['provider_uuid'] in model_mgr.provider_dict
+ assert any(
+ provider.provider_entity.uuid == fake_persistence_data['provider_uuid']
+ for provider in model_mgr.provider_dict.values()
+ )
- await model_mgr.remove_provider(fake_persistence_data['provider_uuid'])
+ await model_mgr.remove_provider(TEST_EXECUTION_CONTEXT, fake_persistence_data['provider_uuid'])
- assert fake_persistence_data['provider_uuid'] not in model_mgr.provider_dict
+ assert all(
+ provider.provider_entity.uuid != fake_persistence_data['provider_uuid']
+ for provider in model_mgr.provider_dict.values()
+ )
# ============================================================================
@@ -541,7 +597,7 @@ async def test_model_manager_init_temporary_runtime_llm_model(fake_requester_reg
'extra_args': {'temperature': 0.5},
}
- runtime_model = await model_mgr.init_temporary_runtime_llm_model(model_info)
+ runtime_model = await model_mgr.init_temporary_runtime_llm_model(TEST_EXECUTION_CONTEXT, model_info)
assert runtime_model.model_entity.uuid == 'temp-model-uuid'
assert runtime_model.model_entity.name == 'TempModel'
@@ -571,7 +627,10 @@ async def test_model_manager_init_temporary_runtime_embedding_model(fake_request
'extra_args': {'dimensions': 512},
}
- runtime_model = await model_mgr.init_temporary_runtime_embedding_model(model_info)
+ runtime_model = await model_mgr.init_temporary_runtime_embedding_model(
+ TEST_EXECUTION_CONTEXT,
+ model_info,
+ )
assert runtime_model.model_entity.uuid == 'temp-embedding-uuid'
assert runtime_model.model_entity.name == 'TempEmbedding'
@@ -596,7 +655,10 @@ async def test_model_manager_init_temporary_runtime_rerank_model(fake_requester_
'extra_args': {},
}
- runtime_model = await model_mgr.init_temporary_runtime_rerank_model(model_info)
+ runtime_model = await model_mgr.init_temporary_runtime_rerank_model(
+ TEST_EXECUTION_CONTEXT,
+ model_info,
+ )
assert runtime_model.model_entity.uuid == 'temp-rerank-uuid'
assert runtime_model.model_entity.name == 'TempRerank'
@@ -632,12 +694,16 @@ async def test_model_manager_reload_provider(fake_requester_registry, fake_persi
model_mgr.ap.persistence_mgr.execute_async = fake_execute
await model_mgr.initialize()
- original_provider = model_mgr.provider_dict[fake_persistence_data['provider_uuid']]
+ original_provider = await model_mgr.get_provider_by_uuid(
+ TEST_EXECUTION_CONTEXT,
+ fake_persistence_data['provider_uuid'],
+ )
original_base_url = original_provider.provider_entity.base_url
# Setup for reload - return updated provider
async def reload_execute(query):
updated_provider = persistence_model.ModelProvider(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid=fake_persistence_data['provider_uuid'],
name='Updated Provider',
requester='fake-requester',
@@ -648,9 +714,12 @@ async def test_model_manager_reload_provider(fake_requester_registry, fake_persi
model_mgr.ap.persistence_mgr.execute_async = reload_execute
- await model_mgr.reload_provider(fake_persistence_data['provider_uuid'])
+ await model_mgr.reload_provider(TEST_EXECUTION_CONTEXT, fake_persistence_data['provider_uuid'])
- updated_provider = model_mgr.provider_dict[fake_persistence_data['provider_uuid']]
+ updated_provider = await model_mgr.get_provider_by_uuid(
+ TEST_EXECUTION_CONTEXT,
+ fake_persistence_data['provider_uuid'],
+ )
assert updated_provider.provider_entity.base_url == 'https://updated.example.com'
assert updated_provider.provider_entity.base_url != original_base_url
@@ -667,7 +736,7 @@ async def test_model_manager_reload_provider_not_found(fake_requester_registry):
model_mgr.ap.persistence_mgr.execute_async = fake_execute
with pytest.raises(provider_errors.ProviderNotFoundError) as exc_info:
- await model_mgr.reload_provider('unknown-provider-uuid')
+ await model_mgr.reload_provider(TEST_EXECUTION_CONTEXT, 'unknown-provider-uuid')
assert exc_info.value.provider_name == 'unknown-provider-uuid'
@@ -686,7 +755,11 @@ async def test_model_manager_load_llm_model_with_provider(
model_entity = fake_persistence_data['llm_models'][0]
- runtime_model = await model_mgr.load_llm_model_with_provider(model_entity, runtime_provider)
+ runtime_model = await model_mgr.load_llm_model_with_provider(
+ TEST_EXECUTION_CONTEXT,
+ model_entity,
+ runtime_provider,
+ )
assert runtime_model.model_entity.uuid == model_entity.uuid
assert runtime_model.provider is runtime_provider
@@ -702,7 +775,11 @@ async def test_model_manager_load_llm_model_with_provider_from_row(
model_entity = fake_persistence_data['llm_models'][0]
row_mock = _make_row_mock(model_entity)
- runtime_model = await model_mgr.load_llm_model_with_provider(row_mock, runtime_provider)
+ runtime_model = await model_mgr.load_llm_model_with_provider(
+ TEST_EXECUTION_CONTEXT,
+ row_mock,
+ runtime_provider,
+ )
assert runtime_model.model_entity.uuid == model_entity.uuid
@@ -716,7 +793,11 @@ async def test_model_manager_load_embedding_model_with_provider(
model_entity = fake_persistence_data['embedding_models'][0]
- runtime_model = await model_mgr.load_embedding_model_with_provider(model_entity, runtime_provider)
+ runtime_model = await model_mgr.load_embedding_model_with_provider(
+ TEST_EXECUTION_CONTEXT,
+ model_entity,
+ runtime_provider,
+ )
assert runtime_model.model_entity.uuid == model_entity.uuid
assert runtime_model.provider is runtime_provider
@@ -735,6 +816,7 @@ async def test_model_manager_load_rerank_model_with_provider(fake_requester_regi
)
await requester_inst.initialize()
provider = requester.RuntimeProvider(
+ execution_context=TEST_EXECUTION_CONTEXT,
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
@@ -742,7 +824,11 @@ async def test_model_manager_load_rerank_model_with_provider(fake_requester_regi
model_entity = fake_persistence_data['rerank_models'][0]
- runtime_model = await model_mgr.load_rerank_model_with_provider(model_entity, provider)
+ runtime_model = await model_mgr.load_rerank_model_with_provider(
+ TEST_EXECUTION_CONTEXT,
+ model_entity,
+ provider,
+ )
assert runtime_model.model_entity.uuid == model_entity.uuid
assert runtime_model.provider is provider
@@ -766,6 +852,7 @@ async def test_model_manager_logs_warning_for_missing_provider(fake_requester_re
elif 'llm_models' in query_str:
# Return model with missing provider
fake_model = persistence_model.LLMModel(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='model-with-missing-provider',
name='MissingProviderModel',
provider_uuid='missing-provider-uuid',
@@ -779,7 +866,7 @@ async def test_model_manager_logs_warning_for_missing_provider(fake_requester_re
await model_mgr.initialize()
# Should have logged warning and skipped the model
- assert len(model_mgr.llm_models) == 0
+ assert len(model_mgr.llm_model_dict) == 0
model_mgr.ap.logger.warning.assert_called()
@@ -793,6 +880,7 @@ async def test_model_manager_handles_requester_not_found_gracefully(fake_request
if 'model_providers' in query_str:
# Return provider with unknown requester
fake_provider = persistence_model.ModelProvider(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='provider-with-unknown-requester',
name='Unknown Requester Provider',
requester='unknown-requester-name',
@@ -802,6 +890,7 @@ async def test_model_manager_handles_requester_not_found_gracefully(fake_request
return _make_mock_result([_make_row_mock(fake_provider)])
elif 'llm_models' in query_str:
fake_model = persistence_model.LLMModel(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='model-uuid',
name='Model',
provider_uuid='provider-with-unknown-requester',
@@ -816,7 +905,7 @@ async def test_model_manager_handles_requester_not_found_gracefully(fake_request
# Provider should be skipped
assert len(model_mgr.provider_dict) == 0
- assert len(model_mgr.llm_models) == 0
+ assert len(model_mgr.llm_model_dict) == 0
model_mgr.ap.logger.warning.assert_called()
@@ -833,6 +922,189 @@ def test_requester_not_found_error_str():
assert error.requester_name == 'test-requester'
+@pytest.mark.asyncio
+async def test_runtime_cache_isolates_same_resource_uuid_between_workspaces(fake_requester_registry):
+ """A UUID collision cannot select another Workspace's runtime object."""
+
+ model_mgr = fake_requester_registry
+ await model_mgr.initialize()
+ contexts = {
+ workspace_uuid: ExecutionContext(
+ instance_uuid='test-instance',
+ workspace_uuid=workspace_uuid,
+ placement_generation=1,
+ )
+ for workspace_uuid in ('workspace-a', 'workspace-b')
+ }
+
+ async def resolve_binding(workspace_uuid, *, expected_generation=None):
+ assert expected_generation in (None, 1)
+ return WorkspaceExecutionBinding(
+ instance_uuid='test-instance',
+ workspace_uuid=workspace_uuid,
+ placement_generation=1,
+ write_fenced=False,
+ state='active',
+ )
+
+ model_mgr.ap.workspace_service.get_execution_binding = AsyncMock(side_effect=resolve_binding)
+ for workspace_uuid, context in contexts.items():
+ provider = await model_mgr.load_provider(
+ context,
+ {
+ 'uuid': 'shared-provider',
+ 'name': f'Provider {workspace_uuid}',
+ 'requester': 'fake-requester',
+ 'base_url': f'https://{workspace_uuid}.example.com',
+ 'api_keys': [],
+ },
+ )
+ await model_mgr.cache_provider(context, provider)
+ runtime_model = await model_mgr.load_llm_model_with_provider(
+ context,
+ persistence_model.LLMModel(
+ workspace_uuid=workspace_uuid,
+ uuid='shared-model',
+ name=f'Model {workspace_uuid}',
+ provider_uuid='shared-provider',
+ abilities=[],
+ extra_args={},
+ ),
+ provider,
+ )
+ await model_mgr.cache_llm_model(context, runtime_model)
+
+ workspace_a_model = await model_mgr.get_model_by_uuid(contexts['workspace-a'], 'shared-model')
+ workspace_b_model = await model_mgr.get_model_by_uuid(contexts['workspace-b'], 'shared-model')
+
+ assert workspace_a_model.model_entity.name == 'Model workspace-a'
+ assert workspace_b_model.model_entity.name == 'Model workspace-b'
+ assert workspace_a_model is not workspace_b_model
+
+
+@pytest.mark.asyncio
+async def test_runtime_cache_rejects_stale_placement_generation(fake_requester_registry):
+ """A stale generation is fenced before any cached model can be returned."""
+
+ model_mgr = fake_requester_registry
+ await model_mgr.initialize()
+ stale_context = TEST_EXECUTION_CONTEXT
+
+ async def reject_stale(_workspace_uuid, *, expected_generation=None):
+ if expected_generation == stale_context.placement_generation:
+ raise WorkspaceGenerationMismatchError('stale generation')
+ raise AssertionError('lookup must include the supplied generation')
+
+ model_mgr.ap.workspace_service.get_execution_binding = AsyncMock(side_effect=reject_stale)
+
+ with pytest.raises(WorkspaceGenerationMismatchError, match='stale generation'):
+ await model_mgr.get_model_by_uuid(stale_context, 'any-model')
+
+
+def test_generation_advance_prunes_superseded_model_runtime_objects():
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('generation advance scanned every model runtime')
+
+ def items(self):
+ raise AssertionError('generation advance scanned every model runtime')
+
+ def keys(self):
+ raise AssertionError('generation advance scanned every model runtime')
+
+ model_mgr = ModelManager(Mock())
+ old_context = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ )
+ new_context = dataclasses.replace(old_context, placement_generation=2)
+ model_mgr._observe_execution_context(old_context)
+ for cache in (
+ model_mgr.provider_dict,
+ model_mgr.llm_model_dict,
+ model_mgr.embedding_model_dict,
+ model_mgr.rerank_model_dict,
+ ):
+ model_mgr._cache_set(
+ cache,
+ ('instance-a', 'workspace-a', 1, 'resource-a'),
+ object(),
+ )
+ model_mgr._cache_set(
+ cache,
+ ('instance-a', 'workspace-b', 1, 'resource-b'),
+ object(),
+ )
+
+ model_mgr.provider_dict = NoGlobalIterationDict(model_mgr.provider_dict)
+ model_mgr.llm_model_dict = NoGlobalIterationDict(model_mgr.llm_model_dict)
+ model_mgr.embedding_model_dict = NoGlobalIterationDict(model_mgr.embedding_model_dict)
+ model_mgr.rerank_model_dict = NoGlobalIterationDict(model_mgr.rerank_model_dict)
+
+ model_mgr._observe_execution_context(new_context)
+
+ for cache in (
+ model_mgr.provider_dict,
+ model_mgr.llm_model_dict,
+ model_mgr.embedding_model_dict,
+ model_mgr.rerank_model_dict,
+ ):
+ assert ('instance-a', 'workspace-a', 1, 'resource-a') not in cache
+ assert ('instance-a', 'workspace-b', 1, 'resource-b') in cache
+ with pytest.raises(WorkspaceInvariantError, match='rolled back'):
+ model_mgr._observe_execution_context(old_context)
+
+
+@pytest.mark.asyncio
+async def test_generation_advance_closes_retired_provider_requester(
+ fake_requester_registry,
+ runtime_provider,
+):
+ model_mgr = fake_requester_registry
+ runtime_provider.requester.aclose = AsyncMock()
+ await model_mgr.cache_provider(TEST_EXECUTION_CONTEXT, runtime_provider)
+
+ next_context = dataclasses.replace(
+ TEST_EXECUTION_CONTEXT,
+ placement_generation=2,
+ )
+ model_mgr.ap.workspace_service.get_execution_binding = AsyncMock(
+ return_value=WorkspaceExecutionBinding(
+ instance_uuid=next_context.instance_uuid,
+ workspace_uuid=next_context.workspace_uuid,
+ placement_generation=next_context.placement_generation,
+ write_fenced=False,
+ state='active',
+ )
+ )
+
+ await model_mgr.resolve_execution_context(next_context)
+
+ runtime_provider.requester.aclose.assert_awaited_once_with()
+ assert model_mgr.provider_dict == {}
+ assert model_mgr._scope_generations == {}
+
+
+@pytest.mark.asyncio
+async def test_model_manager_shutdown_closes_all_requesters_once(
+ fake_requester_registry,
+ runtime_provider,
+):
+ model_mgr = fake_requester_registry
+ runtime_provider.requester.aclose = AsyncMock()
+ await model_mgr.cache_provider(TEST_EXECUTION_CONTEXT, runtime_provider)
+
+ await model_mgr.shutdown()
+ await model_mgr.shutdown()
+
+ runtime_provider.requester.aclose.assert_awaited_once_with()
+ assert model_mgr.provider_dict == {}
+ assert model_mgr.llm_model_dict == {}
+ assert model_mgr.embedding_model_dict == {}
+ assert model_mgr.rerank_model_dict == {}
+
+
def test_provider_not_found_error_str():
"""Test ProviderNotFoundError string representation."""
error = provider_errors.ProviderNotFoundError('test-provider')
diff --git a/tests/unit_tests/provider/test_model_service.py b/tests/unit_tests/provider/test_model_service.py
index b4e1b3ca8..ba184de2b 100644
--- a/tests/unit_tests/provider/test_model_service.py
+++ b/tests/unit_tests/provider/test_model_service.py
@@ -19,6 +19,8 @@ from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.modelmgr.token import TokenManager
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
def test_runtime_llm_model_data_preserves_uuid_after_update_payload_uuid_removed():
@@ -95,11 +97,22 @@ async def test_model_manager_initialize_skips_space_sync_after_timeout():
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=[]))
ap.instance_config = SimpleNamespace(data={'space': {'models_sync_timeout': 0.01}})
ap.logger = Mock()
+ binding = WorkspaceExecutionBinding(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+ write_fenced=False,
+ state='active',
+ )
+ ap.workspace_service = SimpleNamespace(
+ get_local_execution_binding=AsyncMock(return_value=binding),
+ get_execution_binding=AsyncMock(return_value=binding),
+ )
mgr = ModelManager(ap)
mgr.load_models_from_db = AsyncMock()
- async def slow_sync():
+ async def slow_sync(_context):
await asyncio.sleep(1)
mgr.sync_new_models_from_space = AsyncMock(side_effect=slow_sync)
@@ -117,6 +130,14 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
model_uuid = 'qwen-model-uuid'
provider_uuid = 'ollama-provider-uuid'
+ workspace_uuid = 'workspace-test'
+ execution_context = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid=workspace_uuid,
+ placement_generation=1,
+ bot_uuid='bot-uuid',
+ pipeline_uuid='pipeline-uuid',
+ )
ap = SimpleNamespace()
ap.logger = Mock()
@@ -126,24 +147,63 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
ap.plugin_connector = SimpleNamespace(
emit_event=AsyncMock(return_value=SimpleNamespace(event=SimpleNamespace(default_prompt=[], prompt=[])))
)
+ binding = WorkspaceExecutionBinding(
+ instance_uuid='instance-test',
+ workspace_uuid=workspace_uuid,
+ placement_generation=1,
+ write_fenced=False,
+ state='active',
+ )
+ ap.workspace_service = SimpleNamespace(get_execution_binding=AsyncMock(return_value=binding))
ap.model_mgr = ModelManager(ap)
- runtime_provider = Mock()
- ap.model_mgr.provider_dict = {provider_uuid: runtime_provider}
- ap.model_mgr.llm_models = [
- requester.RuntimeLLMModel(
- model_entity=persistence_model.LLMModel(
- uuid=model_uuid,
- name='old-qwen-name',
- provider_uuid=provider_uuid,
- abilities=[],
- extra_args={},
- ),
- provider=runtime_provider,
- )
- ]
+ runtime_provider = Mock(
+ execution_context=execution_context,
+ provider_entity=persistence_model.ModelProvider(
+ workspace_uuid=workspace_uuid,
+ uuid=provider_uuid,
+ name='Ollama',
+ requester='ollama',
+ base_url='http://localhost:11434',
+ api_keys=[],
+ ),
+ )
+ cache_key = ('instance-test', workspace_uuid, 1, provider_uuid)
+ ap.model_mgr.provider_dict = {cache_key: runtime_provider}
+ runtime_model = requester.RuntimeLLMModel(
+ execution_context=execution_context,
+ model_entity=persistence_model.LLMModel(
+ workspace_uuid=workspace_uuid,
+ uuid=model_uuid,
+ name='old-qwen-name',
+ provider_uuid=provider_uuid,
+ abilities=[],
+ extra_args={},
+ ),
+ provider=runtime_provider,
+ )
+ ap.model_mgr.llm_model_dict = {
+ ('instance-test', workspace_uuid, 1, model_uuid): runtime_model,
+ }
- await LLMModelsService(ap).update_llm_model(
+ ap.provider_service = SimpleNamespace(
+ get_provider=AsyncMock(return_value={'uuid': provider_uuid, 'workspace_uuid': workspace_uuid})
+ )
+ model_service = LLMModelsService(ap)
+ model_service.get_llm_model = AsyncMock(
+ return_value={
+ 'uuid': model_uuid,
+ 'workspace_uuid': workspace_uuid,
+ 'name': 'old-qwen-name',
+ 'provider_uuid': provider_uuid,
+ 'abilities': [],
+ 'context_length': None,
+ 'extra_args': {},
+ 'prefered_ranking': 0,
+ }
+ )
+ await model_service.update_llm_model(
+ workspace_uuid,
model_uuid,
{
'name': 'Qwen3.5-27B',
@@ -153,13 +213,17 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
},
)
- runtime_model = await ap.model_mgr.get_model_by_uuid(model_uuid)
+ runtime_model = await ap.model_mgr.get_model_by_uuid(execution_context, model_uuid)
assert runtime_model.model_entity.uuid == model_uuid
assert runtime_model.model_entity.name == 'Qwen3.5-27B'
- session = SimpleNamespace(
+ session = provider_session.Session(
+ instance_uuid='instance-test',
+ workspace_uuid=workspace_uuid,
+ placement_generation=1,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
+ bot_uuid='bot-uuid',
)
conversation = SimpleNamespace(
uuid='conversation-uuid',
@@ -194,6 +258,9 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
'output': {'misc': {'remove-think': False}},
}
query = pipeline_query.Query.model_construct(
+ instance_uuid='instance-test',
+ workspace_uuid=workspace_uuid,
+ placement_generation=1,
query_id='query-id',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
@@ -215,6 +282,7 @@ async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline()
resp_message_chain=None,
current_stage_name=None,
)
+ object.__setattr__(query, '_execution_context', execution_context)
result = await PreProcessor(ap).process(query, 'PreProcessor')
processed_query = result.new_query
diff --git a/tests/unit_tests/provider/test_requester_base.py b/tests/unit_tests/provider/test_requester_base.py
index 71c0da653..672c930be 100644
--- a/tests/unit_tests/provider/test_requester_base.py
+++ b/tests/unit_tests/provider/test_requester_base.py
@@ -15,6 +15,7 @@ from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.provider.modelmgr import token
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.provider.modelmgr.errors import RequesterError
+from tests.unit_tests.provider.conftest import TEST_EXECUTION_CONTEXT, TEST_WORKSPACE_UUID
# ============================================================================
@@ -134,6 +135,7 @@ async def test_requester_invoke_rerank_not_implemented():
# Create fake model
fake_provider_entity = persistence_model.ModelProvider(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='provider-uuid',
name='Provider',
requester='test',
@@ -143,17 +145,20 @@ async def test_requester_invoke_rerank_not_implemented():
fake_token_mgr = token.TokenManager(name='test', tokens=[])
fake_requester = inst
fake_provider = requester.RuntimeProvider(
+ execution_context=TEST_EXECUTION_CONTEXT,
provider_entity=fake_provider_entity,
token_mgr=fake_token_mgr,
requester=fake_requester,
)
fake_model_entity = persistence_model.RerankModel(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='model-uuid',
name='Model',
provider_uuid='provider-uuid',
extra_args={},
)
fake_model = requester.RuntimeRerankModel(
+ execution_context=TEST_EXECUTION_CONTEXT,
model_entity=fake_model_entity,
provider=fake_provider,
)
@@ -289,6 +294,7 @@ async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_l
resp_message_chain=None,
current_stage_name=None,
)
+ object.__setattr__(query, '_execution_context', TEST_EXECUTION_CONTEXT)
messages = [
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
@@ -332,6 +338,7 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider
resp_message_chain=None,
current_stage_name=None,
)
+ object.__setattr__(query, '_execution_context', TEST_EXECUTION_CONTEXT)
messages = [
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
@@ -350,7 +357,11 @@ async def test_runtime_provider_invoke_embedding_returns_vectors(runtime_provide
"""Test RuntimeProvider.invoke_embedding returns embedding vectors."""
provider = runtime_provider
- result = await provider.invoke_embedding(runtime_embedding_model, ['text1', 'text2'])
+ result = await provider.invoke_embedding(
+ runtime_embedding_model,
+ ['text1', 'text2'],
+ execution_context=TEST_EXECUTION_CONTEXT,
+ )
assert len(result) == 2
assert result[0] == [0.1, 0.2, 0.3]
@@ -362,7 +373,12 @@ async def test_runtime_provider_invoke_rerank_returns_scores(runtime_provider, r
# Need to use the correct provider for rerank model
provider = runtime_rerank_model.provider
- result = await provider.invoke_rerank(runtime_rerank_model, 'query', ['doc1', 'doc2', 'doc3'])
+ result = await provider.invoke_rerank(
+ runtime_rerank_model,
+ 'query',
+ ['doc1', 'doc2', 'doc3'],
+ execution_context=TEST_EXECUTION_CONTEXT,
+ )
assert len(result) == 3
assert result[0]['index'] == 0
@@ -532,6 +548,7 @@ async def test_runtime_provider_invoke_llm_propagates_error(mock_app_for_modelmg
await requester_inst.initialize()
provider_entity = persistence_model.ModelProvider(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='error-provider',
name='Error Provider',
requester='error-requester',
@@ -541,19 +558,25 @@ async def test_runtime_provider_invoke_llm_propagates_error(mock_app_for_modelmg
token_mgr = token.TokenManager(name='error-provider', tokens=['error-key'])
provider = requester.RuntimeProvider(
+ execution_context=TEST_EXECUTION_CONTEXT,
provider_entity=provider_entity,
token_mgr=token_mgr,
requester=requester_inst,
)
model_entity = persistence_model.LLMModel(
+ workspace_uuid=TEST_WORKSPACE_UUID,
uuid='error-model',
name='Error Model',
provider_uuid='error-provider',
abilities=[],
extra_args={},
)
- model = requester.RuntimeLLMModel(model_entity=model_entity, provider=provider)
+ model = requester.RuntimeLLMModel(
+ execution_context=TEST_EXECUTION_CONTEXT,
+ model_entity=model_entity,
+ provider=provider,
+ )
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -580,6 +603,7 @@ async def test_runtime_provider_invoke_llm_propagates_error(mock_app_for_modelmg
resp_message_chain=None,
current_stage_name=None,
)
+ object.__setattr__(query, '_execution_context', TEST_EXECUTION_CONTEXT)
messages = [
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
diff --git a/tests/unit_tests/provider/test_session_manager.py b/tests/unit_tests/provider/test_session_manager.py
index eca8cac8a..469743c5b 100644
--- a/tests/unit_tests/provider/test_session_manager.py
+++ b/tests/unit_tests/provider/test_session_manager.py
@@ -10,11 +10,74 @@ from __future__ import annotations
import pytest
import asyncio
+from types import SimpleNamespace
from unittest.mock import Mock
from importlib import import_module
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+import langbot_plugin.api.entities.builtin.provider.message as provider_message
+import langbot_plugin.api.entities.builtin.provider.prompt as provider_prompt
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.pipeline.pool import (
+ ExecutionContextMismatchError,
+ ExecutionContextRequiredError,
+)
+
+
+TEST_CONTEXT = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid='workspace-test',
+ placement_generation=1,
+)
+TEST_BOT_UUID = 'bot-123'
+
+
+def bind_query_context(query, *, context=TEST_CONTEXT, bot_uuid=TEST_BOT_UUID):
+ """Attach the trusted runtime scope expected by the session manager."""
+ query.bot_uuid = bot_uuid
+ query._execution_context = context
+ return query
+
+
+def bind_session_context(session, query):
+ """Make a mocked legacy Session belong to the Query execution scope."""
+ session.bot_uuid = query.bot_uuid
+ session._langbot_session_key = (
+ TEST_CONTEXT.instance_uuid,
+ TEST_CONTEXT.workspace_uuid,
+ TEST_CONTEXT.placement_generation,
+ query.bot_uuid,
+ query.launcher_type.value,
+ query.launcher_id,
+ )
+ return session
+
+
+def scoped_query(
+ *,
+ workspace_uuid='workspace-test',
+ bot_uuid=TEST_BOT_UUID,
+ placement_generation=1,
+ pipeline_uuid=None,
+):
+ """Create a small Query-like object with a complete trusted scope."""
+ context = ExecutionContext(
+ instance_uuid='instance-test',
+ workspace_uuid=workspace_uuid,
+ placement_generation=placement_generation,
+ bot_uuid=bot_uuid,
+ pipeline_uuid=pipeline_uuid,
+ query_uuid=f'query-{workspace_uuid}-{bot_uuid}',
+ )
+ return SimpleNamespace(
+ launcher_type=provider_session.LauncherTypes.PERSON,
+ launcher_id='same-launcher',
+ sender_id='same-sender',
+ bot_uuid=bot_uuid,
+ _execution_context=context,
+ )
def get_session_module():
@@ -71,7 +134,7 @@ class TestSessionManagerGetSession:
query.launcher_type = provider_session.LauncherTypes.PERSON
query.launcher_id = '12345'
query.sender_id = '12345'
- return query
+ return bind_query_context(query)
@pytest.mark.asyncio
async def test_creates_new_session_when_not_found(self, mock_app_with_config, sample_query):
@@ -126,11 +189,13 @@ class TestSessionManagerGetSession:
query1.launcher_type = provider_session.LauncherTypes.PERSON
query1.launcher_id = 'user1'
query1.sender_id = 'user1'
+ bind_query_context(query1)
query2 = Mock(spec=pipeline_query.Query)
query2.launcher_type = provider_session.LauncherTypes.PERSON
query2.launcher_id = 'user2'
query2.sender_id = 'user2'
+ bind_query_context(query2)
session1 = await manager.get_session(query1)
session2 = await manager.get_session(query2)
@@ -149,11 +214,13 @@ class TestSessionManagerGetSession:
query1.launcher_type = provider_session.LauncherTypes.PERSON
query1.launcher_id = 'same_id'
query1.sender_id = 'same_id'
+ bind_query_context(query1)
query2 = Mock(spec=pipeline_query.Query)
query2.launcher_type = provider_session.LauncherTypes.GROUP
query2.launcher_id = 'same_id'
query2.sender_id = 'same_id'
+ bind_query_context(query2)
session1 = await manager.get_session(query1)
session2 = await manager.get_session(query2)
@@ -191,7 +258,7 @@ class TestSessionManagerGetConversation:
query.launcher_type = provider_session.LauncherTypes.PERSON
query.launcher_id = '12345'
query.sender_id = '12345'
- return query
+ return bind_query_context(query)
@pytest.mark.asyncio
async def test_creates_conversation_with_prompt(self, mock_app_with_config, sample_query, sample_session):
@@ -199,6 +266,7 @@ class TestSessionManagerGetConversation:
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
+ bind_session_context(sample_session, sample_query)
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
pipeline_uuid = 'pipeline-123'
@@ -222,6 +290,7 @@ class TestSessionManagerGetConversation:
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
+ bind_session_context(sample_session, sample_query)
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
pipeline_uuid = 'pipeline-123'
@@ -244,14 +313,15 @@ class TestSessionManagerGetConversation:
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
+ bind_session_context(sample_session, sample_query)
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
# First call with pipeline1
- conv1 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-1', 'bot-1')
+ conv1 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-1', TEST_BOT_UUID)
# Second call with different pipeline should create new conversation
- conv2 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-2', 'bot-2')
+ conv2 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-2', TEST_BOT_UUID)
assert conv1 is not conv2
assert len(sample_session.conversations) == 2
@@ -263,6 +333,7 @@ class TestSessionManagerGetConversation:
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
+ bind_session_context(sample_session, sample_query)
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
@@ -278,6 +349,7 @@ class TestSessionManagerGetConversation:
sessionmgr = get_session_module()
manager = sessionmgr.SessionManager(mock_app_with_config)
+ bind_session_context(sample_session, sample_query)
prompt_config = [{'role': 'system', 'content': 'System message'}, {'role': 'user', 'content': 'User message'}]
@@ -287,3 +359,200 @@ class TestSessionManagerGetConversation:
assert conversation.prompt.name == 'default'
assert len(conversation.prompt.messages) == 2
+
+
+class TestSessionManagerWorkspaceIsolation:
+ """Regression coverage for workspace, bot, and placement fencing."""
+
+ @staticmethod
+ def manager():
+ mock_app = Mock()
+ mock_app.instance_config.data = {'concurrency': {'session': 5}}
+ return get_session_module().SessionManager(mock_app)
+
+ @pytest.mark.asyncio
+ async def test_get_session_requires_trusted_query_scope(self):
+ query = SimpleNamespace(
+ launcher_type=provider_session.LauncherTypes.PERSON,
+ launcher_id='same-launcher',
+ sender_id='same-sender',
+ bot_uuid=TEST_BOT_UUID,
+ )
+
+ with pytest.raises(ExecutionContextRequiredError):
+ await self.manager().get_session(query)
+
+ @pytest.mark.asyncio
+ async def test_same_launcher_in_two_workspaces_does_not_share_session(self):
+ manager = self.manager()
+
+ first = await manager.get_session(scoped_query(workspace_uuid='workspace-a'))
+ second = await manager.get_session(scoped_query(workspace_uuid='workspace-b'))
+
+ assert first is not second
+ assert len(manager.session_list) == 2
+
+ @pytest.mark.asyncio
+ async def test_same_launcher_in_two_bots_does_not_share_session(self):
+ manager = self.manager()
+
+ first = await manager.get_session(scoped_query(bot_uuid='bot-a'))
+ second = await manager.get_session(scoped_query(bot_uuid='bot-b'))
+
+ assert first is not second
+
+ @pytest.mark.asyncio
+ async def test_new_placement_generation_does_not_reuse_old_session(self):
+ manager = self.manager()
+
+ first = await manager.get_session(scoped_query(placement_generation=1))
+ second = await manager.get_session(scoped_query(placement_generation=2))
+
+ assert first is not second
+
+ @pytest.mark.asyncio
+ async def test_conversation_rejects_session_from_another_workspace(self):
+ manager = self.manager()
+ query_a = scoped_query(workspace_uuid='workspace-a')
+ query_b = scoped_query(workspace_uuid='workspace-b')
+ session_a = await manager.get_session(query_a)
+
+ with pytest.raises(ExecutionContextMismatchError):
+ await manager.get_conversation(query_b, session_a, [], 'pipeline-1', TEST_BOT_UUID)
+
+ @pytest.mark.asyncio
+ async def test_conversation_rejects_substituted_bot_argument(self):
+ manager = self.manager()
+ query = scoped_query(bot_uuid='bot-a')
+ session = await manager.get_session(query)
+
+ with pytest.raises(ExecutionContextMismatchError):
+ await manager.get_conversation(query, session, [], 'pipeline-1', 'bot-b')
+
+ @pytest.mark.asyncio
+ async def test_per_workspace_capacity_evicts_oldest_idle_session(self):
+ manager = self.manager()
+ manager.ap.instance_config.data['system'] = {
+ 'session_retention': {
+ 'max_entries': 10,
+ 'max_entries_per_workspace': 2,
+ }
+ }
+ queries = []
+ sessions = []
+ for index in range(3):
+ query = scoped_query()
+ query.launcher_id = f'launcher-{index}'
+ queries.append(query)
+ sessions.append(await manager.get_session(query))
+
+ assert len(manager.session_list) == 2
+ assert sessions[0] not in manager.session_list
+ assert sessions[1:] == manager.session_list
+ assert await manager.get_session(queries[-1]) is manager.session_list[-1]
+
+ @pytest.mark.asyncio
+ async def test_new_session_does_not_scan_other_workspace_sessions(self):
+ manager = self.manager()
+ manager.ap.instance_config.data['system'] = {
+ 'session_retention': {
+ 'max_entries': 600,
+ 'max_entries_per_workspace': 2,
+ }
+ }
+ for index in range(512):
+ query = scoped_query(workspace_uuid=f'workspace-{index}')
+ query.launcher_id = f'launcher-{index}'
+ await manager.get_session(query)
+
+ class NoGlobalIterationDict(dict):
+ def __iter__(self):
+ raise AssertionError('global session index iteration is forbidden')
+
+ def items(self):
+ raise AssertionError('global session index iteration is forbidden')
+
+ def values(self):
+ raise AssertionError('global session index iteration is forbidden')
+
+ manager._session_index = NoGlobalIterationDict(manager._session_index)
+ query = scoped_query(workspace_uuid='workspace-new')
+ query.launcher_id = 'launcher-new'
+
+ session = await manager.get_session(query)
+
+ assert session.workspace_uuid == 'workspace-new'
+ assert len(manager._session_index) == 513
+
+ @pytest.mark.asyncio
+ async def test_stale_expiry_revision_does_not_evict_recent_session(
+ self,
+ monkeypatch,
+ ):
+ sessionmgr = get_session_module()
+ manager = self.manager()
+ manager.ap.instance_config.data['system'] = {
+ 'session_retention': {
+ 'max_entries': 10,
+ 'max_entries_per_workspace': 10,
+ 'idle_ttl_seconds': 1,
+ }
+ }
+ clock = [0.0]
+ monkeypatch.setattr(sessionmgr.time, 'monotonic', lambda: clock[0])
+ first_query = scoped_query()
+ first_query.launcher_id = 'first'
+ first = await manager.get_session(first_query)
+
+ clock[0] = 0.5
+ assert await manager.get_session(first_query) is first
+
+ clock[0] = 1.25
+ second_query = scoped_query()
+ second_query.launcher_id = 'second'
+ await manager.get_session(second_query)
+ assert first in manager.session_list
+
+ clock[0] = 2.0
+ third_query = scoped_query()
+ third_query.launcher_id = 'third'
+ await manager.get_session(third_query)
+ assert first not in manager.session_list
+
+ @pytest.mark.asyncio
+ async def test_access_revision_heap_stays_bounded(self):
+ manager = self.manager()
+ query = scoped_query()
+ await manager.get_session(query)
+
+ for _ in range(1000):
+ await manager.get_session(query)
+
+ assert len(manager._session_expiry_heap) <= 64
+
+ def test_trim_conversation_drops_retained_binary_payloads(self):
+ manager = self.manager()
+ conversation = provider_session.Conversation(
+ prompt=provider_prompt.Prompt(name='test', messages=[]),
+ messages=[
+ provider_message.Message(
+ role='user',
+ content=[
+ provider_message.ContentElement.from_text('hello'),
+ provider_message.ContentElement.from_image_base64('x' * 1000000),
+ provider_message.ContentElement.from_file_base64(
+ 'y' * 1000000,
+ 'large.bin',
+ ),
+ ],
+ )
+ ],
+ pipeline_uuid='pipeline-1',
+ bot_uuid=TEST_BOT_UUID,
+ )
+
+ manager.trim_conversation_messages(conversation, max_rounds=10)
+
+ content = conversation.messages[0].content
+ assert content[1].image_base64 is None
+ assert content[2].file_base64 is None
diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py
index 405f11d17..e96156496 100644
--- a/tests/unit_tests/provider/test_skill_tools.py
+++ b/tests/unit_tests/provider/test_skill_tools.py
@@ -7,6 +7,37 @@ from unittest.mock import AsyncMock, Mock
import pytest
+from langbot.pkg.api.http.context import ExecutionContext
+
+
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ query_uuid='query-a',
+)
+
+
+def _make_query(*, variables=None, **kwargs):
+ return SimpleNamespace(
+ query_id=kwargs.pop('query_id', 'query-a'),
+ query_uuid=kwargs.pop('query_uuid', 'query-a'),
+ instance_uuid=kwargs.pop('instance_uuid', _CONTEXT.instance_uuid),
+ workspace_uuid=kwargs.pop('workspace_uuid', _CONTEXT.workspace_uuid),
+ placement_generation=kwargs.pop('placement_generation', _CONTEXT.placement_generation),
+ variables={} if variables is None else variables,
+ **kwargs,
+ )
+
+
+def _make_skill_manager(skills: dict[str, dict], **kwargs):
+ return SimpleNamespace(
+ skills=skills,
+ get_skills=Mock(return_value=skills),
+ get_skill_by_name=Mock(side_effect=lambda _context, name: skills.get(name)),
+ **kwargs,
+ )
+
def _make_ap(logger=None):
ap = SimpleNamespace()
@@ -51,14 +82,14 @@ class TestSkillManagerCache:
mgr = SkillManager(ap)
# Empty cache → returns False
- assert mgr.refresh_skill_from_disk('test-skill') is False
+ assert mgr.refresh_skill_from_disk(_CONTEXT, 'test-skill') is False
# Cache populated → returns True; method does NOT mutate the cache
cached = _make_skill_data(name='test-skill', instructions='Cached')
- mgr.skills['test-skill'] = cached
- assert mgr.refresh_skill_from_disk('test-skill') is True
- assert mgr.skills['test-skill'] is cached
- assert mgr.refresh_skill_from_disk('') is False
+ mgr._skills_by_scope[mgr._scope_key(_CONTEXT)] = {'test-skill': cached}
+ assert mgr.refresh_skill_from_disk(_CONTEXT, 'test-skill') is True
+ assert mgr.get_skills(_CONTEXT)['test-skill'] is cached
+ assert mgr.refresh_skill_from_disk(_CONTEXT, '') is False
@pytest.mark.asyncio
async def test_reload_skills_drops_box_skills_with_missing_package_root(self):
@@ -85,9 +116,9 @@ class TestSkillManagerCache:
ap.box_service = box_service
mgr = SkillManager(ap)
- await mgr.reload_skills()
+ await mgr.reload_skills(_CONTEXT)
- assert list(mgr.skills) == ['alive']
+ assert list(mgr.get_skills(_CONTEXT)) == ['alive']
# Warning fired with the dropped skill name so operators can see it.
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
assert any('ghost' in msg and 'package_root missing' in msg for msg in warning_messages)
@@ -116,9 +147,9 @@ class TestSkillManagerCache:
ap.box_service = box_service
mgr = SkillManager(ap)
- await mgr.reload_skills()
+ await mgr.reload_skills(_CONTEXT)
- assert sorted(mgr.skills) == ['alpha', 'beta']
+ assert sorted(mgr.get_skills(_CONTEXT)) == ['alpha', 'beta']
# No skill dropped → no "package_root missing" warning.
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
assert not any('package_root missing' in msg for msg in warning_messages)
@@ -141,12 +172,12 @@ class TestSkillActivationHelper:
ap = _make_ap()
mgr = SkillManager(ap)
- mgr.skills = {
+ mgr._skills_by_scope[mgr._scope_key(_CONTEXT)] = {
'primary': _make_skill_data(name='primary', instructions='Primary instructions'),
}
ap.skill_mgr = mgr
- query = SimpleNamespace(variables={})
+ query = _make_query()
assert register_activated_skill(ap, query, 'primary') is True
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'primary'}
@@ -159,10 +190,10 @@ class TestSkillActivationHelper:
ap = _make_ap()
mgr = SkillManager(ap)
- mgr.skills = {'primary': _make_skill_data(name='primary')}
+ mgr._skills_by_scope[mgr._scope_key(_CONTEXT)] = {'primary': _make_skill_data(name='primary')}
ap.skill_mgr = mgr
- query = SimpleNamespace(variables={})
+ query = _make_query()
assert register_activated_skill(ap, query, 'missing') is False
assert ACTIVATED_SKILLS_KEY not in query.variables
@@ -171,7 +202,7 @@ class TestSkillActivationHelper:
from langbot.pkg.skill.activation import register_activated_skill
ap = _make_ap() # no skill_mgr attribute
- query = SimpleNamespace(variables={})
+ query = _make_query()
assert register_activated_skill(ap, query, 'primary') is False
@@ -181,13 +212,13 @@ class TestSkillPathHelpers:
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, get_visible_skills
ap = _make_ap()
- ap.skill_mgr = SimpleNamespace(
- skills={
+ ap.skill_mgr = _make_skill_manager(
+ {
'visible': _make_skill_data(name='visible'),
'hidden': _make_skill_data(name='hidden'),
}
)
- query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
+ query = _make_query(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
result = get_visible_skills(ap, query)
@@ -202,13 +233,13 @@ class TestSkillPathHelpers:
)
ap = _make_ap()
- ap.skill_mgr = SimpleNamespace(
- skills={
+ ap.skill_mgr = _make_skill_manager(
+ {
'visible': _make_skill_data(name='visible'),
'hidden': _make_skill_data(name='hidden'),
}
)
- query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
+ query = _make_query(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
restored = restore_activated_skills(ap, query, ['visible', 'hidden', 'visible', ''])
@@ -223,8 +254,8 @@ class TestSkillPathHelpers:
)
ap = _make_ap()
- ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
- query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
+ ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
+ query = _make_query(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
skill, rewritten = resolve_virtual_skill_path(
ap,
@@ -273,6 +304,22 @@ class TestSkillPathHelpers:
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
assert command.rstrip().endswith('python scripts/run.py')
+ def test_wrap_skill_python_env_keeps_state_outside_read_only_source(self):
+ from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env
+
+ command = wrap_skill_command_with_python_env(
+ 'python scripts/run.py',
+ mount_path='/workspace/.skills/demo',
+ state_path='/workspace/.skill-envs/demo',
+ )
+
+ assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in command
+ assert '_LB_META_DIR="/workspace/.skill-envs/demo/.langbot"' in command
+ assert '_LB_TMP_DIR="/workspace/.skill-envs/demo/.tmp"' in command
+ assert '_LB_PIP_CACHE_DIR="/workspace/.skill-envs/demo/.cache/pip"' in command
+ assert 'root = "/workspace/.skills/demo"' in command
+ assert 'pip install "/workspace/.skills/demo"' in command
+
class TestSkillToolLoader:
"""The skill tool surface is now just ``activate`` + ``register_skill``.
@@ -292,13 +339,10 @@ class TestSkillToolLoader:
skill = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Step 1')
ap = _make_ap()
- ap.skill_mgr = SimpleNamespace(
- skills={'demo': skill},
- get_skill_by_name=lambda name: skill if name == 'demo' else None,
- )
+ ap.skill_mgr = _make_skill_manager({'demo': skill})
loader = SkillToolLoader(ap)
- query = SimpleNamespace(variables={})
+ query = _make_query()
result = await loader.invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'demo'}, query)
@@ -317,10 +361,7 @@ class TestSkillToolLoader:
)
ap = _make_ap()
- ap.skill_mgr = SimpleNamespace(
- skills={'demo': _make_skill_data(name='demo')},
- get_skill_by_name=lambda name: None,
- )
+ ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
loader = SkillToolLoader(ap)
@@ -328,7 +369,7 @@ class TestSkillToolLoader:
await loader.invoke_tool(
ACTIVATE_SKILL_TOOL_NAME,
{'skill_name': 'ghost'},
- SimpleNamespace(variables={}),
+ _make_query(),
)
@pytest.mark.asyncio
@@ -362,18 +403,19 @@ class TestSkillToolLoader:
result = await loader.invoke_tool(
REGISTER_SKILL_TOOL_NAME,
{'path': '/workspace/repo'},
- SimpleNamespace(),
+ _make_query(),
)
- ap.skill_service.scan_directory_async.assert_awaited_once_with(os.path.realpath(repo_dir))
+ ap.skill_service.scan_directory_async.assert_awaited_once_with(_CONTEXT, os.path.realpath(repo_dir))
ap.skill_service.create_skill.assert_awaited_once_with(
+ _CONTEXT,
{
'name': 'cloned-skill',
'display_name': 'Cloned Skill',
'description': 'Imported from clone',
'instructions': 'Do work',
'package_root': os.path.realpath(repo_dir),
- }
+ },
)
assert result['registered'] is True
assert result['skill_name'] == 'cloned-skill'
@@ -397,7 +439,7 @@ class TestSkillToolLoader:
await loader.invoke_tool(
REGISTER_SKILL_TOOL_NAME,
{'path': '/workspace/../../etc'},
- SimpleNamespace(),
+ _make_query(),
)
@pytest.mark.asyncio
@@ -417,7 +459,7 @@ class TestSkillToolLoader:
await loader.invoke_tool(
REGISTER_SKILL_TOOL_NAME,
{'path': '/workspace/foo'},
- SimpleNamespace(),
+ _make_query(),
)
@pytest.mark.asyncio
@@ -428,7 +470,7 @@ class TestSkillToolLoader:
ap.skill_mgr = SimpleNamespace(skills={})
ap.box_service = SimpleNamespace(
available=True,
- get_status=AsyncMock(return_value={'backend': {'available': False}}),
+ get_backend_status=AsyncMock(return_value={'backend': {'available': False}}),
)
loader = SkillToolLoader(ap)
@@ -443,10 +485,10 @@ class TestSkillToolLoader:
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
ap = _make_ap()
- ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
+ ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace(
available=True,
- get_status=AsyncMock(return_value={'backend': {'available': True}}),
+ get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = SkillToolLoader(ap)
@@ -466,7 +508,7 @@ class TestSkillToolLoader:
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
ap.box_service = SimpleNamespace(
available=False,
- get_status=AsyncMock(return_value={'backend': {'available': True}}),
+ get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = SkillToolLoader(ap)
@@ -490,20 +532,88 @@ class TestNativeToolLoaderSkillPaths:
f.write('demo instructions')
ap = _make_ap()
- ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
- ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
+ ap.box_service = SimpleNamespace(
+ available=True,
+ default_workspace=tmpdir,
+ shares_filesystem_with_box=True,
+ )
+ ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
result = await loader.invoke_tool(
'read',
{'path': '/workspace/.skills/demo/SKILL.md'},
- SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}),
+ _make_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}),
)
assert result['ok'] is True
assert result['content'] == 'demo instructions'
assert result['truncated'] is False
+ @pytest.mark.asyncio
+ async def test_external_runtime_read_never_interprets_package_root_on_core_host(self):
+ from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
+ from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(os.path.join(tmpdir, 'SKILL.md'), 'w', encoding='utf-8') as file_obj:
+ file_obj.write('core-host-secret')
+
+ ap = _make_ap()
+ ap.box_service = SimpleNamespace(
+ available=True,
+ shares_filesystem_with_box=False,
+ read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}),
+ )
+ ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
+ loader = NativeToolLoader(ap)
+ query = _make_query(
+ query_id='q-external-read',
+ variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
+ )
+
+ result = await loader.invoke_tool(
+ 'read',
+ {'path': '/workspace/.skills/demo/SKILL.md'},
+ query,
+ )
+
+ assert result['ok'] is True
+ assert result['content'] == 'runtime-owned-content'
+ assert 'core-host-secret' not in repr(result)
+ ap.box_service.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
+
+ @pytest.mark.asyncio
+ async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self):
+ from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
+ from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with open(os.path.join(tmpdir, 'secret.txt'), 'w', encoding='utf-8') as file_obj:
+ file_obj.write('core-host-secret')
+
+ ap = _make_ap()
+ ap.box_service = SimpleNamespace(
+ available=True,
+ shares_filesystem_with_box=False,
+ )
+ ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
+ loader = NativeToolLoader(ap)
+ query = _make_query(
+ query_id='q-external-no-protocol',
+ variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
+ )
+
+ with pytest.raises(ValueError, match='owned by the Box Runtime'):
+ await loader.invoke_tool(
+ 'grep',
+ {
+ 'path': '/workspace/.skills/demo',
+ 'pattern': 'core-host-secret',
+ },
+ query,
+ )
+
@pytest.mark.asyncio
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
@@ -519,7 +629,7 @@ class TestNativeToolLoaderSkillPaths:
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
loader = NativeToolLoader(ap)
- query = SimpleNamespace(query_id='q1', launcher_type='person', launcher_id='123', variables={})
+ query = _make_query(query_id='q1', launcher_type='person', launcher_id='123')
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir))
result = await loader.invoke_tool(
@@ -535,7 +645,48 @@ class TestNativeToolLoaderSkillPaths:
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
- ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
+ assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
+ ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo')
+
+ @pytest.mark.asyncio
+ async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
+ from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
+ from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
+
+ ap = _make_ap()
+ ap.box_service = SimpleNamespace(
+ available=True,
+ shares_filesystem_with_box=False,
+ execute_tool=AsyncMock(return_value={'ok': True}),
+ )
+ ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
+ loader = NativeToolLoader(ap)
+ query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
+ register_activated_skill(
+ query,
+ _make_skill_data(
+ name='demo',
+ package_root='/box-runtime/skills/tenants/workspace/demo',
+ python_project=True,
+ ),
+ )
+
+ result = await loader.invoke_tool(
+ 'exec',
+ {
+ 'command': 'python /workspace/.skills/demo/scripts/run.py',
+ 'workdir': '/workspace/.skills/demo',
+ },
+ query,
+ )
+
+ assert result['ok'] is True
+ tool_parameters = ap.box_service.execute_tool.await_args.args[0]
+ wrapped = tool_parameters['command']
+ assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
+ assert 'root = "/workspace/.skills/demo"' in wrapped
+ assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
+ assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
@pytest.mark.asyncio
async def test_write_requires_skill_activation(self):
@@ -545,10 +696,10 @@ class TestNativeToolLoaderSkillPaths:
with tempfile.TemporaryDirectory() as tmpdir:
ap = _make_ap()
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
- ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
+ ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
- query = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
+ query = _make_query(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
with pytest.raises(ValueError, match='Skill "demo" is not available at this path'):
await loader.invoke_tool(
diff --git a/tests/unit_tests/provider/test_tool_manager.py b/tests/unit_tests/provider/test_tool_manager.py
index 0ae33115c..b61fc9d0f 100644
--- a/tests/unit_tests/provider/test_tool_manager.py
+++ b/tests/unit_tests/provider/test_tool_manager.py
@@ -14,6 +14,15 @@ from importlib import import_module
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from langbot.pkg.api.http.context import ExecutionContext
+
+
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+
def get_toolmgr_module():
"""Lazy import to avoid circular import issues."""
@@ -188,6 +197,10 @@ class TestToolManagerExecuteFuncCall:
def sample_query(self):
"""Create sample query for testing."""
query = Mock(spec=pipeline_query.Query)
+ query.bot_uuid = None
+ query.pipeline_uuid = None
+ query.query_uuid = None
+ query._execution_context = _CONTEXT
return query
@pytest.mark.asyncio
diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py
index d73c66e4f..ae50e3935 100644
--- a/tests/unit_tests/provider/test_tool_manager_native.py
+++ b/tests/unit_tests/provider/test_tool_manager_native.py
@@ -1,8 +1,10 @@
from __future__ import annotations
import base64
+import contextlib
import os
import tempfile
+import threading
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -11,7 +13,16 @@ import pytest
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
+from langbot.pkg.provider.tools.loaders import native as native_loader
from langbot.pkg.provider.tools.toolmgr import ToolManager
+from langbot.pkg.api.http.context import ExecutionContext
+
+
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
class StubLoader:
@@ -44,7 +55,8 @@ class StubLoader:
for tool in self._tools
]
- async def has_tool(self, name: str) -> bool:
+ async def has_tool(self, *args) -> bool:
+ name = args[-1]
return any(tool.name == name for tool in self._tools)
async def invoke_tool(self, name: str, parameters: dict, query):
@@ -72,7 +84,7 @@ async def test_tool_manager_omits_skill_authoring_tools_by_default():
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
- tools = await manager.get_all_tools()
+ tools = await manager.get_all_tools(_CONTEXT)
assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool']
@@ -85,7 +97,7 @@ async def test_tool_manager_includes_skill_authoring_tools_when_requested():
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
- tools = await manager.get_all_tools(include_skill_authoring=True)
+ tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
@@ -102,7 +114,7 @@ async def test_tool_manager_catalog_labels_tool_sources():
)
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
- catalog = await manager.get_tool_catalog(include_skill_authoring=True)
+ catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True)
assert [(item['name'], item['source'], item['source_name']) for item in catalog] == [
('exec', 'builtin', 'LangBot'),
@@ -121,11 +133,55 @@ async def test_tool_manager_routes_native_tool_calls():
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
- result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=Mock())
+ query = SimpleNamespace(
+ _execution_context=_CONTEXT,
+ bot_uuid=None,
+ pipeline_uuid=None,
+ query_uuid=None,
+ )
+ result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
assert result == {'backend': 'fake'}
+@pytest.mark.asyncio
+async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_entitlement():
+ box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
+ manager = ToolManager(SimpleNamespace(box_service=box_service))
+ manager.native_tool_loader = StubLoader([make_tool('exec')])
+ manager.skill_tool_loader = StubLoader([make_tool('activate')])
+ manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
+ manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
+
+ tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
+ catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True)
+
+ assert [tool.name for tool in tools] == ['plugin_tool', 'mcp_tool']
+ assert [item['name'] for item in catalog] == ['plugin_tool', 'mcp_tool']
+ assert box_service.is_workspace_sandbox_available.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocation():
+ box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
+ manager = ToolManager(SimpleNamespace(box_service=box_service))
+ manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'unexpected': True})
+ manager.skill_tool_loader = StubLoader([])
+ manager.plugin_tool_loader = StubLoader([])
+ manager.mcp_tool_loader = StubLoader([])
+ query = SimpleNamespace(
+ _execution_context=_CONTEXT,
+ bot_uuid=None,
+ pipeline_uuid=None,
+ query_uuid=None,
+ )
+
+ with pytest.raises(Exception, match='exec'):
+ await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
+
+ box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT)
+
+
@pytest.mark.asyncio
async def test_native_tool_loader_hides_tools_when_box_unavailable():
loader = NativeToolLoader(SimpleNamespace(box_service=SimpleNamespace(available=False)))
@@ -139,7 +195,7 @@ async def test_native_tool_loader_hides_tools_when_box_unavailable():
async def test_native_tool_loader_exposes_all_tools_when_box_available():
box_service = SimpleNamespace(
available=True,
- get_status=AsyncMock(return_value={'backend': {'available': True}}),
+ get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
await loader.initialize()
@@ -155,7 +211,7 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
async def test_native_tool_loader_refreshes_after_box_recovers():
box_service = SimpleNamespace(
available=False,
- get_status=AsyncMock(return_value={'backend': {'available': True}}),
+ get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
await loader.initialize()
@@ -166,20 +222,51 @@ async def test_native_tool_loader_refreshes_after_box_recovers():
assert [tool.name for tool in await loader.get_tools()] == ['exec', 'read', 'write', 'edit', 'glob', 'grep']
+@pytest.mark.asyncio
+async def test_native_tool_loader_rechecks_admission_at_the_final_invoke_boundary():
+ box_service = SimpleNamespace(
+ available=True,
+ require_workspace_sandbox=AsyncMock(side_effect=RuntimeError('entitlement expired')),
+ )
+ loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
+ query = SimpleNamespace(
+ _execution_context=_CONTEXT,
+ bot_uuid=None,
+ pipeline_uuid=None,
+ query_uuid=None,
+ )
+
+ with pytest.raises(RuntimeError, match='entitlement expired'):
+ await loader.invoke_tool('read', {'path': '/workspace/private.txt'}, query)
+
+ box_service.require_workspace_sandbox.assert_awaited_once_with(_CONTEXT)
+
+
# ── read/write/edit file tool tests ─────────────────────────────
def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]:
logger = Mock()
- box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
+ box_service = SimpleNamespace(
+ available=True,
+ default_workspace=tmpdir,
+ _tenant_workspace=Mock(return_value=tmpdir),
+ )
ap = SimpleNamespace(box_service=box_service, logger=logger)
return NativeToolLoader(ap), logger
-def _make_query() -> Mock:
- q = Mock()
- q.query_id = 'test-query-1'
- return q
+def _make_query() -> SimpleNamespace:
+ return SimpleNamespace(
+ query_id='test-query-1',
+ query_uuid='test-query-1',
+ instance_uuid=_CONTEXT.instance_uuid,
+ workspace_uuid=_CONTEXT.workspace_uuid,
+ placement_generation=_CONTEXT.placement_generation,
+ bot_uuid=None,
+ pipeline_uuid=None,
+ variables={},
+ )
@pytest.mark.asyncio
@@ -373,6 +460,24 @@ async def test_edit_rejects_missing_string():
assert 'not found' in result['error'].lower()
+@pytest.mark.asyncio
+async def test_edit_rejects_oversized_host_file(monkeypatch):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ loader, _ = _make_loader_with_workspace(tmpdir)
+ with open(os.path.join(tmpdir, 'large.txt'), 'wb') as f:
+ f.write(b'12345')
+ monkeypatch.setattr(native_loader, '_MAX_HOST_EDIT_FILE_BYTES', 4)
+
+ result = await loader.invoke_tool(
+ 'edit',
+ {'path': '/workspace/large.txt', 'old_string': '1', 'new_string': 'x'},
+ _make_query(),
+ )
+
+ assert result['ok'] is False
+ assert 'edit limit' in result['error']
+
+
@pytest.mark.asyncio
async def test_path_escape_blocked():
with tempfile.TemporaryDirectory() as tmpdir:
@@ -382,6 +487,137 @@ async def test_path_escape_blocked():
await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query())
+@pytest.mark.parametrize(
+ ('tool_name', 'parameters'),
+ [
+ ('read', {'path': '/workspace/shared/tenant-b-only.txt'}),
+ (
+ 'write',
+ {'path': '/workspace/shared/tenant-b-only.txt', 'content': 'overwritten by tenant a'},
+ ),
+ (
+ 'edit',
+ {
+ 'path': '/workspace/shared/tenant-b-only.txt',
+ 'old_string': 'tenant-b-secret',
+ 'new_string': 'overwritten by tenant a',
+ },
+ ),
+ ('glob', {'path': '/workspace/shared', 'pattern': '*'}),
+ ('grep', {'path': '/workspace/shared', 'pattern': 'tenant-b-secret'}),
+ ],
+)
+@pytest.mark.asyncio
+async def test_host_workspace_operations_do_not_follow_a_swapped_ancestor(
+ monkeypatch,
+ tool_name: str,
+ parameters: dict,
+):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tenant_a = os.path.join(tmpdir, 'tenant-a')
+ tenant_b = os.path.join(tmpdir, 'tenant-b')
+ os.makedirs(os.path.join(tenant_a, 'shared'))
+ os.makedirs(os.path.join(tenant_b, 'shared'))
+ tenant_b_file = os.path.join(tenant_b, 'shared', 'tenant-b-only.txt')
+ with open(os.path.join(tenant_a, 'shared', 'tenant-a-only.txt'), 'w', encoding='utf-8') as file_obj:
+ file_obj.write('tenant-a-content')
+ with open(tenant_b_file, 'w', encoding='utf-8') as file_obj:
+ file_obj.write('tenant-b-secret')
+
+ box_service = SimpleNamespace(
+ available=True,
+ default_workspace=tmpdir,
+ _tenant_workspace=Mock(return_value=tenant_a),
+ )
+ loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
+ original_open_host_root = native_loader._open_host_root
+
+ @contextlib.contextmanager
+ def open_host_root_after_swap(location, *, create):
+ with original_open_host_root(location, create=create) as root_fd:
+ original_ancestor = os.path.join(tenant_a, 'shared-original')
+ os.rename(os.path.join(tenant_a, 'shared'), original_ancestor)
+ os.symlink(os.path.join(tenant_b, 'shared'), os.path.join(tenant_a, 'shared'))
+ yield root_fd
+
+ monkeypatch.setattr(native_loader, '_open_host_root', open_host_root_after_swap)
+
+ try:
+ result = await loader.invoke_tool(tool_name, parameters, _make_query())
+ except ValueError as exc:
+ result = {'ok': False, 'error': str(exc)}
+
+ assert result.get('ok') is False
+ assert 'tenant-b-secret' not in repr(result)
+ assert 'tenant-b-only.txt' not in repr(result)
+ with open(tenant_b_file, encoding='utf-8') as file_obj:
+ assert file_obj.read() == 'tenant-b-secret'
+
+
+@pytest.mark.asyncio
+async def test_host_file_api_falls_back_to_tenant_box_when_openat_is_unavailable(monkeypatch):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ box_service = SimpleNamespace(
+ available=True,
+ default_workspace=tmpdir,
+ _tenant_workspace=Mock(return_value=tmpdir),
+ execute_tool=AsyncMock(
+ return_value={
+ 'ok': True,
+ 'stdout': '{"ok": true, "content": "box-owned", "truncated": false}',
+ 'stderr': '',
+ }
+ ),
+ )
+ loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
+ monkeypatch.setattr(native_loader, '_SECURE_HOST_FILE_OPS_AVAILABLE', False)
+
+ result = await loader.invoke_tool(
+ 'read',
+ {'path': '/workspace/file.txt'},
+ _make_query(),
+ )
+
+ assert result['ok'] is True
+ assert result['content'] == 'box-owned'
+ command = box_service.execute_tool.await_args.args[0]['command']
+ assert 'path = "/workspace/file.txt"' in command
+
+
+@pytest.mark.asyncio
+async def test_box_workspace_edit_script_bounds_file_read_and_replacement(monkeypatch):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ box_service = SimpleNamespace(
+ available=True,
+ default_workspace=tmpdir,
+ _tenant_workspace=Mock(return_value=tmpdir),
+ execute_tool=AsyncMock(
+ return_value={
+ 'ok': True,
+ 'stdout': '{"ok": false, "error": "File exceeds limit"}',
+ 'stderr': '',
+ }
+ ),
+ )
+ loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
+ monkeypatch.setattr(native_loader, '_SECURE_HOST_FILE_OPS_AVAILABLE', False)
+
+ await loader.invoke_tool(
+ 'edit',
+ {
+ 'path': '/workspace/file.txt',
+ 'old_string': 'old',
+ 'new_string': 'new',
+ },
+ _make_query(),
+ )
+
+ command = box_service.execute_tool.await_args.args[0]['command']
+ assert f'os.path.getsize(path) > {native_loader._MAX_HOST_EDIT_FILE_BYTES}' in command
+ assert f'f.read({native_loader._MAX_HOST_EDIT_FILE_BYTES + 1})' in command
+ assert f"len(new_content.encode('utf-8')) > {native_loader._MAX_HOST_EDIT_FILE_BYTES}" in command
+
+
@pytest.mark.asyncio
async def test_box_availability_helper_handles_unavailable_and_errors():
from langbot.pkg.provider.tools.loaders.availability import is_box_backend_available
@@ -391,13 +627,13 @@ async def test_box_availability_helper_handles_unavailable_and_errors():
unavailable_backend = SimpleNamespace(
available=True,
- get_status=AsyncMock(return_value={'backend': {'available': False}}),
+ get_backend_status=AsyncMock(return_value={'backend': {'available': False}}),
)
assert await is_box_backend_available(SimpleNamespace(box_service=unavailable_backend)) is False
failing_backend = SimpleNamespace(
available=True,
- get_status=AsyncMock(side_effect=RuntimeError('box unavailable')),
+ get_backend_status=AsyncMock(side_effect=RuntimeError('box unavailable')),
)
assert await is_box_backend_available(SimpleNamespace(box_service=failing_backend)) is False
@@ -495,6 +731,34 @@ async def test_glob_caps_match_count_and_returns_preview():
assert result['truncated_by'] == 'matches'
+@pytest.mark.asyncio
+async def test_glob_runs_off_event_loop_and_caps_directory_walk(monkeypatch):
+ monkeypatch.setattr(native_loader, '_FILE_WALK_MAX_ENTRIES', 10)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ loader, _ = _make_loader_with_workspace(tmpdir)
+ event_loop_thread = threading.get_ident()
+ observed_threads: list[int] = []
+ original = loader._glob_host_location
+
+ def observe(*args, **kwargs):
+ observed_threads.append(threading.get_ident())
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(loader, '_glob_host_location', observe)
+ for index in range(12):
+ with open(os.path.join(tmpdir, f'file-{index:03d}.txt'), 'w', encoding='utf-8') as f:
+ f.write(str(index))
+
+ result = await loader.invoke_tool('glob', {'path': '/workspace', 'pattern': '*.txt'}, _make_query())
+
+ assert result['ok'] is True
+ assert result['total'] == 10
+ assert result['truncated'] is True
+ assert result['truncated_by'] == 'scan'
+ assert observed_threads and observed_threads[0] != event_loop_thread
+
+
@pytest.mark.asyncio
async def test_grep_reports_invalid_regex_and_truncates_long_matching_lines():
with tempfile.TemporaryDirectory() as tmpdir:
@@ -512,3 +776,21 @@ async def test_grep_reports_invalid_regex_and_truncates_long_matching_lines():
assert result['truncated_by'] == 'line'
assert result['matches'][0]['file'] == '/workspace/data.txt'
assert result['matches'][0]['content'].endswith('... [truncated]')
+
+
+@pytest.mark.asyncio
+async def test_grep_interrupts_catastrophic_regex(monkeypatch):
+ monkeypatch.setattr(native_loader, '_GREP_REGEX_TIMEOUT_SECONDS', 0.001)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ loader, _ = _make_loader_with_workspace(tmpdir)
+ with open(os.path.join(tmpdir, 'data.txt'), 'w', encoding='utf-8') as f:
+ f.write(('a' * 100_000) + '!')
+
+ result = await loader.invoke_tool(
+ 'grep',
+ {'path': '/workspace', 'pattern': r'(a+)+$'},
+ _make_query(),
+ )
+
+ assert result == {'ok': False, 'error': 'Regex search timed out'}
diff --git a/tests/unit_tests/rag/test_file_storage.py b/tests/unit_tests/rag/test_file_storage.py
index d4a6f2239..7b464d502 100644
--- a/tests/unit_tests/rag/test_file_storage.py
+++ b/tests/unit_tests/rag/test_file_storage.py
@@ -2,19 +2,42 @@
from __future__ import annotations
+import contextvars
import io
import zipfile
+from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.core.taskmgr import TaskCapacityError
from langbot.pkg.rag.knowledge.kbmgr import RuntimeKnowledgeBase
+from langbot.pkg.storage.mgr import StorageMgr
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
-def _make_zip_bytes(entries: dict[str, bytes]) -> bytes:
+WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid=WORKSPACE_A,
+ placement_generation=2,
+)
+
+
+def _upload_key(logical_key: str, *, context: ExecutionContext = CONTEXT) -> str:
+ return StorageMgr.scoped_object_key(
+ context,
+ owner_type='upload_document',
+ owner='account:test',
+ key=logical_key,
+ )
+
+
+def _make_zip_bytes(entries: dict[str, bytes], *, compression: int = zipfile.ZIP_STORED) -> bytes:
buffer = io.BytesIO()
- with zipfile.ZipFile(buffer, 'w') as zf:
+ with zipfile.ZipFile(buffer, 'w', compression=compression) as zf:
for name, content in entries.items():
zf.writestr(name, content)
zf.mkdir('emptydir')
@@ -25,26 +48,39 @@ def _make_app() -> Mock:
app = Mock()
app.logger = Mock()
app.task_mgr = Mock()
- app.storage_mgr = Mock()
- app.storage_mgr.storage_provider = Mock()
- app.storage_mgr.storage_provider.exists = AsyncMock(return_value=True)
- app.storage_mgr.storage_provider.load = AsyncMock()
- app.storage_mgr.storage_provider.save = AsyncMock()
- app.storage_mgr.storage_provider.size = AsyncMock(return_value=123)
- app.storage_mgr.storage_provider.delete = AsyncMock()
+ storage_mgr = StorageMgr(app)
+ storage_mgr.storage_provider = Mock()
+ storage_mgr.storage_provider.exists = AsyncMock(return_value=True)
+ storage_mgr.storage_provider.load = AsyncMock()
+ storage_mgr.storage_provider.load_bounded = AsyncMock()
+ storage_mgr.storage_provider.save = AsyncMock()
+ storage_mgr.storage_provider.size = AsyncMock(return_value=123)
+ storage_mgr.storage_provider.delete = AsyncMock()
+ app.storage_mgr = storage_mgr
app.persistence_mgr = Mock()
- app.persistence_mgr.execute_async = AsyncMock()
+ app.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=1))
app.plugin_connector = Mock()
+ app.plugin_connector.require_workspace_context = AsyncMock(side_effect=lambda context: context)
+ app.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ return_value=SimpleNamespace(
+ instance_uuid=CONTEXT.instance_uuid,
+ workspace_uuid=CONTEXT.workspace_uuid,
+ placement_generation=CONTEXT.placement_generation,
+ )
+ )
+ )
return app
def _make_kb(plugin_id: str | None = 'author/engine') -> RuntimeKnowledgeBase:
kb_entity = Mock()
kb_entity.uuid = 'test-kb-uuid'
+ kb_entity.workspace_uuid = WORKSPACE_A
kb_entity.collection_id = 'test-collection'
kb_entity.creation_settings = {}
kb_entity.knowledge_engine_plugin_id = plugin_id
- return RuntimeKnowledgeBase(_make_app(), kb_entity)
+ return RuntimeKnowledgeBase(_make_app(), kb_entity, CONTEXT)
class TestStoreFile:
@@ -58,33 +94,89 @@ class TestStoreFile:
kb.ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
- task_id = await kb.store_file('documents/test.pdf')
+ object_key = _upload_key('documents/test.pdf')
+ task_id = await kb.store_file(CONTEXT, object_key)
assert task_id == 'task-1'
- kb.ap.storage_mgr.storage_provider.exists.assert_awaited_once_with('documents/test.pdf')
+ kb.ap.storage_mgr.storage_provider.exists.assert_awaited_once_with(object_key)
kb.ap.persistence_mgr.execute_async.assert_awaited_once()
call_kwargs = kb.ap.task_mgr.create_user_task.call_args.kwargs
assert call_kwargs['kind'] == 'knowledge-operation'
- assert call_kwargs['name'] == 'knowledge-store-file-documents/test.pdf'
- assert call_kwargs['label'] == 'Store file documents/test.pdf'
+ assert call_kwargs['name'] == f'knowledge-store-file-{object_key}'
+ assert call_kwargs['label'] == f'Store file {object_key}'
@pytest.mark.asyncio
async def test_store_file_raises_when_source_file_missing(self):
kb = _make_kb()
kb.ap.storage_mgr.storage_provider.exists = AsyncMock(return_value=False)
- with pytest.raises(Exception, match='File missing.pdf not found'):
- await kb.store_file('missing.pdf')
+ object_key = _upload_key('missing.pdf')
+ with pytest.raises(WorkspaceNotFoundError, match='Upload not found'):
+ await kb.store_file(CONTEXT, object_key)
kb.ap.persistence_mgr.execute_async.assert_not_awaited()
kb.ap.task_mgr.create_user_task.assert_not_called()
+ @pytest.mark.asyncio
+ async def test_store_file_rejects_cross_workspace_upload_key(self):
+ kb = _make_kb()
+ other_context = ExecutionContext(
+ instance_uuid=CONTEXT.instance_uuid,
+ workspace_uuid='00000000-0000-0000-0000-00000000000b',
+ placement_generation=CONTEXT.placement_generation,
+ )
+
+ with pytest.raises(WorkspaceNotFoundError, match='Upload not found'):
+ await kb.store_file(CONTEXT, _upload_key('stolen.pdf', context=other_context))
+
+ kb.ap.storage_mgr.storage_provider.exists.assert_not_awaited()
+ kb.ap.persistence_mgr.execute_async.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_store_file_rejects_stale_generation_and_wrong_owner_type(self):
+ kb = _make_kb()
+ stale_context = ExecutionContext(
+ instance_uuid=CONTEXT.instance_uuid,
+ workspace_uuid=CONTEXT.workspace_uuid,
+ placement_generation=CONTEXT.placement_generation + 1,
+ )
+ plugin_key = StorageMgr.scoped_object_key(
+ CONTEXT,
+ owner_type='plugin_config',
+ owner='plugin:test',
+ key='config.pdf',
+ )
+
+ for object_key in (_upload_key('stale.pdf', context=stale_context), plugin_key, 'raw.pdf'):
+ with pytest.raises(WorkspaceNotFoundError, match='Upload not found'):
+ await kb.store_file(CONTEXT, object_key)
+
+ kb.ap.storage_mgr.storage_provider.exists.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_store_file_rolls_back_pending_record_when_task_capacity_is_exhausted(self):
+ kb = _make_kb()
+ object_key = _upload_key('queued.pdf')
+
+ def reject(coro, **_kwargs):
+ coro.close()
+ raise TaskCapacityError('capacity')
+
+ kb.ap.task_mgr.create_user_task.side_effect = reject
+
+ with pytest.raises(TaskCapacityError, match='capacity'):
+ await kb.store_file(CONTEXT, object_key)
+
+ statements = [str(call.args[0]) for call in kb.ap.persistence_mgr.execute_async.await_args_list]
+ assert any(statement.startswith('INSERT') for statement in statements)
+ assert any(statement.startswith('DELETE') for statement in statements)
+
class TestStoreZipFile:
@pytest.mark.asyncio
async def test_store_zip_file_extracts_supported_files_and_skips_noise(self):
kb = _make_kb()
- kb.ap.storage_mgr.storage_provider.load = AsyncMock(
+ kb.ap.storage_mgr.storage_provider.load_bounded = AsyncMock(
return_value=_make_zip_bytes(
{
'doc1.pdf': b'pdf',
@@ -99,63 +191,131 @@ class TestStoreZipFile:
)
kb.store_file = AsyncMock(side_effect=['task-pdf', 'task-txt', 'task-md', 'task-html'])
- task_id = await kb._store_zip_file('archive.zip', parser_plugin_id='parser/plugin')
+ zip_key = _upload_key('archive.zip')
+ task_id = await kb._store_zip_file(CONTEXT, zip_key, parser_plugin_id='parser/plugin')
assert task_id == 'task-pdf'
assert kb.ap.storage_mgr.storage_provider.save.await_count == 4
saved_names = [call.args[0] for call in kb.ap.storage_mgr.storage_provider.save.await_args_list]
- assert any(name.startswith('doc1_') and name.endswith('.pdf') for name in saved_names)
- assert any(name.startswith('doc2_') and name.endswith('.txt') for name in saved_names)
- assert any(name.startswith('subdir_doc3_') and name.endswith('.md') for name in saved_names)
- assert any(name.startswith('page_') and name.endswith('.html') for name in saved_names)
- assert not any('image' in name for name in saved_names)
- assert not any('hidden' in name for name in saved_names)
- assert not any('__MACOSX' in name for name in saved_names)
- kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('archive.zip')
+ assert {name.rsplit('.', 1)[-1] for name in saved_names} == {'pdf', 'txt', 'md', 'html'}
+ for name in saved_names:
+ StorageMgr.require_scoped_object_key(
+ CONTEXT,
+ name,
+ expected_owner_type='upload_document',
+ )
+ forwarded_keys = [call.args[1] for call in kb.store_file.await_args_list]
+ assert forwarded_keys == saved_names
+ kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(zip_key)
@pytest.mark.asyncio
async def test_store_zip_file_raises_when_no_supported_files(self):
kb = _make_kb()
- kb.ap.storage_mgr.storage_provider.load = AsyncMock(
+ kb.ap.storage_mgr.storage_provider.load_bounded = AsyncMock(
return_value=_make_zip_bytes({'image.png': b'png', 'video.mp4': b'video'})
)
kb.store_file = AsyncMock()
with pytest.raises(Exception, match='No supported files found'):
- await kb._store_zip_file('archive.zip')
+ await kb._store_zip_file(CONTEXT, _upload_key('archive.zip'))
kb.store_file.assert_not_awaited()
- kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('archive.zip')
+ kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(_upload_key('archive.zip'))
+
+ @pytest.mark.asyncio
+ async def test_store_zip_file_rejects_too_many_documents_before_extracting(self):
+ kb = _make_kb()
+ kb.ap.storage_mgr.storage_provider.load_bounded = AsyncMock(
+ return_value=_make_zip_bytes({f'doc-{index}.txt': b'text' for index in range(9)})
+ )
+ kb.store_file = AsyncMock()
+
+ with pytest.raises(ValueError, match='too many supported documents'):
+ await kb._store_zip_file(CONTEXT, _upload_key('archive.zip'))
+
+ kb.store_file.assert_not_awaited()
+ kb.ap.storage_mgr.storage_provider.save.assert_not_awaited()
+ kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(_upload_key('archive.zip'))
+
+ @pytest.mark.asyncio
+ async def test_store_zip_file_rejects_extreme_compression_ratio_before_extracting(self):
+ kb = _make_kb()
+ kb.ap.storage_mgr.storage_provider.load_bounded = AsyncMock(
+ return_value=_make_zip_bytes(
+ {'bomb.txt': b'A' * (1024 * 1024)},
+ compression=zipfile.ZIP_DEFLATED,
+ )
+ )
+ kb.store_file = AsyncMock()
+
+ with pytest.raises(ValueError, match='compression-ratio limit'):
+ await kb._store_zip_file(CONTEXT, _upload_key('archive.zip'))
+
+ kb.store_file.assert_not_awaited()
+ kb.ap.storage_mgr.storage_provider.save.assert_not_awaited()
+ kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(_upload_key('archive.zip'))
class TestStoreFileTask:
+ @pytest.mark.asyncio
+ async def test_store_file_task_opens_uow_before_first_database_helper(self):
+ kb = _make_kb()
+ active_workspace = contextvars.ContextVar('rag_task_workspace', default=None)
+ observed = []
+
+ @asynccontextmanager
+ async def tenant_uow(workspace_uuid):
+ token = active_workspace.set(workspace_uuid)
+ try:
+ yield
+ finally:
+ active_workspace.reset(token)
+
+ kb.ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
+ kb.ap.persistence_mgr.tenant_uow = tenant_uow
+
+ async def assert_execution_context(_context):
+ observed.append(active_workspace.get())
+
+ kb._assert_execution_context = AsyncMock(side_effect=assert_execution_context)
+ kb._set_file_status = AsyncMock(side_effect=[True, True])
+ kb._ingest_document = AsyncMock(return_value={'status': 'completed'})
+ object_key = _upload_key('scoped.pdf')
+ file_obj = SimpleNamespace(uuid='file-uuid', file_name=object_key, extension='pdf')
+
+ await kb._store_file_task(CONTEXT, file_obj, Mock())
+
+ assert observed[0] == WORKSPACE_A
+
@pytest.mark.asyncio
async def test_store_file_task_marks_completed_and_cleans_storage(self):
kb = _make_kb()
kb._ingest_document = AsyncMock(return_value={'status': 'completed'})
- file_obj = SimpleNamespace(uuid='file-uuid', file_name='test.pdf', extension='pdf')
+ object_key = _upload_key('test.pdf')
+ file_obj = SimpleNamespace(uuid='file-uuid', file_name=object_key, extension='pdf')
task_context = Mock()
- await kb._store_file_task(file_obj, task_context)
+ await kb._store_file_task(CONTEXT, file_obj, task_context)
task_context.set_current_action.assert_called_once_with('Processing file')
- kb.ap.storage_mgr.storage_provider.size.assert_awaited_once_with('test.pdf')
+ kb.ap.storage_mgr.storage_provider.size.assert_awaited_once_with(object_key)
kb._ingest_document.assert_awaited_once()
assert kb.ap.persistence_mgr.execute_async.await_count == 2
- kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('test.pdf')
+ kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key)
@pytest.mark.asyncio
async def test_store_file_task_marks_failed_and_cleans_storage(self):
kb = _make_kb()
kb._ingest_document = AsyncMock(return_value={'status': 'failed', 'error_message': 'parser failed'})
- file_obj = SimpleNamespace(uuid='file-uuid', file_name='bad.pdf', extension='pdf')
+ object_key = _upload_key('bad.pdf')
+ file_obj = SimpleNamespace(uuid='file-uuid', file_name=object_key, extension='pdf')
task_context = Mock()
with pytest.raises(Exception, match='parser failed'):
- await kb._store_file_task(file_obj, task_context)
+ await kb._store_file_task(CONTEXT, file_obj, task_context)
assert kb.ap.persistence_mgr.execute_async.await_count == 2
- kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with('bad.pdf')
+ kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key)
class TestDeleteDocument:
@@ -163,7 +323,7 @@ class TestDeleteDocument:
async def test_delete_document_returns_false_when_no_plugin_id(self):
kb = _make_kb(plugin_id=None)
- result = await kb._delete_document('doc-id')
+ result = await kb._delete_document(CONTEXT, 'doc-id')
assert result is False
@@ -172,7 +332,7 @@ class TestDeleteDocument:
kb = _make_kb()
kb.ap.plugin_connector.call_rag_delete_document = AsyncMock(return_value=True)
- result = await kb._delete_document('doc-id')
+ result = await kb._delete_document(CONTEXT, 'doc-id')
assert result is True
kb.ap.plugin_connector.call_rag_delete_document.assert_awaited_once_with(
@@ -184,7 +344,7 @@ class TestDeleteDocument:
kb = _make_kb()
kb.ap.plugin_connector.call_rag_delete_document = AsyncMock(side_effect=Exception('plugin error'))
- result = await kb._delete_document('doc-id')
+ result = await kb._delete_document(CONTEXT, 'doc-id')
assert result is False
kb.ap.logger.error.assert_called_once()
diff --git a/tests/unit_tests/rag/test_kbmgr.py b/tests/unit_tests/rag/test_kbmgr.py
index a1a16118d..9699269d9 100644
--- a/tests/unit_tests/rag/test_kbmgr.py
+++ b/tests/unit_tests/rag/test_kbmgr.py
@@ -1,137 +1,397 @@
-"""Unit tests for RAG knowledge base manager.
-
-Tests cover:
-- RAGManager CRUD operations
-- RuntimeKnowledgeBase getters
-- Knowledge engine enrichment
-- KB loading and removal
-"""
+"""Tests for Workspace-scoped RAG manager and runtime knowledge bases."""
from __future__ import annotations
+import dataclasses
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
import pytest
-import uuid
-from unittest.mock import Mock, AsyncMock
-from importlib import import_module
+
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.entity.persistence.rag import KnowledgeBase
+from langbot.pkg.rag.knowledge.kbmgr import RAGManager, RuntimeKnowledgeBase
+from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
+from langbot.pkg.workspace.errors import WorkspaceInvariantError, WorkspaceNotFoundError
-def get_rag_module():
- """Lazy import to avoid circular import issues."""
- return import_module('langbot.pkg.rag.knowledge.kbmgr')
+CONTEXT_A = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=5,
+)
+CONTEXT_B = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-b',
+ placement_generation=5,
+)
-def create_mock_app():
- """Create mock Application for testing."""
- mock_app = Mock()
- mock_app.logger = Mock()
- mock_app.persistence_mgr = AsyncMock()
- mock_app.persistence_mgr.execute_async = AsyncMock()
- mock_app.persistence_mgr.serialize_model = Mock(return_value={})
- mock_app.plugin_connector = AsyncMock()
- mock_app.plugin_connector.is_enable_plugin = True
- mock_app.storage_mgr = Mock()
- mock_app.storage_mgr.storage_provider = AsyncMock()
- mock_app.task_mgr = AsyncMock()
- mock_app.task_mgr.create_user_task = Mock(return_value=Mock(id=1))
- return mock_app
+class _Result:
+ def __init__(self, rows=(), *, first=None):
+ self.rows = list(rows)
+ self._first = first
+
+ def all(self):
+ return self.rows
+
+ def first(self):
+ return self._first
-def create_mock_kb_entity():
- """Create mock KnowledgeBase entity."""
- mock_kb = Mock()
- mock_kb.uuid = str(uuid.uuid4())
- mock_kb.name = 'Test KB'
- mock_kb.description = 'Test description'
- mock_kb.knowledge_engine_plugin_id = 'author/engine'
- mock_kb.collection_id = mock_kb.uuid
- mock_kb.creation_settings = {}
- mock_kb.retrieval_settings = {}
- return mock_kb
+def _entity(*, kb_uuid='kb-a', workspace_uuid='workspace-a', plugin_id='author/engine'):
+ return KnowledgeBase(
+ uuid=kb_uuid,
+ workspace_uuid=workspace_uuid,
+ name='Test KB',
+ description='description',
+ knowledge_engine_plugin_id=plugin_id,
+ collection_id=kb_uuid,
+ creation_settings={},
+ retrieval_settings={},
+ )
+def _app():
+ return SimpleNamespace(
+ logger=Mock(),
+ persistence_mgr=SimpleNamespace(
+ execute_async=AsyncMock(return_value=_Result()),
+ serialize_model=Mock(
+ side_effect=lambda _model, row: {
+ 'uuid': row.uuid,
+ 'workspace_uuid': row.workspace_uuid,
+ 'name': row.name,
+ 'description': row.description,
+ 'knowledge_engine_plugin_id': row.knowledge_engine_plugin_id,
+ 'collection_id': row.collection_id,
+ 'creation_settings': row.creation_settings,
+ 'retrieval_settings': row.retrieval_settings,
+ }
+ ),
+ ),
+ plugin_connector=SimpleNamespace(
+ is_enable_plugin=True,
+ require_workspace_context=AsyncMock(side_effect=lambda context: context),
+ list_knowledge_engines=AsyncMock(
+ return_value=[
+ {
+ 'plugin_id': 'author/engine',
+ 'name': {'en_US': 'Engine'},
+ 'capabilities': ['doc_ingestion'],
+ }
+ ]
+ ),
+ rag_on_kb_create=AsyncMock(),
+ rag_on_kb_delete=AsyncMock(),
+ call_rag_ingest=AsyncMock(return_value={'status': 'success'}),
+ call_rag_retrieve=AsyncMock(return_value={'results': []}),
+ call_rag_delete_document=AsyncMock(return_value=True),
+ call_parser=AsyncMock(),
+ ),
+ workspace_service=SimpleNamespace(
+ get_execution_binding=AsyncMock(
+ side_effect=lambda workspace_uuid, **_kwargs: SimpleNamespace(
+ instance_uuid='instance-a',
+ workspace_uuid=workspace_uuid,
+ placement_generation=5,
+ )
+ )
+ ),
+ storage_mgr=SimpleNamespace(storage_provider=AsyncMock()),
+ task_mgr=SimpleNamespace(create_user_task=Mock(return_value=SimpleNamespace(id='task-a'))),
+ )
+
+
+@pytest.mark.asyncio
+async def test_create_binds_workspace_and_uses_tuple_runtime_key():
+ app = _app()
+ manager = RAGManager(app)
+
+ kb = await manager.create_knowledge_base(
+ CONTEXT_A,
+ name='Created',
+ knowledge_engine_plugin_id='author/engine',
+ creation_settings={'model': 'embedding-a'},
+ )
+
+ assert kb.workspace_uuid == 'workspace-a'
+ assert ('workspace-a', kb.uuid) in manager.knowledge_bases
+ app.plugin_connector.rag_on_kb_create.assert_awaited_once_with(
+ 'author/engine',
+ kb.uuid,
+ {'model': 'embedding-a'},
+ )
+
+
+@pytest.mark.asyncio
+async def test_create_rejects_unknown_engine_and_rolls_back_plugin_failure():
+ app = _app()
+ manager = RAGManager(app)
+ app.plugin_connector.list_knowledge_engines.return_value = []
+ with pytest.raises(ValueError, match='not found'):
+ await manager.create_knowledge_base(
+ CONTEXT_A,
+ name='Unknown',
+ knowledge_engine_plugin_id='missing/engine',
+ creation_settings={},
+ )
+
+ app.plugin_connector.list_knowledge_engines.return_value = [{'plugin_id': 'author/engine'}]
+ app.plugin_connector.rag_on_kb_create.side_effect = RuntimeError('plugin failed')
+ with pytest.raises(RuntimeError, match='plugin failed'):
+ await manager.create_knowledge_base(
+ CONTEXT_A,
+ name='Rollback',
+ knowledge_engine_plugin_id='author/engine',
+ creation_settings={},
+ )
+ assert manager.knowledge_bases == {}
+
+
+@pytest.mark.asyncio
+async def test_runtime_retrieve_carries_context_and_merges_settings_without_mutation():
+ app = _app()
+ entity = _entity()
+ entity.retrieval_settings = {'top_k': 10, 'model': 'default'}
+ app.plugin_connector.call_rag_retrieve.return_value = {
+ 'results': [
+ {
+ 'id': 'entry-a',
+ 'content': [{'type': 'text', 'text': 'hello'}],
+ 'metadata': {},
+ 'distance': 0.2,
+ }
+ ]
+ }
+ runtime = RuntimeKnowledgeBase(app, entity, CONTEXT_A)
+ overrides = {'top_k': 2, 'filters': {'file_id': 'file-a'}}
+
+ results = await runtime.retrieve(CONTEXT_A, 'query', settings=overrides)
+
+ assert results[0].id == 'entry-a'
+ assert overrides == {'top_k': 2, 'filters': {'file_id': 'file-a'}}
+ payload = app.plugin_connector.call_rag_retrieve.await_args.args[1]
+ assert payload['knowledge_base_id'] == 'kb-a'
+ assert payload['collection_id'] == 'kb-a'
+ assert payload['retrieval_settings']['top_k'] == 2
+ assert payload['filters'] == {'file_id': 'file-a'}
+
+
+@pytest.mark.asyncio
+async def test_runtime_rejects_cross_workspace_and_stale_contexts():
+ app = _app()
+ runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await runtime.retrieve(CONTEXT_B, 'query')
+ stale = CONTEXT_A.__class__(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=4,
+ )
+ with pytest.raises(WorkspaceNotFoundError):
+ await runtime.retrieve(stale, 'query')
+ app.plugin_connector.call_rag_retrieve.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_runtime_retrieve_fails_before_bound_connector_on_generation_mismatch():
+ app = _app()
+ app.plugin_connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
+ runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
+
+ with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
+ await runtime.retrieve(CONTEXT_A, 'query')
+
+ app.plugin_connector.call_rag_retrieve.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ('operation', 'connector_method'),
+ [
+ ('create', 'rag_on_kb_create'),
+ ('ingest', 'call_rag_ingest'),
+ ('delete', 'call_rag_delete_document'),
+ ],
+)
+async def test_runtime_plugin_mutations_fail_before_mismatched_connector(operation, connector_method):
+ app = _app()
+ app.plugin_connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
+ runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
+
+ with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
+ if operation == 'create':
+ await runtime._on_kb_create(CONTEXT_A)
+ elif operation == 'ingest':
+ await runtime._ingest_document(CONTEXT_A, {'filename': 'document.pdf'}, 'storage/path')
+ else:
+ await runtime._delete_document(CONTEXT_A, 'file-a')
+
+ getattr(app.plugin_connector, connector_method).assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_create_fails_before_engine_discovery_on_connector_workspace_mismatch():
+ app = _app()
+ app.plugin_connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
+ manager = RAGManager(app)
+
+ with pytest.raises(WorkspaceNotFoundError, match='Plugin resource not found'):
+ await manager.create_knowledge_base(
+ CONTEXT_A,
+ name='Other workspace',
+ knowledge_engine_plugin_id='author/engine',
+ creation_settings={},
+ )
+
+ app.plugin_connector.list_knowledge_engines.assert_not_awaited()
+ app.persistence_mgr.execute_async.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_ingestion_payload_uses_host_owned_kb_collection():
+ app = _app()
+ runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
+ metadata = {'filename': 'document.pdf'}
+
+ await runtime._ingest_document(CONTEXT_A, metadata, 'uploads/document.pdf')
+
+ payload = app.plugin_connector.call_rag_ingest.await_args.args[1]
+ assert payload['knowledge_base_id'] == 'kb-a'
+ assert payload['collection_id'] == 'kb-a'
+ assert payload['file_object']['metadata']['knowledge_base_id'] == 'kb-a'
+
+
+@pytest.mark.asyncio
+async def test_delete_file_checks_workspace_and_parent_before_plugin_call():
+ app = _app()
+ runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
+ app.persistence_mgr.execute_async.return_value = _Result(first=('file-a',))
+
+ await runtime.delete_file(CONTEXT_A, 'file-a')
+ app.plugin_connector.call_rag_delete_document.assert_awaited_once_with(
+ 'author/engine',
+ 'file-a',
+ 'kb-a',
+ )
+
+ missing_app = _app()
+ missing = RuntimeKnowledgeBase(missing_app, _entity(), CONTEXT_A)
+ with pytest.raises(WorkspaceNotFoundError):
+ await missing.delete_file(CONTEXT_A, 'file-other')
+ missing_app.plugin_connector.call_rag_delete_document.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_manager_get_remove_and_delete_are_workspace_scoped():
+ app = _app()
+ manager = RAGManager(app)
+ runtime = await manager.load_knowledge_base(CONTEXT_A, _entity())
+
+ assert await manager.get_knowledge_base_by_uuid(CONTEXT_A, 'kb-a') is runtime
+ assert await manager.get_knowledge_base_by_uuid(CONTEXT_B, 'kb-a') is None
+ await manager.remove_knowledge_base_from_runtime(CONTEXT_B, 'kb-a')
+ assert await manager.get_knowledge_base_by_uuid(CONTEXT_A, 'kb-a') is runtime
+ await manager.delete_knowledge_base(CONTEXT_A, 'kb-a')
+ app.plugin_connector.rag_on_kb_delete.assert_awaited_once()
+ assert await manager.get_knowledge_base_by_uuid(CONTEXT_A, 'kb-a') is None
+
+
+@pytest.mark.asyncio
+async def test_details_queries_require_workspace_and_enrich_engine():
+ app = _app()
+ row_a = _entity()
+ app.persistence_mgr.execute_async.return_value = _Result([row_a], first=row_a)
+ manager = RAGManager(app)
+
+ listed = await manager.get_all_knowledge_base_details(CONTEXT_A)
+ fetched = await manager.get_knowledge_base_details(CONTEXT_A, 'kb-a')
+ assert listed[0]['knowledge_engine']['plugin_id'] == 'author/engine'
+ assert fetched['knowledge_engine']['capabilities'] == ['doc_ingestion']
+
+
+@pytest.mark.asyncio
+async def test_load_dict_filters_computed_fields_and_requires_matching_workspace():
+ app = _app()
+ manager = RAGManager(app)
+ runtime = await manager.load_knowledge_base(
+ CONTEXT_A,
+ {
+ 'uuid': 'kb-a',
+ 'workspace_uuid': 'workspace-a',
+ 'name': 'KB',
+ 'description': '',
+ 'knowledge_engine_plugin_id': 'author/engine',
+ 'collection_id': 'kb-a',
+ 'creation_settings': {},
+ 'retrieval_settings': {},
+ 'knowledge_engine': {'computed': True},
+ },
+ )
+ assert runtime.get_uuid() == 'kb-a'
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await manager.load_knowledge_base(CONTEXT_B, _entity())
+
+
+# Preserve the complete pre-tenancy RAG manager regression matrix. Every
+# runtime operation now carries the immutable ExecutionContext, and cache
+# assertions use the Workspace + resource tuple key introduced for isolation.
class TestRAGManagerCreateKnowledgeBase:
- """Tests for create_knowledge_base method."""
-
@pytest.mark.asyncio
async def test_creates_kb_with_valid_engine(self):
- """Test creates KB when engine plugin exists."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
-
- # Mock valid engine list
- mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
- return_value=[{'plugin_id': 'author/engine', 'name': 'Engine'}]
- )
- mock_app.persistence_mgr.execute_async = AsyncMock()
- mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
-
- manager = rag_module.RAGManager(mock_app)
+ app = _app()
+ manager = RAGManager(app)
kb = await manager.create_knowledge_base(
+ CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={'model': 'test'},
)
assert kb.name == 'Test KB'
+ assert kb.workspace_uuid == CONTEXT_A.workspace_uuid
assert kb.knowledge_engine_plugin_id == 'author/engine'
@pytest.mark.asyncio
async def test_raises_when_engine_not_found(self):
- """Test raises ValueError when engine plugin not found."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ app.plugin_connector.list_knowledge_engines.return_value = []
- # Mock empty engine list
- mock_app.plugin_connector.list_knowledge_engines = AsyncMock(return_value=[])
-
- manager = rag_module.RAGManager(mock_app)
-
- with pytest.raises(ValueError) as exc_info:
- await manager.create_knowledge_base(
+ with pytest.raises(ValueError, match='not found'):
+ await RAGManager(app).create_knowledge_base(
+ CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='unknown/engine',
creation_settings={},
)
- assert 'not found' in str(exc_info.value)
-
@pytest.mark.asyncio
async def test_rollback_on_plugin_create_failure(self):
- """Test that DB entry is rolled back when plugin create fails."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ app.plugin_connector.rag_on_kb_create.side_effect = RuntimeError('Plugin error')
+ manager = RAGManager(app)
- mock_app.plugin_connector.list_knowledge_engines = AsyncMock(return_value=[{'plugin_id': 'author/engine'}])
- mock_app.persistence_mgr.execute_async = AsyncMock()
- mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Plugin error'))
-
- manager = rag_module.RAGManager(mock_app)
-
- with pytest.raises(Exception):
+ with pytest.raises(RuntimeError, match='Plugin error'):
await manager.create_knowledge_base(
+ CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={},
)
- # Should have called delete to rollback
- # Check that delete was called (for rollback)
- assert len(manager.knowledge_bases) == 0
+ assert manager.knowledge_bases == {}
+ assert app.persistence_mgr.execute_async.await_count == 2
@pytest.mark.asyncio
async def test_sets_default_retrieval_settings(self):
- """Test that empty retrieval_settings defaults to {}."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
- mock_app.plugin_connector.list_knowledge_engines = AsyncMock(return_value=[{'plugin_id': 'author/engine'}])
- mock_app.persistence_mgr.execute_async = AsyncMock()
- mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
-
- manager = rag_module.RAGManager(mock_app)
-
- kb = await manager.create_knowledge_base(
+ kb = await RAGManager(app).create_knowledge_base(
+ CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='author/engine',
creation_settings={},
@@ -142,419 +402,343 @@ class TestRAGManagerCreateKnowledgeBase:
@pytest.mark.asyncio
async def test_skips_validation_when_plugin_disabled(self):
- """Test that engine validation is skipped when plugin disabled."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_app.plugin_connector.is_enable_plugin = False
- mock_app.persistence_mgr.execute_async = AsyncMock()
- mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
+ app = _app()
+ app.plugin_connector.is_enable_plugin = False
- manager = rag_module.RAGManager(mock_app)
-
- # Should not raise even though engine list would be empty
- kb = await manager.create_knowledge_base(
+ kb = await RAGManager(app).create_knowledge_base(
+ CONTEXT_A,
name='Test KB',
knowledge_engine_plugin_id='any/engine',
creation_settings={},
)
assert kb.knowledge_engine_plugin_id == 'any/engine'
+ app.plugin_connector.list_knowledge_engines.assert_not_awaited()
class TestRuntimeKnowledgeBaseOnKBCreate:
- """Tests for _on_kb_create method."""
-
@pytest.mark.asyncio
async def test_calls_plugin_on_create(self):
- """Test that plugin is notified on KB create."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
- mock_kb.creation_settings = {'model': 'test'}
+ app = _app()
+ entity = _entity()
+ entity.creation_settings = {'model': 'test'}
- mock_app.plugin_connector.rag_on_kb_create = AsyncMock()
+ await RuntimeKnowledgeBase(app, entity, CONTEXT_A)._on_kb_create(CONTEXT_A)
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
- await runtime_kb._on_kb_create()
-
- mock_app.plugin_connector.rag_on_kb_create.assert_called_once_with(
- 'author/engine', mock_kb.uuid, {'model': 'test'}
+ app.plugin_connector.rag_on_kb_create.assert_awaited_once_with(
+ 'author/engine',
+ entity.uuid,
+ {'model': 'test'},
)
@pytest.mark.asyncio
async def test_skips_when_no_plugin_id(self):
- """Test that create notification is skipped when no plugin."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
- mock_kb.knowledge_engine_plugin_id = None
+ app = _app()
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
- await runtime_kb._on_kb_create()
+ await RuntimeKnowledgeBase(
+ app,
+ _entity(plugin_id=None),
+ CONTEXT_A,
+ )._on_kb_create(CONTEXT_A)
- mock_app.plugin_connector.rag_on_kb_create.assert_not_called()
+ app.plugin_connector.rag_on_kb_create.assert_not_awaited()
@pytest.mark.asyncio
async def test_raises_on_plugin_error(self):
- """Test that exception is raised when plugin fails."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
+ app = _app()
+ app.plugin_connector.rag_on_kb_create.side_effect = RuntimeError('Plugin failed')
- mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Plugin failed'))
-
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- with pytest.raises(Exception):
- await runtime_kb._on_kb_create()
+ with pytest.raises(RuntimeError, match='Plugin failed'):
+ await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)._on_kb_create(CONTEXT_A)
class TestRuntimeKnowledgeBaseDeleteFile:
- """Tests for delete_file method."""
-
@pytest.mark.asyncio
async def test_delete_file_calls_plugin_and_db(self):
- """Test that delete_file calls plugin and removes DB record."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
+ app = _app()
+ app.persistence_mgr.execute_async.return_value = _Result(first=('file-uuid',))
- mock_app.plugin_connector.call_rag_delete_document = AsyncMock(return_value=True)
+ await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).delete_file(
+ CONTEXT_A,
+ 'file-uuid',
+ )
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
- await runtime_kb.delete_file('file-uuid')
-
- mock_app.plugin_connector.call_rag_delete_document.assert_called_once()
- mock_app.persistence_mgr.execute_async.assert_called()
+ app.plugin_connector.call_rag_delete_document.assert_awaited_once_with(
+ 'author/engine',
+ 'file-uuid',
+ 'kb-a',
+ )
+ assert app.persistence_mgr.execute_async.await_count == 2
class TestRuntimeKnowledgeBaseIngestDocument:
- """Tests for _ingest_document method."""
-
@pytest.mark.asyncio
async def test_ingest_calls_plugin(self):
- """Test that ingest calls plugin connector."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
+ app = _app()
- mock_app.plugin_connector.call_rag_ingest = AsyncMock(return_value={'status': 'success'})
-
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- result = await runtime_kb._ingest_document(
+ result = await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)._ingest_document(
+ CONTEXT_A,
{'filename': 'test.pdf'},
'storage/path',
)
- assert result['status'] == 'success'
- mock_app.plugin_connector.call_rag_ingest.assert_called_once()
+ assert result == {'status': 'success'}
+ app.plugin_connector.call_rag_ingest.assert_awaited_once()
@pytest.mark.asyncio
async def test_ingest_raises_when_no_plugin_id(self):
- """Test that ValueError is raised when no plugin ID."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
- mock_kb.knowledge_engine_plugin_id = None
+ app = _app()
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- with pytest.raises(ValueError) as exc_info:
- await runtime_kb._ingest_document({'filename': 'test.pdf'}, 'path')
-
- assert 'Plugin ID required' in str(exc_info.value)
+ with pytest.raises(ValueError, match='Plugin ID required'):
+ await RuntimeKnowledgeBase(
+ app,
+ _entity(plugin_id=None),
+ CONTEXT_A,
+ )._ingest_document(CONTEXT_A, {'filename': 'test.pdf'}, 'path')
class TestRAGManagerLoadKnowledgeBasesFromDB:
- """Tests for load_knowledge_bases_from_db method."""
-
@pytest.mark.asyncio
async def test_loads_all_kbs_from_db(self):
- """Test that all KBs are loaded from database."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ kb1 = _entity(kb_uuid='kb-1')
+ kb2 = _entity(kb_uuid='kb-2')
+ app.persistence_mgr.execute_async.return_value = _Result([kb1, kb2])
+ manager = RAGManager(app)
- mock_kb1 = create_mock_kb_entity()
- mock_kb2 = create_mock_kb_entity()
- mock_app.persistence_mgr.execute_async = AsyncMock(
- return_value=Mock(all=Mock(return_value=[mock_kb1, mock_kb2]))
- )
-
- manager = rag_module.RAGManager(mock_app)
await manager.load_knowledge_bases_from_db()
- assert len(manager.knowledge_bases) == 2
+ assert set(manager.knowledge_bases) == {
+ ('workspace-a', 'kb-1'),
+ ('workspace-a', 'kb-2'),
+ }
+
+ @pytest.mark.asyncio
+ async def test_cloud_startup_reuses_validated_binding(self):
+ class TenantUow:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *_args):
+ return False
+
+ app = _app()
+ binding = WorkspaceExecutionBinding(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=5,
+ write_fenced=False,
+ state='active',
+ )
+ app.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
+ app.persistence_mgr.tenant_uow = lambda _workspace_uuid: TenantUow()
+ app.persistence_mgr.execute_async.return_value = _Result([_entity()])
+ app.workspace_service.list_active_execution_bindings = AsyncMock(return_value=[binding])
+ app.workspace_service.get_execution_binding = AsyncMock(
+ side_effect=AssertionError('startup RAG loader repeated a validated binding lookup')
+ )
+ manager = RAGManager(app)
+
+ await manager.load_knowledge_bases_from_db()
+
+ assert set(manager.knowledge_bases) == {('workspace-a', 'kb-a')}
+ app.workspace_service.get_execution_binding.assert_not_awaited()
@pytest.mark.asyncio
async def test_handles_load_error_gracefully(self):
- """Test that load errors are logged but not raised."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ app.persistence_mgr.execute_async.return_value = _Result([_entity()])
+ app.workspace_service.get_execution_binding.side_effect = RuntimeError('binding unavailable')
+ manager = RAGManager(app)
- # KB that will cause initialize to fail
- mock_kb = create_mock_kb_entity()
-
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[mock_kb])))
-
- # Make initialize fail by having plugin_connector throw error
- mock_app.plugin_connector.rag_on_kb_create = AsyncMock(side_effect=Exception('Init failed'))
-
- manager = rag_module.RAGManager(mock_app)
- # Should not raise - errors are caught
await manager.load_knowledge_bases_from_db()
- # KB should still be loaded (initialize just passes)
- # The error would come from runtime_kb.initialize which we can't easily mock
- # So we just verify it doesn't crash
+ assert manager.knowledge_bases == {}
+ app.logger.error.assert_called_once()
class TestRuntimeKnowledgeBaseGetters:
- """Tests for RuntimeKnowledgeBase getter methods."""
-
def test_get_uuid_returns_entity_uuid(self):
- """Test get_uuid returns KB entity UUID."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
+ entity = _entity()
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- assert runtime_kb.get_uuid() == mock_kb.uuid
+ assert RuntimeKnowledgeBase(_app(), entity, CONTEXT_A).get_uuid() == entity.uuid
def test_get_name_returns_entity_name(self):
- """Test get_name returns KB entity name."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
+ entity = _entity()
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- assert runtime_kb.get_name() == mock_kb.name
+ assert RuntimeKnowledgeBase(_app(), entity, CONTEXT_A).get_name() == entity.name
def test_get_knowledge_engine_plugin_id_returns_plugin_id(self):
- """Test get_knowledge_engine_plugin_id returns plugin ID."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
+ runtime = RuntimeKnowledgeBase(_app(), _entity(), CONTEXT_A)
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- assert runtime_kb.get_knowledge_engine_plugin_id() == 'author/engine'
+ assert runtime.get_knowledge_engine_plugin_id() == 'author/engine'
def test_get_knowledge_engine_plugin_id_returns_empty_when_none(self):
- """Test returns empty string when plugin_id is None."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
- mock_kb.knowledge_engine_plugin_id = None
+ runtime = RuntimeKnowledgeBase(_app(), _entity(plugin_id=None), CONTEXT_A)
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- assert runtime_kb.get_knowledge_engine_plugin_id() == ''
+ assert runtime.get_knowledge_engine_plugin_id() == ''
class TestRuntimeKnowledgeBaseRetrieve:
- """Tests for RuntimeKnowledgeBase retrieve method."""
-
@pytest.mark.asyncio
async def test_retrieve_merges_settings(self):
- """Test that retrieve merges stored and request settings."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
- mock_kb.retrieval_settings = {'top_k': 10, 'model': 'default'}
+ app = _app()
+ entity = _entity()
+ entity.retrieval_settings = {'top_k': 10, 'model': 'default'}
+ app.plugin_connector.call_rag_retrieve.return_value = {
+ 'results': [
+ {
+ 'id': 'doc1',
+ 'content': [{'type': 'text', 'text': 'test content'}],
+ 'metadata': {},
+ 'distance': 0.1,
+ }
+ ]
+ }
- # Mock plugin connector response with valid RetrievalResultEntry fields
- # content must be list of ContentElement dicts
- mock_app.plugin_connector.call_rag_retrieve = AsyncMock(
- return_value={
- 'results': [
- {
- 'id': 'doc1',
- 'content': [{'type': 'text', 'text': 'test content'}],
- 'metadata': {},
- 'distance': 0.1,
- }
- ]
- }
+ results = await RuntimeKnowledgeBase(app, entity, CONTEXT_A).retrieve(
+ CONTEXT_A,
+ 'query text',
+ settings={'top_k': 20},
)
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- # Override top_k in request
- results = await runtime_kb.retrieve('query text', settings={'top_k': 20})
-
assert len(results) == 1
- # Check that merged settings were passed (top_k overridden)
- call_args = mock_app.plugin_connector.call_rag_retrieve.call_args
- assert call_args[0][1]['retrieval_settings']['top_k'] == 20
+ payload = app.plugin_connector.call_rag_retrieve.await_args.args[1]
+ assert payload['retrieval_settings'] == {'top_k': 20, 'model': 'default'}
@pytest.mark.asyncio
async def test_retrieve_adds_default_top_k(self):
- """Test that default top_k=5 is added when not specified."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
- mock_kb.retrieval_settings = {}
+ app = _app()
- mock_app.plugin_connector.call_rag_retrieve = AsyncMock(return_value={'results': []})
+ await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).retrieve(
+ CONTEXT_A,
+ 'query text',
+ )
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- await runtime_kb.retrieve('query text')
-
- call_args = mock_app.plugin_connector.call_rag_retrieve.call_args
- assert call_args[0][1]['retrieval_settings']['top_k'] == 5
+ payload = app.plugin_connector.call_rag_retrieve.await_args.args[1]
+ assert payload['retrieval_settings']['top_k'] == 5
@pytest.mark.asyncio
async def test_retrieve_converts_dict_to_entry(self):
- """Test that dict results are converted to RetrievalResultEntry."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
+ app = _app()
+ app.plugin_connector.call_rag_retrieve.return_value = {
+ 'results': [
+ {
+ 'id': 'doc1',
+ 'content': [{'type': 'text', 'text': 'test content'}],
+ 'metadata': {'source': 'file.pdf'},
+ 'distance': 0.15,
+ }
+ ]
+ }
- # Mock response with valid RetrievalResultEntry fields
- # content must be list of ContentElement dicts
- mock_app.plugin_connector.call_rag_retrieve = AsyncMock(
- return_value={
- 'results': [
- {
- 'id': 'doc1',
- 'content': [{'type': 'text', 'text': 'test content'}],
- 'metadata': {'source': 'file.pdf'},
- 'distance': 0.15,
- }
- ]
- }
+ results = await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).retrieve(
+ CONTEXT_A,
+ 'query',
)
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- results = await runtime_kb.retrieve('query')
-
assert len(results) == 1
- # Result should be RetrievalResultEntry
- assert hasattr(results[0], 'content')
assert results[0].id == 'doc1'
+ assert hasattr(results[0], 'content')
class TestRuntimeKnowledgeBaseDispose:
- """Tests for RuntimeKnowledgeBase dispose method."""
-
@pytest.mark.asyncio
async def test_dispose_calls_on_kb_delete(self):
- """Test that dispose calls _on_kb_delete."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
+ app = _app()
- mock_app.plugin_connector.rag_on_kb_delete = AsyncMock()
+ await RuntimeKnowledgeBase(app, _entity(), CONTEXT_A).dispose(CONTEXT_A)
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
-
- await runtime_kb.dispose()
-
- mock_app.plugin_connector.rag_on_kb_delete.assert_called_once()
+ app.plugin_connector.rag_on_kb_delete.assert_awaited_once_with(
+ 'author/engine',
+ 'kb-a',
+ )
@pytest.mark.asyncio
async def test_dispose_skips_when_no_plugin_id(self):
- """Test that dispose skips when no plugin ID."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_kb = create_mock_kb_entity()
- mock_kb.knowledge_engine_plugin_id = None
+ app = _app()
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
+ await RuntimeKnowledgeBase(
+ app,
+ _entity(plugin_id=None),
+ CONTEXT_A,
+ ).dispose(CONTEXT_A)
- await runtime_kb.dispose()
-
- # Should not call plugin connector
- mock_app.plugin_connector.rag_on_kb_delete.assert_not_called()
+ app.plugin_connector.rag_on_kb_delete.assert_not_awaited()
class TestRAGManagerInit:
- """Tests for RAGManager initialization."""
-
def test_init_stores_app_reference(self):
- """Test that __init__ stores Application reference."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
- manager = rag_module.RAGManager(mock_app)
-
- assert manager.ap is mock_app
+ assert RAGManager(app).ap is app
def test_init_creates_empty_knowledge_bases_dict(self):
- """Test that knowledge_bases starts as empty dict."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ assert RAGManager(_app()).knowledge_bases == {}
- manager = rag_module.RAGManager(mock_app)
+ def test_generation_advance_prunes_superseded_runtime_knowledge_bases(self):
+ class NoGlobalItemsScan(dict):
+ def items(self):
+ raise AssertionError('generation advance scanned every knowledge runtime')
+
+ app = _app()
+ manager = RAGManager(app)
+ manager._cache_runtime(
+ RuntimeKnowledgeBase(
+ app,
+ _entity(),
+ CONTEXT_A,
+ )
+ )
+ manager.knowledge_bases = NoGlobalItemsScan(manager.knowledge_bases)
+
+ next_context = dataclasses.replace(CONTEXT_A, placement_generation=6)
+ manager._observe_execution_context(next_context)
assert manager.knowledge_bases == {}
+ with pytest.raises(WorkspaceInvariantError, match='rolled back'):
+ manager._observe_execution_context(CONTEXT_A)
class TestRAGManagerGetKnowledgeBase:
- """Tests for RAGManager get methods."""
-
@pytest.mark.asyncio
async def test_get_knowledge_base_by_uuid_returns_runtime_kb(self):
- """Test get_knowledge_base_by_uuid returns loaded KB."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ manager = RAGManager(app)
+ runtime = RuntimeKnowledgeBase(app, _entity(), CONTEXT_A)
+ manager.knowledge_bases[('workspace-a', 'kb-a')] = runtime
- manager = rag_module.RAGManager(mock_app)
- mock_kb = create_mock_kb_entity()
+ result = await manager.get_knowledge_base_by_uuid(CONTEXT_A, 'kb-a')
- # Manually add to knowledge_bases
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
- manager.knowledge_bases[mock_kb.uuid] = runtime_kb
-
- result = await manager.get_knowledge_base_by_uuid(mock_kb.uuid)
-
- assert result is runtime_kb
+ assert result is runtime
@pytest.mark.asyncio
async def test_get_knowledge_base_by_uuid_returns_none_when_not_found(self):
- """Test returns None when KB not in runtime."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
-
- manager = rag_module.RAGManager(mock_app)
-
- result = await manager.get_knowledge_base_by_uuid('nonexistent-uuid')
-
- assert result is None
+ assert (
+ await RAGManager(_app()).get_knowledge_base_by_uuid(
+ CONTEXT_A,
+ 'nonexistent-uuid',
+ )
+ is None
+ )
@pytest.mark.asyncio
async def test_remove_knowledge_base_from_runtime(self):
- """Test remove_knowledge_base_from_runtime removes KB."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ manager = RAGManager(app)
+ manager.knowledge_bases[('workspace-a', 'kb-a')] = RuntimeKnowledgeBase(
+ app,
+ _entity(),
+ CONTEXT_A,
+ )
- manager = rag_module.RAGManager(mock_app)
- mock_kb = create_mock_kb_entity()
+ await manager.remove_knowledge_base_from_runtime(CONTEXT_A, 'kb-a')
- # Add to knowledge_bases
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
- manager.knowledge_bases[mock_kb.uuid] = runtime_kb
-
- await manager.remove_knowledge_base_from_runtime(mock_kb.uuid)
-
- assert mock_kb.uuid not in manager.knowledge_bases
+ assert ('workspace-a', 'kb-a') not in manager.knowledge_bases
class TestRAGManagerEnrichKB:
- """Tests for _enrich_kb_dict method."""
-
def test_enrich_adds_engine_info_from_map(self):
- """Test that engine info is added from engine_map."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
-
- manager = rag_module.RAGManager(mock_app)
-
kb_dict = {'knowledge_engine_plugin_id': 'author/engine'}
engine_map = {
'author/engine': {
@@ -564,208 +748,133 @@ class TestRAGManagerEnrichKB:
}
}
- manager._enrich_kb_dict(kb_dict, engine_map)
+ RAGManager(_app())._enrich_kb_dict(kb_dict, engine_map)
- assert 'knowledge_engine' in kb_dict
assert kb_dict['knowledge_engine']['plugin_id'] == 'author/engine'
assert kb_dict['knowledge_engine']['capabilities'] == ['doc_ingestion', 'search']
def test_enrich_uses_fallback_when_engine_not_in_map(self):
- """Test that fallback info is used when engine not found."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
-
- manager = rag_module.RAGManager(mock_app)
-
kb_dict = {'knowledge_engine_plugin_id': 'unknown/engine'}
- engine_map = {}
- manager._enrich_kb_dict(kb_dict, engine_map)
+ RAGManager(_app())._enrich_kb_dict(kb_dict, {})
- assert 'knowledge_engine' in kb_dict
assert kb_dict['knowledge_engine']['plugin_id'] == 'unknown/engine'
assert kb_dict['knowledge_engine']['capabilities'] == []
def test_enrich_uses_fallback_when_no_plugin_id(self):
- """Test that fallback is used when no plugin ID."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
-
- manager = rag_module.RAGManager(mock_app)
-
kb_dict = {}
- engine_map = {}
- manager._enrich_kb_dict(kb_dict, engine_map)
+ RAGManager(_app())._enrich_kb_dict(kb_dict, {})
- assert 'knowledge_engine' in kb_dict
- # Should have Internal (Legacy) name
+ assert kb_dict['knowledge_engine']['plugin_id'] is None
assert 'en_US' in kb_dict['knowledge_engine']['name']
def test_enrich_converts_string_name_to_i18n(self):
- """Test that engine name is converted to i18n dict."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
-
- manager = rag_module.RAGManager(mock_app)
-
kb_dict = {'knowledge_engine_plugin_id': 'author/engine'}
engine_map = {
'author/engine': {
'plugin_id': 'author/engine',
- 'name': 'Simple Name', # String, not dict
+ 'name': 'Simple Name',
'capabilities': [],
}
}
- manager._enrich_kb_dict(kb_dict, engine_map)
+ RAGManager(_app())._enrich_kb_dict(kb_dict, engine_map)
- # Name should be converted to i18n dict
- engine_name = kb_dict['knowledge_engine']['name']
- assert isinstance(engine_name, dict)
- assert engine_name['en_US'] == 'Simple Name'
+ assert kb_dict['knowledge_engine']['name'] == {
+ 'en_US': 'Simple Name',
+ 'zh_Hans': 'Simple Name',
+ }
class TestRAGManagerDeleteKnowledgeBase:
- """Tests for delete_knowledge_base method."""
-
@pytest.mark.asyncio
async def test_delete_removes_from_runtime_and_disposes(self):
- """Test that delete removes KB and calls dispose."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ manager = RAGManager(app)
+ manager.knowledge_bases[('workspace-a', 'kb-a')] = RuntimeKnowledgeBase(
+ app,
+ _entity(),
+ CONTEXT_A,
+ )
- manager = rag_module.RAGManager(mock_app)
- mock_kb = create_mock_kb_entity()
+ await manager.delete_knowledge_base(CONTEXT_A, 'kb-a')
- # Add to knowledge_bases
- runtime_kb = rag_module.RuntimeKnowledgeBase(mock_app, mock_kb)
- manager.knowledge_bases[mock_kb.uuid] = runtime_kb
-
- await manager.delete_knowledge_base(mock_kb.uuid)
-
- assert mock_kb.uuid not in manager.knowledge_bases
+ assert ('workspace-a', 'kb-a') not in manager.knowledge_bases
+ app.plugin_connector.rag_on_kb_delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_logs_warning_when_not_in_runtime(self):
- """Test that warning is logged when KB not in runtime."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
- manager = rag_module.RAGManager(mock_app)
+ await RAGManager(app).delete_knowledge_base(CONTEXT_A, 'nonexistent-uuid')
- await manager.delete_knowledge_base('nonexistent-uuid')
-
- mock_app.logger.warning.assert_called_once()
+ app.logger.warning.assert_called_once()
class TestRAGManagerGetAllDetails:
- """Tests for get_all_knowledge_base_details method."""
-
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_kbs(self):
- """Test returns empty list when no knowledge bases."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
-
- manager = rag_module.RAGManager(mock_app)
- result = await manager.get_all_knowledge_base_details()
-
- assert result == []
+ assert await RAGManager(_app()).get_all_knowledge_base_details(CONTEXT_A) == []
@pytest.mark.asyncio
async def test_enriches_each_kb_with_engine_info(self):
- """Test that each KB is enriched with engine info."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ app.persistence_mgr.execute_async.return_value = _Result([_entity()])
- # Mock DB result
- mock_kb_row = Mock()
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[mock_kb_row])))
- mock_app.persistence_mgr.serialize_model = Mock(
- return_value={'uuid': 'kb1', 'knowledge_engine_plugin_id': 'author/engine'}
- )
- mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
- return_value=[{'plugin_id': 'author/engine', 'name': 'Engine', 'capabilities': ['search']}]
- )
-
- manager = rag_module.RAGManager(mock_app)
- result = await manager.get_all_knowledge_base_details()
+ result = await RAGManager(app).get_all_knowledge_base_details(CONTEXT_A)
assert len(result) == 1
- assert 'knowledge_engine' in result[0]
+ assert result[0]['knowledge_engine']['plugin_id'] == 'author/engine'
class TestRAGManagerGetDetails:
- """Tests for get_knowledge_base_details method."""
-
@pytest.mark.asyncio
async def test_returns_none_when_kb_not_found(self):
- """Test returns None when KB doesn't exist."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
-
- manager = rag_module.RAGManager(mock_app)
- result = await manager.get_knowledge_base_details('nonexistent')
-
- assert result is None
+ assert (
+ await RAGManager(_app()).get_knowledge_base_details(
+ CONTEXT_A,
+ 'nonexistent',
+ )
+ is None
+ )
@pytest.mark.asyncio
async def test_returns_enriched_kb_dict(self):
- """Test returns enriched KB dict when found."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ app = _app()
+ app.persistence_mgr.execute_async.return_value = _Result(first=_entity())
- mock_kb_row = Mock()
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=mock_kb_row)))
- mock_app.persistence_mgr.serialize_model = Mock(
- return_value={'uuid': 'kb1', 'knowledge_engine_plugin_id': 'author/engine'}
- )
- mock_app.plugin_connector.list_knowledge_engines = AsyncMock(
- return_value=[{'plugin_id': 'author/engine', 'name': 'Engine', 'capabilities': []}]
- )
-
- manager = rag_module.RAGManager(mock_app)
- result = await manager.get_knowledge_base_details('kb1')
+ result = await RAGManager(app).get_knowledge_base_details(CONTEXT_A, 'kb-a')
assert result is not None
- assert 'knowledge_engine' in result
+ assert result['knowledge_engine']['plugin_id'] == 'author/engine'
class TestRAGManagerLoadKnowledgeBase:
- """Tests for load_knowledge_base method."""
-
@pytest.mark.asyncio
async def test_loads_kb_entity_into_runtime(self):
- """Test that KB entity is loaded into runtime."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
+ manager = RAGManager(_app())
- manager = rag_module.RAGManager(mock_app)
- mock_kb = create_mock_kb_entity()
+ result = await manager.load_knowledge_base(CONTEXT_A, _entity())
- result = await manager.load_knowledge_base(mock_kb)
-
- assert mock_kb.uuid in manager.knowledge_bases
- assert result.get_uuid() == mock_kb.uuid
+ assert ('workspace-a', 'kb-a') in manager.knowledge_bases
+ assert result.get_uuid() == 'kb-a'
@pytest.mark.asyncio
async def test_load_handles_dict_entity(self):
- """Test that dict entity is converted to KB object."""
- rag_module = get_rag_module()
- mock_app = create_mock_app()
-
- manager = rag_module.RAGManager(mock_app)
-
+ manager = RAGManager(_app())
kb_dict = {
'uuid': 'kb-uuid',
+ 'workspace_uuid': 'workspace-a',
'name': 'Test',
+ 'description': '',
'knowledge_engine_plugin_id': 'author/engine',
- 'knowledge_engine': {'name': 'should_be_filtered'}, # non-db field
+ 'collection_id': 'kb-uuid',
+ 'creation_settings': {},
+ 'retrieval_settings': {},
+ 'knowledge_engine': {'name': 'should_be_filtered'},
}
- await manager.load_knowledge_base(kb_dict)
+ await manager.load_knowledge_base(CONTEXT_A, kb_dict)
- assert 'kb-uuid' in manager.knowledge_bases
+ assert ('workspace-a', 'kb-uuid') in manager.knowledge_bases
diff --git a/tests/unit_tests/rag/test_runtime_service.py b/tests/unit_tests/rag/test_runtime_service.py
index 650b3bf2f..11bb3807a 100644
--- a/tests/unit_tests/rag/test_runtime_service.py
+++ b/tests/unit_tests/rag/test_runtime_service.py
@@ -1,474 +1,467 @@
-"""Tests for RAGRuntimeService.
-
-Tests the service that handles RAG-related requests from plugins,
-using mocked vector_db_mgr and storage_mgr.
-"""
+"""Tenant-aware tests for the plugin-facing RAG runtime service."""
from __future__ import annotations
-from unittest.mock import AsyncMock, MagicMock
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
import pytest
-from tests.utils.import_isolation import isolated_sys_modules
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.rag.service.runtime import RAGRuntimeService
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
-class TestRAGRuntimeServiceVectorUpsert:
- """Tests for vector_upsert method."""
+WORKSPACE_UUID = '00000000-0000-0000-0000-00000000000a'
+CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid=WORKSPACE_UUID,
+ placement_generation=4,
+)
- def _create_mock_app(self):
- """Create mock app with vector_db_mgr and storage_mgr."""
- mock_app = MagicMock()
- mock_app.vector_db_mgr = MagicMock()
- mock_app.vector_db_mgr.upsert = AsyncMock()
- mock_app.storage_mgr = MagicMock()
- mock_app.storage_mgr.storage_provider = MagicMock()
- mock_app.storage_mgr.storage_provider.load = AsyncMock(return_value=b'content')
- return mock_app
- def _make_rag_import_mocks(self):
- """Create mocks needed for importing RAG service."""
- return {
- 'langbot.pkg.core.app': MagicMock(),
- 'langbot_plugin.api.entities.builtin.rag': MagicMock(),
- }
+class _ScalarResult:
+ def __init__(self, value):
+ self.value = value
+ def scalar_one_or_none(self):
+ return self.value
+
+ def first(self):
+ return None if self.value is None else (self.value,)
+
+
+def _app(*, kb_uuid='kb-a', file_exists=True):
+ persistence_results = [_ScalarResult(kb_uuid)]
+ if file_exists is not None:
+ persistence_results.append(_ScalarResult('file-a' if file_exists else None))
+ return SimpleNamespace(
+ workspace_service=SimpleNamespace(
+ get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid='instance-a'))
+ ),
+ persistence_mgr=SimpleNamespace(execute_async=AsyncMock(side_effect=persistence_results)),
+ vector_db_mgr=SimpleNamespace(
+ upsert=AsyncMock(),
+ search=AsyncMock(return_value=[{'id': 'chunk-a'}]),
+ delete_by_file_id=AsyncMock(),
+ delete_by_filter=AsyncMock(return_value=3),
+ list_by_filter=AsyncMock(return_value=([{'id': 'chunk-a'}], 1)),
+ ),
+ storage_mgr=SimpleNamespace(
+ load_scoped_object_key=AsyncMock(return_value=b'content'),
+ storage_provider=SimpleNamespace(load=AsyncMock(return_value=b'content')),
+ ),
+ )
+
+
+@pytest.mark.asyncio
+async def test_vector_upsert_resolves_canonical_kb_and_forwards_trusted_context():
+ app = _app()
+ service = RAGRuntimeService(app)
+
+ await service.vector_upsert(
+ CONTEXT,
+ 'logical-collection',
+ [[0.1, 0.2]],
+ ['chunk-a'],
+ metadata=[{'file_id': 'file-a'}],
+ documents=['hello'],
+ )
+
+ app.vector_db_mgr.upsert.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ vectors=[[0.1, 0.2]],
+ ids=['chunk-a'],
+ metadata=[{'file_id': 'file-a'}],
+ documents=['hello'],
+ )
+
+
+@pytest.mark.asyncio
+async def test_vector_upsert_rejects_mismatched_lengths():
+ service = RAGRuntimeService(_app())
+ with pytest.raises(ValueError, match='vectors and ids'):
+ await service.vector_upsert(CONTEXT, 'kb-a', [[0.1]], ['a', 'b'])
+
+
+@pytest.mark.asyncio
+async def test_unknown_or_cross_workspace_collection_is_not_forwarded():
+ app = _app(kb_uuid=None)
+ service = RAGRuntimeService(app)
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await service.vector_search(CONTEXT, 'kb-from-other-workspace', [0.1], 5)
+ app.vector_db_mgr.search.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_vector_search_forwards_all_search_options():
+ app = _app()
+ service = RAGRuntimeService(app)
+
+ result = await service.vector_search(
+ CONTEXT,
+ 'kb-a',
+ [0.1, 0.2],
+ 7,
+ filters={'file_id': 'file-a'},
+ search_type='hybrid',
+ query_text='hello',
+ vector_weight=0.7,
+ )
+
+ assert result == [{'id': 'chunk-a'}]
+ app.vector_db_mgr.search.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ query_vector=[0.1, 0.2],
+ limit=7,
+ filter={'file_id': 'file-a'},
+ search_type='hybrid',
+ query_text='hello',
+ vector_weight=0.7,
+ )
+
+
+@pytest.mark.asyncio
+async def test_vector_delete_and_list_stay_on_canonical_kb():
+ delete_app = _app()
+ delete_service = RAGRuntimeService(delete_app)
+ assert await delete_service.vector_delete(CONTEXT, 'logical', file_ids=['file-a']) == 1
+ delete_app.vector_db_mgr.delete_by_file_id.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ file_ids=['file-a'],
+ )
+
+ filter_app = _app()
+ filter_service = RAGRuntimeService(filter_app)
+ assert await filter_service.vector_delete(CONTEXT, 'logical', filters={'page': 1}) == 3
+ filter_app.vector_db_mgr.delete_by_filter.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ filter={'page': 1},
+ )
+
+ list_app = _app()
+ list_service = RAGRuntimeService(list_app)
+ assert await list_service.vector_list(CONTEXT, 'logical', {'page': 1}, 10, 2) == (
+ [{'id': 'chunk-a'}],
+ 1,
+ )
+ list_app.vector_db_mgr.list_by_filter.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ filter={'page': 1},
+ limit=10,
+ offset=2,
+ )
+
+
+@pytest.mark.asyncio
+async def test_file_stream_requires_workspace_owned_file():
+ app = _app(file_exists=True)
+ app.persistence_mgr.execute_async = AsyncMock(return_value=_ScalarResult('file-a'))
+ service = RAGRuntimeService(app)
+ assert await service.get_file_stream(CONTEXT, 'nested/file.pdf') == b'content'
+ app.storage_mgr.load_scoped_object_key.assert_awaited_once_with(
+ CONTEXT,
+ 'nested/file.pdf',
+ expected_owner_type='upload_document',
+ )
+
+ missing_app = _app(file_exists=False)
+ missing_app.persistence_mgr.execute_async = AsyncMock(return_value=_ScalarResult(None))
+ missing_service = RAGRuntimeService(missing_app)
+ with pytest.raises(WorkspaceNotFoundError):
+ await missing_service.get_file_stream(CONTEXT, 'other.pdf')
+ missing_app.storage_mgr.load_scoped_object_key.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'unsafe_path',
+ [
+ '',
+ '../secret.txt',
+ '/absolute/path.txt',
+ '..\\secret.txt',
+ 'nested\\..\\secret.txt',
+ '%2e%2e/secret.txt',
+ 'nested/%2e%2e/secret.txt',
+ 'C:\\secret.txt',
+ 'safe/\x00file.txt',
+ ],
+)
+async def test_file_stream_rejects_unsafe_paths_before_storage(unsafe_path):
+ app = _app(file_exists=True)
+ service = RAGRuntimeService(app)
+ with pytest.raises(ValueError, match='Invalid storage path'):
+ await service.get_file_stream(CONTEXT, unsafe_path)
+ app.storage_mgr.load_scoped_object_key.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_runtime_rejects_wrong_instance_binding():
+ app = _app()
+ app.workspace_service.get_execution_binding.return_value = SimpleNamespace(instance_uuid='instance-b')
+ service = RAGRuntimeService(app)
+
+ with pytest.raises(Exception, match='another LangBot instance'):
+ await service.vector_search(CONTEXT, 'kb-a', [0.1], 1)
+ app.persistence_mgr.execute_async.assert_not_awaited()
+
+
+# The following classes preserve the pre-tenancy regression scenarios. They
+# intentionally exercise the same inputs through the new trusted
+# ExecutionContext and assert the canonical Workspace-owned KB forwarded to the
+# vector layer.
+class TestRAGRuntimeServiceVectorUpsertRegression:
@pytest.mark.asyncio
async def test_vector_upsert_basic(self):
- """Basic vector upsert delegates to vector_db_mgr."""
- mock_app = self._create_mock_app()
+ app = _app()
+ service = RAGRuntimeService(app)
+ vectors = [[0.1, 0.2], [0.3, 0.4]]
+ ids = ['id1', 'id2']
- mocks = self._make_rag_import_mocks()
+ await service.vector_upsert(CONTEXT, 'test_collection', vectors, ids)
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- vectors = [[0.1, 0.2], [0.3, 0.4]]
- ids = ['id1', 'id2']
-
- await service.vector_upsert(
- collection_id='test_collection',
- vectors=vectors,
- ids=ids,
- )
-
- mock_app.vector_db_mgr.upsert.assert_called_once()
- call_args = mock_app.vector_db_mgr.upsert.call_args
- assert call_args.kwargs['collection_name'] == 'test_collection'
- assert call_args.kwargs['vectors'] == vectors
- assert call_args.kwargs['ids'] == ids
- # Default metadata is empty dicts
- assert call_args.kwargs['metadata'] == [{} for _ in vectors]
+ app.vector_db_mgr.upsert.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ vectors=vectors,
+ ids=ids,
+ metadata=[{}, {}],
+ documents=None,
+ )
@pytest.mark.asyncio
async def test_vector_upsert_with_metadata(self):
- """Vector upsert with provided metadata."""
- mock_app = self._create_mock_app()
+ app = _app()
+ metadata = [{'file_id': 'abc', 'page': 1}]
- mocks = self._make_rag_import_mocks()
+ await RAGRuntimeService(app).vector_upsert(
+ CONTEXT,
+ 'test',
+ [[0.1, 0.2]],
+ ['id1'],
+ metadata=metadata,
+ )
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- vectors = [[0.1, 0.2]]
- ids = ['id1']
- metadata = [{'file_id': 'abc', 'page': 1}]
-
- await service.vector_upsert(
- collection_id='test',
- vectors=vectors,
- ids=ids,
- metadata=metadata,
- )
-
- call_args = mock_app.vector_db_mgr.upsert.call_args
- assert call_args.kwargs['metadata'] == metadata
+ assert app.vector_db_mgr.upsert.await_args.kwargs['metadata'] == metadata
@pytest.mark.asyncio
async def test_vector_upsert_with_documents(self):
- """Vector upsert with documents for full-text search."""
- mock_app = self._create_mock_app()
+ app = _app()
+ documents = ['This is a test document']
- mocks = self._make_rag_import_mocks()
-
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- vectors = [[0.1, 0.2]]
- ids = ['id1']
- documents = ['This is a test document']
-
- await service.vector_upsert(
- collection_id='test',
- vectors=vectors,
- ids=ids,
- documents=documents,
- )
-
- call_args = mock_app.vector_db_mgr.upsert.call_args
- assert call_args.kwargs['documents'] == documents
-
-
-class TestRAGRuntimeServiceVectorSearch:
- """Tests for vector_search method."""
-
- def _create_mock_app(self):
- """Create mock app."""
- mock_app = MagicMock()
- mock_app.vector_db_mgr = MagicMock()
- mock_app.vector_db_mgr.search = AsyncMock(
- return_value=[
- {'id': 'id1', 'distance': 0.1, 'metadata': {'file_id': 'abc'}},
- {'id': 'id2', 'distance': 0.2, 'metadata': {'file_id': 'def'}},
- ]
+ await RAGRuntimeService(app).vector_upsert(
+ CONTEXT,
+ 'test',
+ [[0.1, 0.2]],
+ ['id1'],
+ documents=documents,
)
- return mock_app
- def _make_rag_import_mocks(self):
- return {
- 'langbot.pkg.core.app': MagicMock(),
- 'langbot_plugin.api.entities.builtin.rag': MagicMock(),
- }
+ assert app.vector_db_mgr.upsert.await_args.kwargs['documents'] == documents
+
+class TestRAGRuntimeServiceVectorSearchRegression:
@pytest.mark.asyncio
async def test_vector_search_basic(self):
- """Basic vector search delegates to vector_db_mgr."""
- mock_app = self._create_mock_app()
+ app = _app()
+ query_vector = [0.1, 0.2, 0.3]
- mocks = self._make_rag_import_mocks()
+ result = await RAGRuntimeService(app).vector_search(
+ CONTEXT,
+ 'test',
+ query_vector,
+ 5,
+ )
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- query_vector = [0.1, 0.2, 0.3]
-
- result = await service.vector_search(
- collection_id='test',
- query_vector=query_vector,
- top_k=5,
- )
-
- assert len(result) == 2
- mock_app.vector_db_mgr.search.assert_called_once()
- call_args = mock_app.vector_db_mgr.search.call_args
- assert call_args.kwargs['collection_name'] == 'test'
- assert call_args.kwargs['query_vector'] == query_vector
- assert call_args.kwargs['limit'] == 5
+ assert result == [{'id': 'chunk-a'}]
+ app.vector_db_mgr.search.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ query_vector=query_vector,
+ limit=5,
+ filter=None,
+ search_type='vector',
+ query_text='',
+ vector_weight=None,
+ )
@pytest.mark.asyncio
async def test_vector_search_with_filters(self):
- """Vector search with metadata filters."""
- mock_app = self._create_mock_app()
+ app = _app()
+ filters = {'file_id': 'abc'}
- mocks = self._make_rag_import_mocks()
+ await RAGRuntimeService(app).vector_search(
+ CONTEXT,
+ 'test',
+ [0.1, 0.2],
+ 10,
+ filters=filters,
+ )
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- filters = {'file_id': 'abc'}
-
- await service.vector_search(
- collection_id='test',
- query_vector=[0.1, 0.2],
- top_k=10,
- filters=filters,
- )
-
- call_args = mock_app.vector_db_mgr.search.call_args
- assert call_args.kwargs['filter'] == filters
+ assert app.vector_db_mgr.search.await_args.kwargs['filter'] == filters
@pytest.mark.asyncio
async def test_vector_search_hybrid_mode(self):
- """Vector search with hybrid search type."""
- mock_app = self._create_mock_app()
+ app = _app()
- mocks = self._make_rag_import_mocks()
+ await RAGRuntimeService(app).vector_search(
+ CONTEXT,
+ 'test',
+ [0.1, 0.2],
+ 10,
+ search_type='hybrid',
+ query_text='search query',
+ vector_weight=0.7,
+ )
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- await service.vector_search(
- collection_id='test',
- query_vector=[0.1, 0.2],
- top_k=10,
- search_type='hybrid',
- query_text='search query',
- vector_weight=0.7,
- )
-
- call_args = mock_app.vector_db_mgr.search.call_args
- assert call_args.kwargs['search_type'] == 'hybrid'
- assert call_args.kwargs['query_text'] == 'search query'
- assert call_args.kwargs['vector_weight'] == 0.7
+ kwargs = app.vector_db_mgr.search.await_args.kwargs
+ assert kwargs['search_type'] == 'hybrid'
+ assert kwargs['query_text'] == 'search query'
+ assert kwargs['vector_weight'] == 0.7
-class TestRAGRuntimeServiceVectorDelete:
- """Tests for vector_delete method."""
-
- def _create_mock_app(self):
- mock_app = MagicMock()
- mock_app.vector_db_mgr = MagicMock()
- mock_app.vector_db_mgr.delete_by_file_id = AsyncMock()
- mock_app.vector_db_mgr.delete_by_filter = AsyncMock(return_value=5)
- return mock_app
-
- def _make_rag_import_mocks(self):
- return {
- 'langbot.pkg.core.app': MagicMock(),
- 'langbot_plugin.api.entities.builtin.rag': MagicMock(),
- }
-
+class TestRAGRuntimeServiceVectorDeleteRegression:
@pytest.mark.asyncio
async def test_vector_delete_by_file_ids(self):
- """Delete by file_ids delegates to delete_by_file_id."""
- mock_app = self._create_mock_app()
+ app = _app()
- mocks = self._make_rag_import_mocks()
+ result = await RAGRuntimeService(app).vector_delete(
+ CONTEXT,
+ 'test',
+ file_ids=['file1', 'file2', 'file3'],
+ )
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- result = await service.vector_delete(
- collection_id='test',
- file_ids=['file1', 'file2', 'file3'],
- )
-
- assert result == 3 # Returns count of file_ids
- mock_app.vector_db_mgr.delete_by_file_id.assert_called_once()
- call_args = mock_app.vector_db_mgr.delete_by_file_id.call_args
- assert call_args.kwargs['collection_name'] == 'test'
- assert call_args.kwargs['file_ids'] == ['file1', 'file2', 'file3']
+ assert result == 3
+ app.vector_db_mgr.delete_by_file_id.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ file_ids=['file1', 'file2', 'file3'],
+ )
@pytest.mark.asyncio
async def test_vector_delete_by_filters(self):
- """Delete by filters delegates to delete_by_filter."""
- mock_app = self._create_mock_app()
+ app = _app()
+ filters = {'status': 'deleted'}
+ app.vector_db_mgr.delete_by_filter.return_value = 5
- mocks = self._make_rag_import_mocks()
+ result = await RAGRuntimeService(app).vector_delete(
+ CONTEXT,
+ 'test',
+ filters=filters,
+ )
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- filters = {'status': 'deleted'}
-
- result = await service.vector_delete(
- collection_id='test',
- filters=filters,
- )
-
- assert result == 5 # Returns count from delete_by_filter
- mock_app.vector_db_mgr.delete_by_filter.assert_called_once()
- call_args = mock_app.vector_db_mgr.delete_by_filter.call_args
- assert call_args.kwargs['collection_name'] == 'test'
- assert call_args.kwargs['filter'] == filters
+ assert result == 5
+ assert app.vector_db_mgr.delete_by_filter.await_args.kwargs['filter'] == filters
@pytest.mark.asyncio
async def test_vector_delete_no_params(self):
- """Delete with no params returns 0."""
- mock_app = self._create_mock_app()
+ app = _app()
- mocks = self._make_rag_import_mocks()
+ result = await RAGRuntimeService(app).vector_delete(CONTEXT, 'test')
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- result = await service.vector_delete(collection_id='test')
-
- assert result == 0
- mock_app.vector_db_mgr.delete_by_file_id.assert_not_called()
- mock_app.vector_db_mgr.delete_by_filter.assert_not_called()
+ assert result == 0
+ app.vector_db_mgr.delete_by_file_id.assert_not_awaited()
+ app.vector_db_mgr.delete_by_filter.assert_not_awaited()
-class TestRAGRuntimeServiceVectorList:
- """Tests for vector_list method."""
-
- def _create_mock_app(self):
- mock_app = MagicMock()
- mock_app.vector_db_mgr = MagicMock()
- mock_app.vector_db_mgr.list_by_filter = AsyncMock(
- return_value=([{'id': 'id1', 'metadata': {'file_id': 'abc'}}], 10)
- )
- return mock_app
-
- def _make_rag_import_mocks(self):
- return {
- 'langbot.pkg.core.app': MagicMock(),
- 'langbot_plugin.api.entities.builtin.rag': MagicMock(),
- }
-
+class TestRAGRuntimeServiceVectorListRegression:
@pytest.mark.asyncio
async def test_vector_list_basic(self):
- """Basic vector list delegates to vector_db_mgr."""
- mock_app = self._create_mock_app()
+ app = _app()
- mocks = self._make_rag_import_mocks()
+ items, total = await RAGRuntimeService(app).vector_list(CONTEXT, 'test')
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- items, total = await service.vector_list(
- collection_id='test',
- )
-
- assert len(items) == 1
- assert total == 10
- mock_app.vector_db_mgr.list_by_filter.assert_called_once()
- call_args = mock_app.vector_db_mgr.list_by_filter.call_args
- assert call_args.kwargs['collection_name'] == 'test'
- assert call_args.kwargs['limit'] == 20 # Default
- assert call_args.kwargs['offset'] == 0 # Default
+ assert items == [{'id': 'chunk-a'}]
+ assert total == 1
+ app.vector_db_mgr.list_by_filter.assert_awaited_once_with(
+ execution_context=CONTEXT,
+ knowledge_base_uuid='kb-a',
+ filter=None,
+ limit=20,
+ offset=0,
+ )
@pytest.mark.asyncio
async def test_vector_list_with_pagination(self):
- """Vector list with custom pagination."""
- mock_app = self._create_mock_app()
+ app = _app()
- mocks = self._make_rag_import_mocks()
+ await RAGRuntimeService(app).vector_list(CONTEXT, 'test', limit=50, offset=100)
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- await service.vector_list(
- collection_id='test',
- limit=50,
- offset=100,
- )
-
- call_args = mock_app.vector_db_mgr.list_by_filter.call_args
- assert call_args.kwargs['limit'] == 50
- assert call_args.kwargs['offset'] == 100
+ kwargs = app.vector_db_mgr.list_by_filter.await_args.kwargs
+ assert kwargs['limit'] == 50
+ assert kwargs['offset'] == 100
@pytest.mark.asyncio
async def test_vector_list_with_filters(self):
- """Vector list with metadata filters."""
- mock_app = self._create_mock_app()
+ app = _app()
+ filters = {'file_id': 'abc'}
- mocks = self._make_rag_import_mocks()
+ await RAGRuntimeService(app).vector_list(CONTEXT, 'test', filters=filters)
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- filters = {'file_id': 'abc'}
-
- await service.vector_list(
- collection_id='test',
- filters=filters,
- )
-
- call_args = mock_app.vector_db_mgr.list_by_filter.call_args
- assert call_args.kwargs['filter'] == filters
+ assert app.vector_db_mgr.list_by_filter.await_args.kwargs['filter'] == filters
-class TestRAGRuntimeServiceGetFileStream:
- """Tests for get_file_stream method."""
-
- def _create_mock_app(self):
- mock_app = MagicMock()
- mock_app.vector_db_mgr = MagicMock()
- mock_app.storage_mgr = MagicMock()
- mock_app.storage_mgr.storage_provider = MagicMock()
- mock_app.storage_mgr.storage_provider.load = AsyncMock(return_value=b'file content')
- return mock_app
-
- def _make_rag_import_mocks(self):
- return {
- 'langbot.pkg.core.app': MagicMock(),
- 'langbot_plugin.api.entities.builtin.rag': MagicMock(),
- }
-
+class TestRAGRuntimeServiceGetFileStreamRegression:
@pytest.mark.asyncio
async def test_get_file_stream_basic(self):
- """Get file stream loads from storage."""
- mock_app = self._create_mock_app()
+ app = _app(file_exists=True)
+ app.persistence_mgr.execute_async = AsyncMock(return_value=_ScalarResult('file-a'))
- mocks = self._make_rag_import_mocks()
+ result = await RAGRuntimeService(app).get_file_stream(
+ CONTEXT,
+ 'knowledge/files/doc.pdf',
+ )
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- result = await service.get_file_stream('knowledge/files/doc.pdf')
-
- assert result == b'file content'
- mock_app.storage_mgr.storage_provider.load.assert_called_once_with('knowledge/files/doc.pdf')
+ assert result == b'content'
+ app.storage_mgr.load_scoped_object_key.assert_awaited_once_with(
+ CONTEXT,
+ 'knowledge/files/doc.pdf',
+ expected_owner_type='upload_document',
+ )
@pytest.mark.asyncio
async def test_get_file_stream_empty_result(self):
- """Empty file returns empty bytes."""
- mock_app = self._create_mock_app()
- mock_app.storage_mgr.storage_provider.load = AsyncMock(return_value=None)
+ app = _app(file_exists=True)
+ app.persistence_mgr.execute_async = AsyncMock(return_value=_ScalarResult('file-a'))
+ app.storage_mgr.load_scoped_object_key.return_value = None
- mocks = self._make_rag_import_mocks()
+ result = await RAGRuntimeService(app).get_file_stream(CONTEXT, 'nonexistent.pdf')
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- result = await service.get_file_stream('nonexistent.pdf')
-
- assert result == b''
+ assert result == b''
@pytest.mark.asyncio
async def test_get_file_stream_normalizes_safe_path(self):
- """Safe relative paths are normalized before loading."""
- mock_app = self._create_mock_app()
+ app = _app(file_exists=True)
+ app.persistence_mgr.execute_async = AsyncMock(return_value=_ScalarResult('file-a'))
- mocks = self._make_rag_import_mocks()
+ result = await RAGRuntimeService(app).get_file_stream(
+ CONTEXT,
+ 'knowledge/./files/doc.pdf',
+ )
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- result = await service.get_file_stream('knowledge/./files/doc.pdf')
-
- assert result == b'file content'
- mock_app.storage_mgr.storage_provider.load.assert_called_once_with('knowledge/files/doc.pdf')
+ assert result == b'content'
+ app.storage_mgr.load_scoped_object_key.assert_awaited_once_with(
+ CONTEXT,
+ 'knowledge/files/doc.pdf',
+ expected_owner_type='upload_document',
+ )
@pytest.mark.asyncio
async def test_get_file_stream_path_traversal_blocked(self):
- """Path traversal attacks are blocked."""
- mock_app = self._create_mock_app()
+ app = _app(file_exists=True)
+ service = RAGRuntimeService(app)
- mocks = self._make_rag_import_mocks()
-
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- # Absolute path should raise ValueError
- with pytest.raises(ValueError, match='Invalid storage path'):
- await service.get_file_stream('/etc/passwd')
-
- # Path traversal should raise ValueError
- with pytest.raises(ValueError, match='Invalid storage path'):
- await service.get_file_stream('knowledge/../../../etc/passwd')
+ with pytest.raises(ValueError, match='Invalid storage path'):
+ await service.get_file_stream(CONTEXT, '/etc/passwd')
+ with pytest.raises(ValueError, match='Invalid storage path'):
+ await service.get_file_stream(CONTEXT, 'knowledge/../../../etc/passwd')
@pytest.mark.asyncio
@pytest.mark.parametrize(
@@ -486,36 +479,22 @@ class TestRAGRuntimeServiceGetFileStream:
],
)
async def test_get_file_stream_rejects_unsafe_paths(self, storage_path: str):
- """Unsafe runtime file paths are rejected before storage load."""
- mock_app = self._create_mock_app()
+ app = _app(file_exists=True)
- mocks = self._make_rag_import_mocks()
+ with pytest.raises(ValueError, match='Invalid storage path'):
+ await RAGRuntimeService(app).get_file_stream(CONTEXT, storage_path)
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- with pytest.raises(ValueError, match='Invalid storage path'):
- await service.get_file_stream(storage_path)
-
- mock_app.storage_mgr.storage_provider.load.assert_not_called()
+ app.storage_mgr.load_scoped_object_key.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_file_stream_normalizes_path(self):
- """Valid paths with .. in filename (not traversal) should work."""
- mock_app = self._create_mock_app()
+ app = _app(file_exists=True)
+ app.persistence_mgr.execute_async = AsyncMock(return_value=_ScalarResult('file-a'))
- mocks = self._make_rag_import_mocks()
+ await RAGRuntimeService(app).get_file_stream(CONTEXT, 'knowledge/files/test.pdf')
- with isolated_sys_modules(mocks):
- from langbot.pkg.rag.service.runtime import RAGRuntimeService
-
- service = RAGRuntimeService(mock_app)
-
- # Path that contains '..' as part of filename (not traversal)
- # This should NOT raise - posixpath.normpath handles this
- # But the current implementation checks '..' in split('/')
- # Let's test a simple valid path
- await service.get_file_stream('knowledge/files/test.pdf')
- mock_app.storage_mgr.storage_provider.load.assert_called()
+ app.storage_mgr.load_scoped_object_key.assert_awaited_once_with(
+ CONTEXT,
+ 'knowledge/files/test.pdf',
+ expected_owner_type='upload_document',
+ )
diff --git a/tests/unit_tests/rag/test_tenant_isolation.py b/tests/unit_tests/rag/test_tenant_isolation.py
new file mode 100644
index 000000000..3f8f71ada
--- /dev/null
+++ b/tests/unit_tests/rag/test_tenant_isolation.py
@@ -0,0 +1,390 @@
+from __future__ import annotations
+
+import datetime
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.api.http.service.knowledge import KnowledgeService
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.rag import File, KnowledgeBase
+from langbot.pkg.entity.persistence.workspace import Workspace, WorkspaceExecutionState
+from langbot.pkg.rag.knowledge.kbmgr import RAGManager
+from langbot.pkg.rag.service.runtime import RAGRuntimeService
+from langbot.pkg.vector.mgr import VectorDBManager
+from langbot.pkg.vector.vdbs.pgvector_db import PgVectorDatabase
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
+from langbot.pkg.workspace.policy import CloudWorkspacePolicy, SingleWorkspacePolicy
+from langbot.pkg.workspace.service import WorkspaceService
+
+
+pytestmark = pytest.mark.asyncio
+
+INSTANCE_UUID = 'instance-rag-isolation'
+WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
+
+
+class _PersistenceManager:
+ def __init__(self, engine):
+ self.engine = engine
+
+ def get_db_engine(self):
+ return self.engine
+
+ async def execute_async(self, *args, **kwargs):
+ async with self.engine.connect() as connection:
+ result = await connection.execute(*args, **kwargs)
+ await connection.commit()
+ return result
+
+ @asynccontextmanager
+ async def tenant_uow(self, _workspace_uuid):
+ # This lightweight fixture does not emulate PostgreSQL RLS; production
+ # persistence tests cover the transaction-bound unit of work itself.
+ async with AsyncSession(self.engine, expire_on_commit=False) as session, session.begin():
+ yield SimpleNamespace(session=session)
+
+ @staticmethod
+ def serialize_model(model, row, masked_columns=()):
+ return {
+ column.name: (
+ getattr(row, column.name).isoformat()
+ if isinstance(getattr(row, column.name), datetime.datetime)
+ else getattr(row, column.name)
+ )
+ for column in model.__table__.columns
+ if column.name not in masked_columns
+ }
+
+
+class _RecordingVectorDatabase:
+ def __init__(self):
+ self.collections: list[str] = []
+ self.metadatas: list[list[dict]] = []
+ self.calls: list[tuple[str, str]] = []
+
+ async def add_embeddings(self, *, collection, ids, embeddings_list, metadatas, documents):
+ self.collections.append(collection)
+ self.metadatas.append(metadatas)
+ self.calls.append(('upsert', collection))
+
+ async def search(self, *, collection, **_kwargs):
+ self.calls.append(('search', collection))
+ return {'ids': [[]], 'distances': [[]], 'metadatas': [[]]}
+
+ async def delete_by_file_id(self, collection, _file_id):
+ self.calls.append(('delete_by_file_id', collection))
+
+ async def delete_collection(self, collection):
+ self.calls.append(('delete_collection', collection))
+
+ async def delete_by_filter(self, collection, _filter):
+ self.calls.append(('delete_by_filter', collection))
+ return 1
+
+ async def list_by_filter(self, collection, _filter, _limit, _offset):
+ self.calls.append(('list_by_filter', collection))
+ return [], 0
+
+
+@pytest.fixture
+async def tenant_rag(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "rag-tenant.db"}')
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ await connection.execute(
+ sqlalchemy.insert(Workspace),
+ [
+ {
+ 'uuid': WORKSPACE_A,
+ 'instance_uuid': INSTANCE_UUID,
+ 'name': 'Workspace A',
+ 'slug': 'workspace-a',
+ 'source': 'local',
+ },
+ {
+ 'uuid': WORKSPACE_B,
+ 'instance_uuid': INSTANCE_UUID,
+ 'name': 'Workspace B',
+ 'slug': 'workspace-b',
+ 'source': 'cloud_projection',
+ },
+ ],
+ )
+ await connection.execute(
+ sqlalchemy.insert(WorkspaceExecutionState),
+ [
+ {
+ 'workspace_uuid': WORKSPACE_A,
+ 'instance_uuid': INSTANCE_UUID,
+ 'active_generation': 3,
+ 'state': 'active',
+ 'source': 'local',
+ 'write_fenced': False,
+ },
+ {
+ 'workspace_uuid': WORKSPACE_B,
+ 'instance_uuid': INSTANCE_UUID,
+ 'active_generation': 3,
+ 'state': 'active',
+ 'source': 'cloud',
+ 'write_fenced': False,
+ },
+ ],
+ )
+ await connection.execute(
+ sqlalchemy.insert(KnowledgeBase),
+ [
+ {
+ 'uuid': 'kb-a',
+ 'workspace_uuid': WORKSPACE_A,
+ 'name': 'Same Knowledge Base',
+ 'description': 'A',
+ 'knowledge_engine_plugin_id': 'author/engine',
+ 'collection_id': 'kb-a',
+ 'creation_settings': {},
+ 'retrieval_settings': {},
+ },
+ {
+ 'uuid': 'kb-b',
+ 'workspace_uuid': WORKSPACE_B,
+ 'name': 'Same Knowledge Base',
+ 'description': 'B',
+ 'knowledge_engine_plugin_id': 'author/engine',
+ 'collection_id': 'kb-b',
+ 'creation_settings': {},
+ 'retrieval_settings': {},
+ },
+ ],
+ )
+ await connection.execute(
+ sqlalchemy.insert(File),
+ [
+ {
+ 'uuid': 'file-a',
+ 'workspace_uuid': WORKSPACE_A,
+ 'kb_id': 'kb-a',
+ 'file_name': 'a.pdf',
+ 'extension': 'pdf',
+ },
+ {
+ 'uuid': 'file-b',
+ 'workspace_uuid': WORKSPACE_B,
+ 'kb_id': 'kb-b',
+ 'file_name': 'b.pdf',
+ 'extension': 'pdf',
+ },
+ ],
+ )
+
+ app = SimpleNamespace()
+ app.persistence_mgr = _PersistenceManager(engine)
+ app.logger = Mock()
+ app.workspace_policy = SingleWorkspacePolicy()
+ app.plugin_connector = SimpleNamespace(
+ is_enable_plugin=False,
+ rag_on_kb_create=AsyncMock(),
+ rag_on_kb_delete=AsyncMock(),
+ )
+ app.workspace_service = WorkspaceService(app, instance_uuid=INSTANCE_UUID)
+ app.rag_mgr = RAGManager(app)
+ await app.rag_mgr.initialize()
+ app.knowledge_service = KnowledgeService(app)
+
+ yield app, engine
+ await engine.dispose()
+
+
+def _context(workspace_uuid: str) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid=INSTANCE_UUID,
+ workspace_uuid=workspace_uuid,
+ placement_generation=3,
+ )
+
+
+async def test_context_is_mandatory_and_same_names_are_isolated(tenant_rag):
+ app, _engine = tenant_rag
+
+ with pytest.raises(WorkspaceRequiredError):
+ await app.knowledge_service.get_knowledge_bases(None)
+
+ bases_a = await app.knowledge_service.get_knowledge_bases(_context(WORKSPACE_A))
+ bases_b = await app.knowledge_service.get_knowledge_bases(_context(WORKSPACE_B))
+ assert [(item['uuid'], item['name']) for item in bases_a] == [('kb-a', 'Same Knowledge Base')]
+ assert [(item['uuid'], item['name']) for item in bases_b] == [('kb-b', 'Same Knowledge Base')]
+
+
+async def test_cross_workspace_uuid_and_file_guessing_return_not_found(tenant_rag):
+ app, engine = tenant_rag
+ context_a = _context(WORKSPACE_A)
+
+ assert await app.knowledge_service.get_knowledge_base(context_a, 'kb-b') is None
+ with pytest.raises(WorkspaceNotFoundError):
+ await app.knowledge_service.update_knowledge_base(context_a, 'kb-b', {'name': 'stolen'})
+ with pytest.raises(WorkspaceNotFoundError):
+ await app.knowledge_service.delete_knowledge_base(context_a, 'kb-b')
+ with pytest.raises(WorkspaceNotFoundError):
+ await app.knowledge_service.get_files_by_knowledge_base(context_a, 'kb-b')
+ with pytest.raises(WorkspaceNotFoundError):
+ await app.knowledge_service.delete_file(context_a, 'kb-b', 'file-b')
+
+ async with engine.connect() as connection:
+ assert (
+ await connection.scalar(sqlalchemy.select(KnowledgeBase.name).where(KnowledgeBase.uuid == 'kb-b'))
+ == 'Same Knowledge Base'
+ )
+ assert await connection.scalar(sqlalchemy.select(File.uuid).where(File.uuid == 'file-b')) == 'file-b'
+
+
+async def test_runtime_rejects_cross_workspace_collection_reference(tenant_rag):
+ app, _engine = tenant_rag
+ app.vector_db_mgr = SimpleNamespace(upsert=AsyncMock())
+ service = RAGRuntimeService(app)
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await service.vector_upsert(
+ _context(WORKSPACE_A),
+ 'kb-b',
+ vectors=[[0.1, 0.2]],
+ ids=['chunk-1'],
+ )
+ app.vector_db_mgr.upsert.assert_not_awaited()
+
+
+async def test_physical_vector_handles_do_not_collide_across_workspaces(tenant_rag):
+ app, _engine = tenant_rag
+ database = _RecordingVectorDatabase()
+ manager = VectorDBManager(app)
+ manager.vector_db = database
+
+ context_a = _context(WORKSPACE_A)
+ context_b = _context(WORKSPACE_B)
+ assert manager.physical_collection_name(context_a, 'same-kb-id') != manager.physical_collection_name(
+ context_b,
+ 'same-kb-id',
+ )
+
+ await manager.upsert(context_a, 'kb-a', [[0.1]], ['a'], metadata=[{'source': 'client'}])
+ await manager.upsert(context_b, 'kb-b', [[0.2]], ['b'], metadata=[{'source': 'client'}])
+ assert len(set(database.collections)) == 2
+ assert database.metadatas[0][0]['_langbot_workspace_uuid'] == WORKSPACE_A
+ assert database.metadatas[1][0]['_langbot_workspace_uuid'] == WORKSPACE_B
+
+
+async def test_migrated_local_kb_keeps_legacy_collection_for_every_vector_operation(tenant_rag):
+ app, engine = tenant_rag
+ legacy_collection = 'legacy-collection-kb-a'
+ async with engine.begin() as connection:
+ await connection.execute(
+ sqlalchemy.update(KnowledgeBase)
+ .where(KnowledgeBase.uuid == 'kb-a')
+ .values(
+ collection_id=legacy_collection,
+ legacy_vector_collection=True,
+ )
+ )
+
+ database = _RecordingVectorDatabase()
+ manager = VectorDBManager(app)
+ manager.vector_db = database
+ context = _context(WORKSPACE_A)
+
+ await manager.upsert(context, 'kb-a', [[0.1]], ['chunk-a'])
+ await manager.search(context, 'kb-a', [0.1], 3)
+ await manager.delete_by_file_id(context, 'kb-a', ['file-a'])
+ assert await manager.delete_by_filter(context, 'kb-a', {'file_id': 'file-a'}) == 1
+ assert await manager.list_by_filter(context, 'kb-a', {'file_id': 'file-a'}) == ([], 0)
+ await manager.delete_collection(context, 'kb-a')
+
+ assert database.calls == [
+ ('upsert', legacy_collection),
+ ('search', legacy_collection),
+ ('delete_by_file_id', legacy_collection),
+ ('delete_by_filter', legacy_collection),
+ ('list_by_filter', legacy_collection),
+ ('delete_collection', legacy_collection),
+ ]
+
+
+@pytest.mark.parametrize('deny_by', ['projected_workspace', 'multi_workspace_policy'])
+async def test_legacy_marker_is_ignored_outside_single_local_workspace(tenant_rag, deny_by):
+ app, engine = tenant_rag
+ workspace_uuid = WORKSPACE_B if deny_by == 'projected_workspace' else WORKSPACE_A
+ kb_uuid = 'kb-b' if deny_by == 'projected_workspace' else 'kb-a'
+ legacy_collection = f'legacy-{deny_by}'
+ async with engine.begin() as connection:
+ await connection.execute(
+ sqlalchemy.update(KnowledgeBase)
+ .where(KnowledgeBase.uuid == kb_uuid)
+ .values(
+ collection_id=legacy_collection,
+ legacy_vector_collection=True,
+ )
+ )
+ if deny_by == 'multi_workspace_policy':
+ app.workspace_policy = CloudWorkspacePolicy()
+
+ database = _RecordingVectorDatabase()
+ manager = VectorDBManager(app)
+ manager.vector_db = database
+ context = _context(workspace_uuid)
+ await manager.search(context, kb_uuid, [0.1], 3)
+
+ assert database.calls == [('search', manager.physical_collection_name(context, kb_uuid))]
+ assert database.calls[0][1] != legacy_collection
+ app.logger.warning.assert_called_once()
+
+
+async def test_stale_generation_is_rejected_before_vector_access(tenant_rag):
+ app, _engine = tenant_rag
+ database = _RecordingVectorDatabase()
+ manager = VectorDBManager(app)
+ manager.vector_db = database
+ stale = ExecutionContext(
+ instance_uuid=INSTANCE_UUID,
+ workspace_uuid=WORKSPACE_A,
+ placement_generation=2,
+ )
+
+ with pytest.raises(Exception, match='generation'):
+ await manager.upsert(stale, 'kb-a', [[0.1]], ['a'])
+ assert database.collections == []
+
+
+async def test_pgvector_first_write_binds_dimension_and_later_mismatch_fails(tenant_rag):
+ app, engine = tenant_rag
+ manager = VectorDBManager(app)
+ pgvector = object.__new__(PgVectorDatabase)
+ pgvector.allowed_dimensions = frozenset({1, 2})
+ pgvector.add_embeddings = AsyncMock()
+ pgvector.search = AsyncMock(return_value={'ids': [[]], 'distances': [[]], 'metadatas': [[]]})
+ manager.vector_db = pgvector
+ context = _context(WORKSPACE_A)
+
+ await manager.upsert(context, 'kb-a', [[0.1]], ['chunk-a'])
+ scope = pgvector.add_embeddings.await_args.kwargs['scope']
+ assert scope.workspace_uuid == WORKSPACE_A
+ assert scope.knowledge_base_uuid == 'kb-a'
+ assert scope.embedding_dimension == 1
+
+ async with engine.connect() as connection:
+ selected_dimension = await connection.scalar(
+ sqlalchemy.select(KnowledgeBase.embedding_dimension).where(
+ KnowledgeBase.workspace_uuid == WORKSPACE_A,
+ KnowledgeBase.uuid == 'kb-a',
+ )
+ )
+ assert selected_dimension == 1
+
+ with pytest.raises(ValueError, match='dimension is 1, not 2'):
+ await manager.upsert(context, 'kb-a', [[0.1, 0.2]], ['chunk-b'])
+ with pytest.raises(ValueError, match='not enabled'):
+ await manager.search(context, 'kb-a', [0.1, 0.2, 0.3], 3)
diff --git a/tests/unit_tests/storage/test_localstorage_path_traversal.py b/tests/unit_tests/storage/test_localstorage_path_traversal.py
index 5e950eb32..dc72da5d1 100644
--- a/tests/unit_tests/storage/test_localstorage_path_traversal.py
+++ b/tests/unit_tests/storage/test_localstorage_path_traversal.py
@@ -168,6 +168,15 @@ class TestPathTraversalPrevention:
assert loaded == content
await provider.delete(key)
+ @pytest.mark.asyncio
+ async def test_bounded_load_stops_after_limit(self, storage_provider):
+ provider, storage_path = storage_provider
+
+ with patch('langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH', storage_path):
+ await provider.save('oversized.bin', b'12345')
+ with pytest.raises(ValueError, match='4-byte read limit'):
+ await provider.load_bounded('oversized.bin', max_bytes=4)
+
@pytest.mark.asyncio
async def test_delete_dir_recursive_non_existing_dir(self, storage_provider):
"""delete_dir_recursive should handle non-existing directories gracefully."""
diff --git a/tests/unit_tests/storage/test_s3storage.py b/tests/unit_tests/storage/test_s3storage.py
index eb3d0f7e6..7e90cf43d 100644
--- a/tests/unit_tests/storage/test_s3storage.py
+++ b/tests/unit_tests/storage/test_s3storage.py
@@ -74,6 +74,19 @@ class TestS3StorageProviderInit:
assert provider.s3_client is None
assert provider.bucket_name is None
+ @pytest.mark.asyncio
+ async def test_shutdown_closes_client_once(self):
+ s3storage = get_s3storage_module()
+ provider = s3storage.S3StorageProvider(Mock())
+ client = Mock()
+ provider.s3_client = client
+
+ await provider.shutdown()
+ await provider.shutdown()
+
+ client.close.assert_called_once_with()
+ assert provider.s3_client is None
+
class TestS3StorageProviderWithMoto:
"""Tests using moto to mock AWS S3."""
@@ -121,6 +134,16 @@ class TestS3StorageProviderWithMoto:
loaded_data = await provider.load('test/file.txt')
assert loaded_data == test_data
+ @pytest.mark.asyncio
+ async def test_bounded_load_rejects_oversized_object(self, mock_app_with_s3_config, s3_mock):
+ s3storage = get_s3storage_module()
+ provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
+ await provider.initialize()
+ await provider.save('test/oversized.bin', b'12345')
+
+ with pytest.raises(ValueError, match='4-byte read limit'):
+ await provider.load_bounded('test/oversized.bin', max_bytes=4)
+
@pytest.mark.asyncio
async def test_exists_returns_true_for_existing_object(self, mock_app_with_s3_config, s3_mock):
"""Test that exists returns True for existing object."""
diff --git a/tests/unit_tests/storage/test_storage_manager.py b/tests/unit_tests/storage/test_storage_manager.py
index d96f1cb04..e17c317bd 100644
--- a/tests/unit_tests/storage/test_storage_manager.py
+++ b/tests/unit_tests/storage/test_storage_manager.py
@@ -92,6 +92,15 @@ class TestStorageMgr:
await storage_mgr.initialize()
mock_init.assert_called_once()
+ @pytest.mark.asyncio
+ async def test_shutdown_delegates_to_active_provider(self):
+ storage_mgr = StorageMgr(Mock())
+ storage_mgr.storage_provider = Mock(shutdown=AsyncMock())
+
+ await storage_mgr.shutdown()
+
+ storage_mgr.storage_provider.shutdown.assert_awaited_once()
+
class TestStorageProviderBase:
"""Test StorageProvider base class methods."""
diff --git a/tests/unit_tests/storage/test_workspace_scoping.py b/tests/unit_tests/storage/test_workspace_scoping.py
new file mode 100644
index 000000000..2787ba88b
--- /dev/null
+++ b/tests/unit_tests/storage/test_workspace_scoping.py
@@ -0,0 +1,277 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from langbot.pkg.api.http.authz import WorkspaceRequiredError
+from langbot.pkg.api.http.context import ExecutionContext
+from langbot.pkg.storage.mgr import StorageMgr
+from langbot.pkg.storage.provider import HARD_MAX_STORAGE_OBJECT_BYTES
+from langbot.pkg.utils.bounded_executor import current_blocking_work_scope
+
+
+WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
+WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
+
+
+def _context(workspace_uuid: str, generation: int = 7) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid='instance',
+ workspace_uuid=workspace_uuid,
+ placement_generation=generation,
+ )
+
+
+def _context_for_instance(instance_uuid: str) -> ExecutionContext:
+ return ExecutionContext(
+ instance_uuid=instance_uuid,
+ workspace_uuid=WORKSPACE_A,
+ placement_generation=7,
+ )
+
+
+class _Provider:
+ def __init__(self):
+ self.values: dict[str, bytes] = {}
+ self.observed_public_scopes: list[str | None] = []
+
+ async def save(self, key: str, value: bytes):
+ self.values[key] = value
+
+ async def load(self, key: str) -> bytes:
+ self.observed_public_scopes.append(current_blocking_work_scope())
+ return self.values[key]
+
+ async def exists(self, key: str) -> bool:
+ self.observed_public_scopes.append(current_blocking_work_scope())
+ return key in self.values
+
+ async def size(self, key: str) -> int:
+ return len(self.values[key])
+
+ async def delete(self, key: str):
+ self.values.pop(key, None)
+
+
+class _WorkspaceService:
+ instance_uuid = 'instance'
+
+ async def get_execution_binding(self, workspace_uuid, expected_generation=None):
+ if workspace_uuid not in {WORKSPACE_A, WORKSPACE_B} or expected_generation != 7:
+ raise ValueError('inactive or stale binding')
+ return SimpleNamespace(
+ instance_uuid='instance',
+ workspace_uuid=workspace_uuid,
+ placement_generation=7,
+ )
+
+
+@pytest.fixture
+def manager():
+ application = SimpleNamespace(workspace_service=_WorkspaceService())
+ storage = StorageMgr(application)
+ storage.storage_provider = _Provider()
+ return storage
+
+
+def test_binary_storage_canonical_key_covers_every_scope_dimension(manager):
+ baseline = manager.canonical_binary_storage_key(
+ _context(WORKSPACE_A),
+ owner_type='plugin',
+ owner='author/name',
+ key='same-key',
+ )
+ assert baseline != manager.canonical_binary_storage_key(
+ _context(WORKSPACE_B),
+ owner_type='plugin',
+ owner='author/name',
+ key='same-key',
+ )
+ assert baseline != manager.canonical_binary_storage_key(
+ _context_for_instance('other-instance'),
+ owner_type='plugin',
+ owner='author/name',
+ key='same-key',
+ )
+ assert baseline != manager.canonical_binary_storage_key(
+ _context(WORKSPACE_A),
+ owner_type='workspace',
+ owner='author/name',
+ key='same-key',
+ )
+ assert baseline != manager.canonical_binary_storage_key(
+ _context(WORKSPACE_A),
+ owner_type='plugin',
+ owner='other/name',
+ key='same-key',
+ )
+ assert baseline != manager.canonical_binary_storage_key(
+ _context(WORKSPACE_A),
+ owner_type='plugin',
+ owner='author/name',
+ key='other-key',
+ )
+
+
+@pytest.mark.asyncio
+async def test_public_object_route_derives_trusted_workspace(manager):
+ object_key = await manager.save_scoped(
+ _context(WORKSPACE_A),
+ owner_type='upload',
+ owner='account:a',
+ key='photo.png',
+ value=b'image-a',
+ )
+ assert await manager.resolve_public_object(object_key, expected_owner_type='upload') == b'image-a'
+ assert manager.storage_provider.observed_public_scopes[-2:] == [
+ WORKSPACE_A,
+ WORKSPACE_A,
+ ]
+ assert await manager.resolve_public_object(object_key, expected_owner_type='plugin') is None
+
+ guessed_workspace_key = object_key.replace(WORKSPACE_A, WORKSPACE_B)
+ assert await manager.resolve_public_object(guessed_workspace_key, expected_owner_type='upload') is None
+
+ stale_generation_key = object_key.replace('/7/upload/', '/8/upload/')
+ assert await manager.resolve_public_object(stale_generation_key, expected_owner_type='upload') is None
+
+
+@pytest.mark.asyncio
+async def test_storage_operations_without_context_fail_closed(manager):
+ with pytest.raises(WorkspaceRequiredError):
+ await manager.save_scoped(
+ None,
+ owner_type='upload',
+ owner='account:a',
+ key='photo.png',
+ value=b'image',
+ )
+
+
+@pytest.mark.asyncio
+async def test_opaque_object_operations_reject_cross_scope_and_owner_type(manager):
+ object_key = await manager.save_scoped(
+ _context(WORKSPACE_A),
+ owner_type='upload',
+ owner='account:a',
+ key='document.pdf',
+ value=b'document-a',
+ )
+
+ assert await manager.exists_scoped_object_key(
+ _context(WORKSPACE_A),
+ object_key,
+ expected_owner_type='upload',
+ )
+ assert (
+ await manager.load_scoped_object_key(
+ _context(WORKSPACE_A),
+ object_key,
+ expected_owner_type='upload',
+ )
+ == b'document-a'
+ )
+ assert await manager.size_scoped_object_key(
+ _context(WORKSPACE_A),
+ object_key,
+ expected_owner_type='upload',
+ ) == len(b'document-a')
+
+ for wrong_context in (_context(WORKSPACE_B), _context(WORKSPACE_A, generation=8)):
+ with pytest.raises(WorkspaceRequiredError):
+ await manager.load_scoped_object_key(
+ wrong_context,
+ object_key,
+ expected_owner_type='upload',
+ )
+ with pytest.raises(WorkspaceRequiredError):
+ await manager.delete_scoped_object_key(
+ wrong_context,
+ object_key,
+ expected_owner_type='upload',
+ )
+
+ with pytest.raises(WorkspaceRequiredError):
+ await manager.load_scoped_object_key(
+ _context(WORKSPACE_A),
+ object_key,
+ expected_owner_type='plugin_config',
+ )
+
+ assert object_key in manager.storage_provider.values
+
+
+@pytest.mark.asyncio
+async def test_scoped_object_reads_and_writes_have_configured_and_hard_byte_limits(manager):
+ manager.ap.instance_config = SimpleNamespace(data={'storage': {'max_object_read_bytes': 4}})
+
+ with pytest.raises(ValueError, match='4-byte write limit'):
+ await manager.save_scoped(
+ _context(WORKSPACE_A),
+ owner_type='upload',
+ owner='account:a',
+ key='oversized.bin',
+ value=b'12345',
+ )
+
+ object_key = manager.scoped_object_key(
+ _context(WORKSPACE_A),
+ owner_type='upload',
+ owner='account:a',
+ key='external.bin',
+ )
+ manager.storage_provider.values[object_key] = b'12345'
+ manager.storage_provider.load = AsyncMock(side_effect=AssertionError('oversized object must not be loaded'))
+ with pytest.raises(ValueError, match='4-byte read limit'):
+ await manager.load_scoped_object_key(
+ _context(WORKSPACE_A),
+ object_key,
+ expected_owner_type='upload',
+ )
+ manager.storage_provider.load.assert_not_awaited()
+
+ manager.ap.instance_config.data['storage']['max_object_read_bytes'] = HARD_MAX_STORAGE_OBJECT_BYTES + 1
+ assert manager._object_read_limit() == HARD_MAX_STORAGE_OBJECT_BYTES
+
+
+@pytest.mark.asyncio
+async def test_scoped_provider_is_not_touched_after_generation_is_fenced(manager):
+ object_key = await manager.save_scoped(
+ _context(WORKSPACE_A),
+ owner_type='upload',
+ owner='account:a',
+ key='document.pdf',
+ value=b'document-a',
+ )
+ manager.ap.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(side_effect=ValueError('generation is stale'))
+ )
+ manager.storage_provider.load = AsyncMock(side_effect=AssertionError('provider must not be called'))
+
+ with pytest.raises(WorkspaceRequiredError, match='execution scope is unavailable'):
+ await manager.load_scoped_object_key(
+ _context(WORKSPACE_A),
+ object_key,
+ expected_owner_type='upload',
+ )
+ manager.storage_provider.load.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_scoped_provider_rejects_incomplete_execution_binding(manager):
+ manager.ap.workspace_service = SimpleNamespace(
+ get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid='instance'))
+ )
+ manager.storage_provider.save = AsyncMock(side_effect=AssertionError('provider must not be called'))
+
+ with pytest.raises(WorkspaceRequiredError, match='execution scope is unavailable'):
+ await manager.save_scoped(
+ _context(WORKSPACE_A),
+ owner_type='upload',
+ owner='account:a',
+ key='document.pdf',
+ value=b'document-a',
+ )
+ manager.storage_provider.save.assert_not_awaited()
diff --git a/tests/unit_tests/survey/test_survey_manager.py b/tests/unit_tests/survey/test_survey_manager.py
index 5bf219ad3..da67e8219 100644
--- a/tests/unit_tests/survey/test_survey_manager.py
+++ b/tests/unit_tests/survey/test_survey_manager.py
@@ -29,9 +29,22 @@ def create_mock_app():
mock_app.instance_config.data = {'space': {'url': 'https://space.example.com'}}
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
+ mock_app.workspace_service.instance_uuid = 'instance-test'
+
+ def close_scheduled_coroutine(coro, **kwargs):
+ coro.close()
+ return Mock()
+
+ mock_app.task_mgr.create_task = Mock(side_effect=close_scheduled_coroutine)
return mock_app
+def scalar_result(value=None):
+ """Return the scalar-only shape used by Connection column selects."""
+
+ return Mock(scalar_one_or_none=Mock(return_value=value))
+
+
class TestSurveyManagerInit:
"""Tests for SurveyManager initialization."""
@@ -67,7 +80,7 @@ class TestSurveyManagerInit:
"""Test that initialize loads space URL from config."""
survey_module = get_survey_module()
mock_app = create_mock_app()
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
@@ -80,7 +93,7 @@ class TestSurveyManagerInit:
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.instance_config.data = {'space': {'url': 'https://space.example.com/'}}
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
@@ -93,7 +106,7 @@ class TestSurveyManagerInit:
survey_module = get_survey_module()
mock_app = create_mock_app()
mock_app.instance_config.data = {}
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
await manager.initialize()
@@ -110,12 +123,7 @@ class TestLoadTriggeredEvents:
survey_module = get_survey_module()
mock_app = create_mock_app()
- # Mock existing metadata row
- mock_row = Mock()
- mock_row.value = json.dumps(['event1', 'event2'])
- mock_result = Mock()
- mock_result.first = Mock(return_value=(mock_row,))
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result(json.dumps(['event1', 'event2'])))
manager = survey_module.SurveyManager(mock_app)
await manager._load_triggered_events()
@@ -128,7 +136,7 @@ class TestLoadTriggeredEvents:
"""Test that empty set is used when no events stored."""
survey_module = get_survey_module()
mock_app = create_mock_app()
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
await manager._load_triggered_events()
@@ -218,7 +226,7 @@ class TestTriggerEvent:
"""Test that new event is added and saved."""
survey_module = get_survey_module()
mock_app = create_mock_app()
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
@@ -235,7 +243,7 @@ class TestRecordBotResponseSuccess:
manager = survey_module.SurveyManager(mock_app)
manager._space_url = 'https://space.example.com'
# No existing metadata rows: select returns no row
- mock_app.persistence_mgr.execute_async = AsyncMock(return_value=Mock(first=Mock(return_value=None)))
+ mock_app.persistence_mgr.execute_async = AsyncMock(return_value=scalar_result())
return manager
@pytest.mark.asyncio
@@ -304,19 +312,13 @@ class TestRecordBotResponseSuccess:
survey_module = get_survey_module()
mock_app = create_mock_app()
- count_row = Mock()
- count_row.value = '42'
-
def execute_side_effect(stmt):
- result = Mock()
# Both _load_triggered_events and _load_bot_response_count select
- # from Metadata; return the count row only for the count key.
+ # Metadata.value; return the count only for the count key.
stmt_str = str(stmt.compile(compile_kwargs={'literal_binds': True}))
if survey_module.BOT_RESPONSE_COUNT_KEY in stmt_str:
- result.first.return_value = (count_row,)
- else:
- result.first.return_value = None
- return result
+ return scalar_result('42')
+ return scalar_result()
mock_app.persistence_mgr.execute_async = AsyncMock(side_effect=execute_side_effect)
diff --git a/tests/unit_tests/telemetry/test_heartbeat.py b/tests/unit_tests/telemetry/test_heartbeat.py
index 18d61f2d8..a8dfae936 100644
--- a/tests/unit_tests/telemetry/test_heartbeat.py
+++ b/tests/unit_tests/telemetry/test_heartbeat.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
+from types import SimpleNamespace
import pytest
from unittest.mock import AsyncMock, Mock
@@ -49,6 +50,7 @@ def make_app():
# skills
ap.skill_mgr = Mock()
ap.skill_mgr.skills = {'a': {}, 'b': {}, 'c': {}}
+ ap.skill_mgr.total_cached_skill_count.return_value = 3
return ap
@@ -95,6 +97,35 @@ class TestBuildHeartbeatPayload:
payload = await heartbeat.build_heartbeat_payload(ap)
assert payload['features']['pipeline_count'] == -1
+ @pytest.mark.asyncio
+ async def test_cloud_counts_loaded_registries_without_tenant_sql(self):
+ heartbeat = get_heartbeat_module()
+ ap = make_app()
+ ap.persistence_mgr.mode = SimpleNamespace(value='cloud_runtime')
+ ap.persistence_mgr.execute_async = AsyncMock(
+ side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs')
+ )
+ ap.pipeline_mgr = SimpleNamespace(
+ _pipelines_by_key={'pipeline-a': object(), 'pipeline-b': object()},
+ )
+ ap.tool_mgr = SimpleNamespace(
+ mcp_tool_loader=SimpleNamespace(
+ _sessions={'mcp-a': object(), 'mcp-b': object(), 'mcp-c': object()},
+ ),
+ )
+ ap.rag_mgr = SimpleNamespace(
+ knowledge_bases={'kb-a': object()},
+ )
+
+ payload = await heartbeat.build_heartbeat_payload(ap)
+
+ features = payload['features']
+ assert features['pipeline_count'] == 2
+ assert features['mcp_server_count'] == 3
+ assert features['knowledge_base_count'] == 1
+ assert features['bot_count'] == 1
+ ap.persistence_mgr.execute_async.assert_not_awaited()
+
@pytest.mark.asyncio
async def test_no_user_content_fields(self):
"""The heartbeat must never carry message content / credentials keys."""
diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py
index 8f05277cb..d81f67af5 100644
--- a/tests/unit_tests/test_preproc.py
+++ b/tests/unit_tests/test_preproc.py
@@ -16,10 +16,21 @@ from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot_plugin.api.entities.builtin.provider.prompt import Prompt
from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session
+from langbot.pkg.api.http.context import ExecutionContext
+
+
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ bot_uuid='bot-1',
+ pipeline_uuid='pipe-1',
+)
+
def _make_query() -> Query:
message_chain = MessageChain([Plain(text='create a skill')])
- return Query(
+ query = Query(
query_id=1,
launcher_type=LauncherTypes.PERSON,
launcher_id='launcher-1',
@@ -45,6 +56,8 @@ def _make_query() -> Query:
},
variables={},
)
+ object.__setattr__(query, '_execution_context', _CONTEXT)
+ return query
def _make_conversation() -> Conversation:
@@ -84,6 +97,8 @@ def _make_app(*, skill_service) -> SimpleNamespace:
get_pipeline=AsyncMock(return_value={'extensions_preferences': {'enable_all_skills': True}})
),
skill_mgr=SimpleNamespace(
+ ensure_loaded=AsyncMock(),
+ get_skills=Mock(return_value={}),
build_skill_aware_prompt_addition=Mock(return_value=''),
skills={},
),
@@ -119,6 +134,7 @@ async def test_preproc_enables_skill_authoring_tools_when_skill_service_availabl
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(
+ _CONTEXT,
None,
None,
include_skill_authoring=True,
@@ -137,6 +153,7 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(
+ _CONTEXT,
None,
None,
include_skill_authoring=False,
@@ -157,6 +174,7 @@ async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disable
assert result.result_type == entities_module.ResultType.CONTINUE
app.tool_mgr.get_all_tools.assert_awaited_once_with(
+ _CONTEXT,
None,
None,
include_skill_authoring=True,
@@ -179,7 +197,10 @@ async def test_preproc_injects_skill_index_into_system_prompt():
result = await stage_process_capture(preproc_module, app, query)
assert result.result_type == entities_module.ResultType.CONTINUE
- app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=None)
+ app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(
+ _CONTEXT,
+ bound_skills=None,
+ )
head = query.prompt.messages[0]
assert head.role == 'system'
assert head.content.endswith(addendum)
@@ -206,7 +227,10 @@ async def test_preproc_respects_pipeline_bound_skills_subset():
result = await stage_process_capture(preproc_module, app, query)
assert result.result_type == entities_module.ResultType.CONTINUE
- app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=['only-this'])
+ app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(
+ _CONTEXT,
+ bound_skills=['only-this'],
+ )
assert query.variables.get('_pipeline_bound_skills') == ['only-this']
diff --git a/tests/unit_tests/test_skill_service.py b/tests/unit_tests/test_skill_service.py
index 6fd7d64f2..5b2203beb 100644
--- a/tests/unit_tests/test_skill_service.py
+++ b/tests/unit_tests/test_skill_service.py
@@ -1,11 +1,28 @@
+import io
from types import SimpleNamespace
from unittest.mock import AsyncMock
+import zipfile
+import httpx
import pytest
+from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.api.http.service.skill import SkillService
+_CONTEXT = ExecutionContext(
+ instance_uuid='instance-a',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+)
+
+
+def _workspace_service():
+ return SimpleNamespace(
+ get_execution_binding=AsyncMock(return_value=SimpleNamespace(instance_uuid=_CONTEXT.instance_uuid))
+ )
+
+
class TestRequireBoxForWrite:
"""Box is the only source of truth for skills — there is no local
filesystem fallback. Every write and (most) read methods refuse cleanly
@@ -14,6 +31,7 @@ class TestRequireBoxForWrite:
def _ap_with_disabled_box(self):
return SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
+ workspace_service=_workspace_service(),
box_service=SimpleNamespace(
available=False,
enabled=False,
@@ -24,6 +42,7 @@ class TestRequireBoxForWrite:
def _ap_with_failed_box(self):
return SimpleNamespace(
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
+ workspace_service=_workspace_service(),
box_service=SimpleNamespace(
available=False,
enabled=True,
@@ -35,55 +54,116 @@ class TestRequireBoxForWrite:
async def test_create_skill_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='disabled in config'):
- await service.create_skill({'name': 'x'})
+ await service.create_skill(_CONTEXT, {'name': 'x'})
@pytest.mark.asyncio
async def test_create_skill_refused_when_box_failed(self):
service = SkillService(self._ap_with_failed_box())
with pytest.raises(ValueError, match='docker daemon not running'):
- await service.create_skill({'name': 'x'})
+ await service.create_skill(_CONTEXT, {'name': 'x'})
@pytest.mark.asyncio
async def test_update_skill_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Editing a skill requires the Box runtime'):
- await service.update_skill('x', {})
+ await service.update_skill(_CONTEXT, 'x', {})
@pytest.mark.asyncio
async def test_write_skill_file_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Editing skill files requires the Box runtime'):
- await service.write_skill_file('x', 'a.txt', 'hi')
+ await service.write_skill_file(_CONTEXT, 'x', 'a.txt', 'hi')
@pytest.mark.asyncio
async def test_install_from_github_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Installing a skill from GitHub'):
- await service.install_from_github({'owner': 'o', 'repo': 'r', 'asset_url': 'https://example/x.zip'})
+ await service.install_from_github(
+ _CONTEXT,
+ {'owner': 'o', 'repo': 'r', 'asset_url': 'https://example/x.zip'},
+ )
@pytest.mark.asyncio
async def test_install_from_zip_upload_refused_when_box_disabled(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Installing a skill from upload'):
- await service.install_from_zip_upload(file_bytes=b'', filename='x.zip')
+ await service.install_from_zip_upload(
+ _CONTEXT,
+ file_bytes=b'',
+ filename='x.zip',
+ )
@pytest.mark.asyncio
async def test_create_skill_refused_when_box_service_missing_entirely(self):
"""No ap.box_service attribute at all (truly minimal setup):
Box is the only source of truth, so creation must still refuse."""
- service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
+ service = SkillService(
+ SimpleNamespace(
+ skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
+ workspace_service=_workspace_service(),
+ )
+ )
with pytest.raises(ValueError, match='not initialised'):
- await service.create_skill({'name': 'x'})
+ await service.create_skill(_CONTEXT, {'name': 'x'})
@pytest.mark.asyncio
async def test_list_skills_returns_empty_when_box_unavailable(self):
"""list_skills should render an empty surface (not crash) so the
skills page can show a banner instead of a broken state."""
service = SkillService(self._ap_with_disabled_box())
- assert await service.list_skills() == []
+ assert await service.list_skills(_CONTEXT) == []
@pytest.mark.asyncio
async def test_read_skill_file_refused_when_box_unavailable(self):
service = SkillService(self._ap_with_disabled_box())
with pytest.raises(ValueError, match='Reading a skill file'):
- await service.read_skill_file('x', 'a.txt')
+ await service.read_skill_file(_CONTEXT, 'x', 'a.txt')
+
+
+class TestGithubSkillArchiveLimits:
+ @staticmethod
+ def _service() -> SkillService:
+ return SkillService(SimpleNamespace())
+
+ @pytest.mark.asyncio
+ async def test_download_rejects_declared_oversized_archive(self, monkeypatch):
+ import langbot.pkg.api.http.service.skill as skill_module
+
+ real_client = httpx.AsyncClient
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(
+ 200,
+ headers={'content-length': str(10 * 1024 * 1024 + 1)},
+ content=b'',
+ request=request,
+ )
+
+ monkeypatch.setattr(
+ skill_module.httpx,
+ 'AsyncClient',
+ lambda **_kwargs: real_client(transport=httpx.MockTransport(handler)),
+ )
+
+ with pytest.raises(ValueError, match='compressed size limit'):
+ await self._service()._download_github_asset('https://codeload.github.com/o/r/zip/main')
+
+ def test_copy_rejects_high_compression_ratio_before_extracting(self):
+ source_buffer = io.BytesIO()
+ with zipfile.ZipFile(source_buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr('repo-main/skill/SKILL.md', b'---\nname: safe\n---\n')
+ archive.writestr('repo-main/skill/bomb.bin', b'0' * (1024 * 1024))
+
+ source_buffer.seek(0)
+ target_buffer = io.BytesIO()
+ with (
+ zipfile.ZipFile(source_buffer, 'r') as source_zip,
+ zipfile.ZipFile(target_buffer, 'w', zipfile.ZIP_DEFLATED) as target_zip,
+ ):
+ with pytest.raises(ValueError, match='compression-ratio limit'):
+ self._service()._copy_github_skill_directory_to_zip(
+ source_zip,
+ target_zip,
+ 'repo-main/skill',
+ 'safe',
+ )
diff --git a/tests/unit_tests/test_telemetry.py b/tests/unit_tests/test_telemetry.py
index e9159337c..ed3994923 100644
--- a/tests/unit_tests/test_telemetry.py
+++ b/tests/unit_tests/test_telemetry.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from types import SimpleNamespace
+import asyncio
import pytest
@@ -20,7 +21,9 @@ async def test_send_tasks_are_scoped_to_manager_instance(monkeypatch):
assert first.send_tasks is not second.send_tasks
await first.start_send_task({'event': 'first'})
- await first.send_tasks[0]
+ task = first.send_tasks[0]
+ await task
+ await asyncio.sleep(0)
- assert len(first.send_tasks) == 1
+ assert first.send_tasks == []
assert second.send_tasks == []
diff --git a/tests/unit_tests/utils/test_bounded_executor.py b/tests/unit_tests/utils/test_bounded_executor.py
new file mode 100644
index 000000000..34581734d
--- /dev/null
+++ b/tests/unit_tests/utils/test_bounded_executor.py
@@ -0,0 +1,278 @@
+from __future__ import annotations
+
+import asyncio
+import threading
+
+import pytest
+
+from langbot.pkg.utils.bounded_executor import (
+ BlockingWorkCapacityError,
+ BoundedThreadPoolExecutor,
+ blocking_work_scope,
+ configure_bounded_default_executor,
+ run_blocking_atomic,
+ run_blocking_cleanup,
+)
+
+
+def test_bounded_executor_rejects_instead_of_queueing_without_limit():
+ executor = BoundedThreadPoolExecutor(
+ max_workers=1,
+ max_pending=1,
+ max_inflight_per_scope=1,
+ )
+ started = threading.Event()
+ release = threading.Event()
+
+ def block() -> str:
+ started.set()
+ release.wait(timeout=5)
+ return 'done'
+
+ first = executor.submit(block)
+ assert started.wait(timeout=1)
+ second = executor.submit(lambda: 'queued')
+
+ with pytest.raises(
+ BlockingWorkCapacityError,
+ match='capacity reached',
+ ):
+ executor.submit(lambda: 'rejected')
+
+ assert executor.snapshot() == {
+ 'max_workers': 1,
+ 'max_pending': 1,
+ 'max_inflight_per_scope': 1,
+ 'inflight': 2,
+ 'running': 1,
+ 'pending': 1,
+ 'active_scopes': 0,
+ 'submitted_total': 2,
+ 'completed_total': 0,
+ 'rejected_total': 1,
+ 'global_rejected_total': 1,
+ 'scope_rejected_total': 0,
+ }
+
+ release.set()
+ assert first.result(timeout=1) == 'done'
+ assert second.result(timeout=1) == 'queued'
+ assert executor.snapshot()['inflight'] == 0
+ executor.shutdown()
+
+
+def test_workspace_scope_cannot_monopolize_global_workers():
+ executor = BoundedThreadPoolExecutor(
+ max_workers=2,
+ max_pending=2,
+ max_inflight_per_scope=1,
+ )
+ release = threading.Event()
+ workspace_a_started = threading.Event()
+ workspace_b_started = threading.Event()
+
+ def block(started: threading.Event) -> str:
+ started.set()
+ release.wait(timeout=5)
+ return 'done'
+
+ try:
+ with blocking_work_scope('workspace-a'):
+ workspace_a = executor.submit(block, workspace_a_started)
+ assert workspace_a_started.wait(timeout=1)
+ with pytest.raises(
+ BlockingWorkCapacityError,
+ match='Workspace blocking executor capacity reached',
+ ):
+ executor.submit(lambda: 'rejected')
+
+ with blocking_work_scope('workspace-b'):
+ workspace_b = executor.submit(block, workspace_b_started)
+ assert workspace_b_started.wait(timeout=1)
+
+ snapshot = executor.snapshot()
+ assert snapshot['inflight'] == 2
+ assert snapshot['active_scopes'] == 2
+ assert snapshot['scope_rejected_total'] == 1
+ assert snapshot['global_rejected_total'] == 0
+ finally:
+ release.set()
+ assert workspace_a.result(timeout=1) == 'done'
+ assert workspace_b.result(timeout=1) == 'done'
+ executor.shutdown()
+
+
+def test_default_executor_bounds_asyncio_to_thread():
+ loop = asyncio.new_event_loop()
+ executor = configure_bounded_default_executor(
+ loop,
+ max_workers=2,
+ max_pending=3,
+ )
+ try:
+ assert loop.run_until_complete(asyncio.to_thread(lambda: 'bounded')) == 'bounded'
+ assert executor.snapshot()['completed_total'] == 1
+ assert (
+ configure_bounded_default_executor(
+ loop,
+ max_workers=2,
+ max_pending=3,
+ )
+ is executor
+ )
+ finally:
+ executor.shutdown()
+ loop.close()
+
+
+def test_workspace_scope_is_enforced_for_asyncio_to_thread():
+ loop = asyncio.new_event_loop()
+ executor = configure_bounded_default_executor(
+ loop,
+ max_workers=2,
+ max_pending=2,
+ max_inflight_per_scope=1,
+ )
+ started = threading.Event()
+ release = threading.Event()
+
+ def block() -> str:
+ started.set()
+ release.wait(timeout=5)
+ return 'workspace-a'
+
+ async def exercise() -> None:
+ with blocking_work_scope('workspace-a'):
+ workspace_a = asyncio.create_task(asyncio.to_thread(block))
+ try:
+ while not started.is_set():
+ await asyncio.sleep(0)
+
+ with blocking_work_scope('workspace-a'):
+ with pytest.raises(
+ BlockingWorkCapacityError,
+ match='Workspace blocking executor capacity reached',
+ ):
+ await asyncio.to_thread(lambda: 'rejected')
+
+ with blocking_work_scope('workspace-b'):
+ assert await asyncio.to_thread(lambda: 'workspace-b') == 'workspace-b'
+ finally:
+ release.set()
+ assert await workspace_a == 'workspace-a'
+
+ try:
+ loop.run_until_complete(exercise())
+ finally:
+ executor.shutdown()
+ loop.close()
+
+
+def test_blocking_cleanup_waits_for_capacity_instead_of_leaking_work():
+ loop = asyncio.new_event_loop()
+ executor = configure_bounded_default_executor(
+ loop,
+ max_workers=1,
+ max_pending=0,
+ max_inflight_per_scope=1,
+ )
+ started = threading.Event()
+ release = threading.Event()
+ cleaned = threading.Event()
+
+ def block() -> None:
+ started.set()
+ release.wait(timeout=5)
+
+ async def exercise() -> None:
+ blocker = asyncio.create_task(asyncio.to_thread(block))
+ while not started.is_set():
+ await asyncio.sleep(0)
+ cleanup = asyncio.create_task(run_blocking_cleanup(cleaned.set))
+ await asyncio.sleep(0.03)
+ assert not cleanup.done()
+ release.set()
+ await blocker
+ await cleanup
+
+ try:
+ loop.run_until_complete(exercise())
+ assert cleaned.is_set()
+ assert executor.snapshot()['global_rejected_total'] >= 1
+ finally:
+ release.set()
+ executor.shutdown()
+ loop.close()
+
+
+def test_blocking_atomic_waits_for_thread_before_propagating_cancellation():
+ loop = asyncio.new_event_loop()
+ executor = configure_bounded_default_executor(
+ loop,
+ max_workers=1,
+ max_pending=1,
+ max_inflight_per_scope=1,
+ )
+ started = threading.Event()
+ release = threading.Event()
+ completed = threading.Event()
+
+ def block() -> None:
+ started.set()
+ release.wait(timeout=5)
+ completed.set()
+
+ async def exercise() -> None:
+ operation = asyncio.create_task(run_blocking_atomic(block))
+ while not started.is_set():
+ await asyncio.sleep(0)
+ operation.cancel()
+ await asyncio.sleep(0)
+ assert not operation.done()
+ release.set()
+ with pytest.raises(asyncio.CancelledError):
+ await operation
+
+ try:
+ loop.run_until_complete(exercise())
+ assert completed.is_set()
+ finally:
+ release.set()
+ executor.shutdown()
+ loop.close()
+
+
+@pytest.mark.parametrize(
+ ('max_workers', 'max_pending'),
+ [
+ (0, 1),
+ (65, 1),
+ (1, -1),
+ (1, 4097),
+ (True, 1),
+ ],
+)
+def test_bounded_executor_rejects_unsafe_limits(
+ max_workers,
+ max_pending,
+):
+ with pytest.raises(ValueError):
+ BoundedThreadPoolExecutor(
+ max_workers=max_workers,
+ max_pending=max_pending,
+ )
+
+
+@pytest.mark.parametrize(
+ ('max_workers', 'max_inflight_per_scope'),
+ [(8, 0), (8, 4097), (8, True), (8, 5), (2, 2)],
+)
+def test_bounded_executor_rejects_unsafe_scope_limits(
+ max_workers,
+ max_inflight_per_scope,
+):
+ with pytest.raises(ValueError):
+ BoundedThreadPoolExecutor(
+ max_workers=max_workers,
+ max_inflight_per_scope=max_inflight_per_scope,
+ )
diff --git a/tests/unit_tests/utils/test_cloud_runtime_soak.py b/tests/unit_tests/utils/test_cloud_runtime_soak.py
new file mode 100644
index 000000000..c10b14314
--- /dev/null
+++ b/tests/unit_tests/utils/test_cloud_runtime_soak.py
@@ -0,0 +1,577 @@
+from __future__ import annotations
+
+import io
+import json
+from collections import deque
+from pathlib import Path
+
+import pytest
+
+from scripts import cloud_runtime_soak as soak
+
+
+def _write(path: Path, value: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(value, encoding='utf-8')
+
+
+def _process_stat(pid: int, *, user_ticks: int, system_ticks: int) -> str:
+ fields = [
+ 'S',
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ str(user_ticks),
+ str(system_ticks),
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ '0',
+ ]
+ return f'{pid} (worker with spaces) ' + ' '.join(fields)
+
+
+def _sample(timestamp: float, **metrics: float) -> soak.MetricSample:
+ return soak.MetricSample(
+ monotonic_seconds=timestamp,
+ wall_time=f'sample-{timestamp}',
+ metrics=metrics,
+ )
+
+
+def _state(
+ kind: str,
+ samples: list[soak.MetricSample],
+ *,
+ baseline: dict[str, float] | None = None,
+ latest: dict[str, float] | None = None,
+) -> soak.TargetState:
+ return soak.TargetState(
+ target=soak.Target(name='target', kind=kind, location='/target'),
+ samples=deque(samples),
+ baseline_metrics=baseline or dict(samples[0].metrics),
+ last_metrics=latest or dict(samples[-1].metrics),
+ attempted_samples=len(samples),
+ successful_samples=len(samples),
+ )
+
+
+def _thresholds(**overrides) -> soak.Thresholds:
+ values = {
+ 'max_memory_growth_bytes': 64 * soak.BYTES_PER_MIB,
+ 'max_memory_slope_bytes_per_hour': 32 * soak.BYTES_PER_MIB,
+ 'max_tail_cpu_cores': 0.5,
+ 'max_throttled_period_ratio': 0.25,
+ 'allow_rejections': False,
+ 'max_transient_gauge_growth': 0,
+ 'require_hard_limits': False,
+ 'max_event_loop_lag_ms': 1000,
+ 'max_event_loop_p95_lag_ms': 250,
+ 'require_event_loop_metrics': True,
+ }
+ values.update(overrides)
+ return soak.Thresholds(**values)
+
+
+@pytest.mark.parametrize(
+ ('value', 'expected'),
+ [
+ ('1', 1),
+ ('1.5s', 1.5),
+ ('2m', 120),
+ ('3H', 10_800),
+ ('1d', 86_400),
+ ],
+)
+def test_parse_duration(value: str, expected: float) -> None:
+ assert soak.parse_duration(value) == expected
+
+
+@pytest.mark.parametrize('value', ['', '0s', '-1s', 'wat'])
+def test_parse_duration_rejects_invalid_values(value: str) -> None:
+ with pytest.raises(Exception):
+ soak.parse_duration(value)
+
+
+def test_build_targets_rejects_secret_bearing_health_url() -> None:
+ with pytest.raises(ValueError, match='must not contain credentials'):
+ soak.build_targets(
+ endpoints=['core=https://user:secret@example.test/healthz'],
+ cgroups=[],
+ pids=[],
+ )
+
+ with pytest.raises(ValueError, match='query parameters'):
+ soak.build_targets(
+ endpoints=['core=https://example.test/healthz?token=secret'],
+ cgroups=[],
+ pids=[],
+ )
+
+
+def test_read_cgroup_snapshot_reads_v2_pressure_and_limits(tmp_path: Path) -> None:
+ _write(tmp_path / 'memory.current', '1048576\n')
+ _write(tmp_path / 'memory.peak', '2097152\n')
+ _write(tmp_path / 'memory.swap.current', '4096\n')
+ _write(tmp_path / 'memory.max', '1073741824\n')
+ _write(tmp_path / 'memory.swap.max', 'max\n')
+ _write(tmp_path / 'pids.current', '7\n')
+ _write(tmp_path / 'pids.max', '128\n')
+ _write(
+ tmp_path / 'cpu.stat',
+ 'usage_usec 1234\nnr_periods 20\nnr_throttled 2\nthrottled_usec 99\n',
+ )
+ _write(tmp_path / 'memory.events', 'high 1\nmax 2\noom 0\noom_kill 0\n')
+ _write(tmp_path / 'pids.events', 'max 3\n')
+ _write(tmp_path / 'cpu.max', '100000 100000\n')
+
+ metrics = soak.read_cgroup_snapshot(tmp_path)
+
+ assert metrics['memory.current_bytes'] == 1_048_576
+ assert metrics['memory.max_bytes'] == 1_073_741_824
+ assert 'memory.swap.max_bytes' not in metrics
+ assert metrics['cpu.usage_usec'] == 1234
+ assert metrics['cpu.nr_throttled'] == 2
+ assert metrics['memory.events.max'] == 2
+ assert metrics['pids.events.max'] == 3
+ assert metrics['cpu.quota_usec'] == 100_000
+
+
+def test_read_process_snapshot_aggregates_descendants(tmp_path: Path) -> None:
+ for pid, rss_kib, threads, user_ticks, system_ticks in (
+ (100, 1000, 2, 100, 50),
+ (200, 500, 1, 20, 10),
+ ):
+ process_root = tmp_path / str(pid)
+ _write(
+ process_root / 'status',
+ f'Name:\tworker\nVmRSS:\t{rss_kib} kB\nThreads:\t{threads}\n',
+ )
+ _write(
+ process_root / 'stat',
+ _process_stat(
+ pid,
+ user_ticks=user_ticks,
+ system_ticks=system_ticks,
+ ),
+ )
+ (process_root / 'fd').mkdir()
+ (process_root / 'fd' / '0').touch()
+ (process_root / 'fd' / '1').touch()
+ (process_root / 'task' / str(pid)).mkdir(parents=True)
+ _write(tmp_path / '100' / 'task' / '100' / 'children', '200\n')
+ _write(tmp_path / '200' / 'task' / '200' / 'children', '\n')
+
+ metrics = soak.read_process_snapshot(100, proc_root=tmp_path, clock_ticks=100)
+
+ assert metrics == {
+ 'rss_bytes': 1500 * 1024,
+ 'cpu_seconds': 1.8,
+ 'threads': 3,
+ 'open_fds': 4,
+ 'processes': 2,
+ }
+
+
+class _FakeResponse:
+ def __init__(self, payload: dict) -> None:
+ self.status = 200
+ self.headers = {'Content-Type': 'application/json'}
+ self._body = json.dumps(payload).encode()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return None
+
+ def getcode(self) -> int:
+ return self.status
+
+ def read(self, limit: int) -> bytes:
+ return self._body[:limit]
+
+
+def test_read_endpoint_snapshot_flattens_resource_metrics() -> None:
+ def opener(_request, *, timeout: float):
+ assert timeout == 2
+ return _FakeResponse(
+ {
+ 'code': 0,
+ 'resources': {
+ 'blocking_executor': {
+ 'pending': 0,
+ 'global_rejected_total': 2,
+ },
+ 'restart_coordinator': {
+ 'active_launches': 1,
+ 'circuit_open_total': 3,
+ },
+ },
+ }
+ )
+
+ metrics = soak.read_endpoint_snapshot(
+ 'http://langbot.test/healthz',
+ timeout_seconds=2,
+ opener=opener,
+ )
+
+ assert metrics['http.ok'] == 1
+ assert metrics['body.resources.blocking_executor.pending'] == 0
+ assert metrics['body.resources.blocking_executor.global_rejected_total'] == 2
+ assert metrics['body.resources.restart_coordinator.active_launches'] == 1
+ assert metrics['body.resources.restart_coordinator.circuit_open_total'] == 3
+
+
+def test_read_endpoint_snapshot_fails_closed_on_not_ready() -> None:
+ def opener(_request, *, timeout: float):
+ return _FakeResponse({'ready': False})
+
+ with pytest.raises(RuntimeError, match='not ready'):
+ soak.read_endpoint_snapshot(
+ 'http://box.test/readyz',
+ timeout_seconds=2,
+ opener=opener,
+ )
+
+
+def test_evaluate_gate_accepts_stable_process_tail() -> None:
+ state = _state(
+ 'process',
+ [
+ _sample(0, rss_bytes=100 * soak.BYTES_PER_MIB, cpu_seconds=0),
+ _sample(1800, rss_bytes=101 * soak.BYTES_PER_MIB, cpu_seconds=10),
+ _sample(3600, rss_bytes=100 * soak.BYTES_PER_MIB, cpu_seconds=20),
+ ],
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(),
+ )
+
+ assert result.passed
+ assert result.targets['process:target']['cpu.average_cores'] < 0.01
+
+
+def test_evaluate_gate_detects_material_memory_leak_and_idle_cpu() -> None:
+ state = _state(
+ 'process',
+ [
+ _sample(0, rss_bytes=100 * soak.BYTES_PER_MIB, cpu_seconds=0),
+ _sample(1800, rss_bytes=150 * soak.BYTES_PER_MIB, cpu_seconds=1800),
+ _sample(3600, rss_bytes=200 * soak.BYTES_PER_MIB, cpu_seconds=3600),
+ ],
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(),
+ )
+
+ assert not result.passed
+ assert any('grew 100.00 MiB' in failure for failure in result.failures)
+ assert any('tail CPU averaged 1.000 cores' in failure for failure in result.failures)
+
+
+def test_evaluate_gate_counts_oom_and_throttling_across_workload() -> None:
+ baseline = {
+ 'memory.current_bytes': 100,
+ 'memory.events.high': 0,
+ 'memory.events.max': 0,
+ 'memory.events.oom': 0,
+ 'memory.events.oom_kill': 0,
+ 'pids.events.max': 0,
+ 'cpu.usage_usec': 0,
+ 'cpu.nr_periods': 0,
+ 'cpu.nr_throttled': 0,
+ }
+ latest = {
+ **baseline,
+ 'memory.events.oom_kill': 1,
+ 'pids.events.max': 2,
+ 'cpu.usage_usec': 1_000_000,
+ 'cpu.nr_periods': 100,
+ 'cpu.nr_throttled': 30,
+ }
+ state = _state(
+ 'cgroup',
+ [
+ _sample(100, **{**baseline, 'cpu.usage_usec': 500_000}),
+ _sample(200, **latest),
+ ],
+ baseline=baseline,
+ latest=latest,
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=100,
+ thresholds=_thresholds(max_tail_cpu_cores=100),
+ )
+
+ assert any('memory.events.oom_kill by 1' in failure for failure in result.failures)
+ assert any('pids.events.max by 2' in failure for failure in result.failures)
+ assert any('throttled-period ratio 0.300' in failure for failure in result.failures)
+
+
+def test_evaluate_gate_can_require_all_hard_cgroup_limits() -> None:
+ metrics = {
+ 'memory.current_bytes': 100,
+ 'memory.max_bytes': 1000,
+ 'memory.events.high': 0,
+ 'memory.events.max': 0,
+ 'memory.events.oom': 0,
+ 'memory.events.oom_kill': 0,
+ 'pids.current': 1,
+ 'pids.events.max': 0,
+ 'cpu.usage_usec': 0,
+ 'cpu.nr_periods': 0,
+ 'cpu.nr_throttled': 0,
+ }
+ state = _state(
+ 'cgroup',
+ [_sample(0, **metrics), _sample(60, **metrics)],
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(require_hard_limits=True),
+ )
+
+ assert any('missing hard cgroup limits: cpu, pids, swap' in failure for failure in result.failures)
+
+
+def test_evaluate_gate_detects_executor_rejection_and_stuck_pending() -> None:
+ prefix = 'body.resources.blocking_executor'
+ state = _state(
+ 'endpoint',
+ [
+ _sample(
+ 0,
+ **{
+ f'{prefix}.pending': 1,
+ f'{prefix}.global_rejected_total': 0,
+ },
+ ),
+ _sample(
+ 60,
+ **{
+ f'{prefix}.pending': 2,
+ f'{prefix}.global_rejected_total': 1,
+ },
+ ),
+ ],
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(),
+ )
+
+ assert any('global_rejected_total by 1' in failure for failure in result.failures)
+ assert any('pending above zero' in failure for failure in result.failures)
+
+
+def test_evaluate_gate_detects_restart_circuit_and_stuck_launch() -> None:
+ prefix = 'body.resources.restart_coordinator'
+ state = _state(
+ 'endpoint',
+ [
+ _sample(
+ 0,
+ **{
+ f'{prefix}.active_launches': 1,
+ f'{prefix}.gate_waiters': 2,
+ f'{prefix}.half_open_probe_inflight': 1,
+ f'{prefix}.open_remaining_seconds': 60,
+ f'{prefix}.circuit_open_total': 0,
+ },
+ ),
+ _sample(
+ 60,
+ **{
+ f'{prefix}.active_launches': 1,
+ f'{prefix}.gate_waiters': 2,
+ f'{prefix}.half_open_probe_inflight': 1,
+ f'{prefix}.open_remaining_seconds': 1,
+ f'{prefix}.circuit_open_total': 1,
+ },
+ ),
+ ],
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(),
+ )
+
+ assert any('circuit_open_total by 1' in failure for failure in result.failures)
+ assert any('active_launches above zero' in failure for failure in result.failures)
+ assert any('gate_waiters above zero' in failure for failure in result.failures)
+ assert any('half_open_probe_inflight above zero' in failure for failure in result.failures)
+ assert any('open_remaining_seconds above zero' in failure for failure in result.failures)
+
+
+def test_evaluate_gate_detects_stuck_mcp_projection_cleanup() -> None:
+ prefix = 'body.resources.runtimes'
+ state = _state(
+ 'endpoint',
+ [
+ _sample(
+ 0,
+ **{
+ f'{prefix}.mcp_projection_retirements': 3,
+ f'{prefix}.mcp_projection_reconcile_active': 1,
+ },
+ ),
+ _sample(
+ 60,
+ **{
+ f'{prefix}.mcp_projection_retirements': 1,
+ f'{prefix}.mcp_projection_reconcile_active': 1,
+ },
+ ),
+ ],
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(),
+ )
+
+ assert any('mcp_projection_retirements above zero' in failure for failure in result.failures)
+ assert any('mcp_projection_reconcile_active above zero' in failure for failure in result.failures)
+
+
+def test_evaluate_gate_detects_directory_and_database_capacity_violation() -> None:
+ prefix = 'body.resources'
+ state = _state(
+ 'endpoint',
+ [
+ _sample(
+ 0,
+ **{
+ f'{prefix}.directory.active_workspaces': 1000,
+ f'{prefix}.directory.max_active_workspaces': 1000,
+ f'{prefix}.database_pool.checked_out': 20,
+ f'{prefix}.database_pool.configured_capacity': 20,
+ },
+ ),
+ _sample(
+ 60,
+ **{
+ f'{prefix}.directory.active_workspaces': 1001,
+ f'{prefix}.directory.max_active_workspaces': 1000,
+ f'{prefix}.database_pool.checked_out': 21,
+ f'{prefix}.database_pool.configured_capacity': 20,
+ },
+ ),
+ ],
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(),
+ )
+
+ assert any('directory.active_workspaces' in failure for failure in result.failures)
+ assert any('database_pool.checked_out' in failure for failure in result.failures)
+
+
+def test_evaluate_gate_requires_capacity_gauges_from_named_core_endpoint() -> None:
+ state = _state(
+ 'endpoint',
+ [_sample(0, **{'http.ok': 1}), _sample(60, **{'http.ok': 1})],
+ )
+ state.target = soak.Target(name='core', kind='endpoint', location='/core')
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(require_event_loop_metrics=False),
+ )
+
+ assert any('required capacity gauge' in failure for failure in result.failures)
+
+
+def test_evaluate_gate_detects_event_loop_stall_and_sustained_lag() -> None:
+ prefix = 'body.resources.event_loop'
+ samples = [
+ _sample(
+ 0,
+ **{
+ f'{prefix}.running': 1,
+ f'{prefix}.samples_total': 10,
+ f'{prefix}.recent_max_lag_ms': 20,
+ f'{prefix}.recent_p95_lag_ms': 10,
+ },
+ ),
+ _sample(
+ 60,
+ **{
+ f'{prefix}.running': 1,
+ f'{prefix}.samples_total': 70,
+ f'{prefix}.recent_max_lag_ms': 1500,
+ f'{prefix}.recent_p95_lag_ms': 300,
+ },
+ ),
+ ]
+ state = _state('endpoint', samples)
+ state.observed_max_metrics = {
+ metric: max(sample.metrics[metric] for sample in samples) for metric in samples[0].metrics
+ }
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(),
+ )
+
+ assert any('event-loop lag reached 1500.00 ms' in item for item in result.failures)
+ assert any('recent p95 reached 300.00 ms' in item for item in result.failures)
+
+
+def test_evaluate_gate_requires_running_event_loop_monitor() -> None:
+ state = _state(
+ 'endpoint',
+ [_sample(0, **{'http.ok': 1}), _sample(60, **{'http.ok': 1})],
+ )
+
+ result = soak.evaluate_gate(
+ [state],
+ analysis_start_seconds=0,
+ thresholds=_thresholds(),
+ )
+
+ assert any('did not expose event-loop health metrics' in item for item in result.failures)
+
+
+def test_write_json_line_streams_one_record() -> None:
+ stream = io.StringIO()
+ soak._write_json_line(stream, {'z': 1, 'a': 2})
+ assert stream.getvalue() == '{"a":2,"z":1}\n'
+
+
+def test_main_requires_a_target() -> None:
+ with pytest.raises(SystemExit) as exc_info:
+ soak.main(['--duration', '2s', '--sample-interval', '1s', '--startup-grace', '1s'])
+ assert exc_info.value.code == 2
diff --git a/tests/unit_tests/utils/test_event_loop_monitor.py b/tests/unit_tests/utils/test_event_loop_monitor.py
new file mode 100644
index 000000000..f80186d94
--- /dev/null
+++ b/tests/unit_tests/utils/test_event_loop_monitor.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import asyncio
+import time
+
+import pytest
+
+from langbot.pkg.utils.event_loop_monitor import EventLoopLagMonitor
+
+
+@pytest.mark.parametrize(
+ 'kwargs',
+ [
+ {'sample_interval_seconds': 0},
+ {'sample_interval_seconds': float('inf')},
+ {'recent_sample_count': 1},
+ {'recent_sample_count': 3601},
+ ],
+)
+def test_event_loop_monitor_rejects_unbounded_configuration(kwargs) -> None:
+ with pytest.raises(ValueError):
+ EventLoopLagMonitor(**kwargs)
+
+
+def test_event_loop_monitor_snapshot_is_bounded_and_reports_p95() -> None:
+ monitor = EventLoopLagMonitor(recent_sample_count=4)
+ for lag_seconds in (0.001, 0.002, 0.003, 0.004, 0.100):
+ monitor._record_lag_seconds(lag_seconds)
+
+ snapshot = monitor.snapshot()
+
+ assert snapshot == {
+ 'running': False,
+ 'samples_total': 5,
+ 'last_lag_ms': 100,
+ 'recent_p95_lag_ms': 100,
+ 'recent_max_lag_ms': 100,
+ 'max_lag_ms': 100,
+ }
+ assert len(monitor._recent_lag_ms) == 4
+
+
+async def test_event_loop_monitor_start_and_stop_are_idempotent() -> None:
+ monitor = EventLoopLagMonitor(
+ sample_interval_seconds=0.001,
+ recent_sample_count=4,
+ )
+
+ monitor.start()
+ task = monitor._task
+ monitor.start()
+ assert monitor._task is task
+ await asyncio.sleep(0.005)
+ assert monitor.snapshot()['samples_total'] > 0
+ assert monitor.snapshot()['running'] is True
+
+ await monitor.stop()
+ await monitor.stop()
+ assert monitor.snapshot()['running'] is False
+ assert task is not None and task.done()
+
+
+async def test_event_loop_monitor_observes_real_scheduler_stall() -> None:
+ monitor = EventLoopLagMonitor(
+ sample_interval_seconds=0.005,
+ recent_sample_count=8,
+ )
+ monitor.start()
+ try:
+ await asyncio.sleep(0.01)
+ time.sleep(0.05)
+ await asyncio.sleep(0.01)
+ assert monitor.snapshot()['recent_max_lag_ms'] >= 35
+ finally:
+ await monitor.stop()
diff --git a/tests/unit_tests/utils/test_httpclient.py b/tests/unit_tests/utils/test_httpclient.py
index 0a102969a..cf1653cb8 100644
--- a/tests/unit_tests/utils/test_httpclient.py
+++ b/tests/unit_tests/utils/test_httpclient.py
@@ -6,8 +6,14 @@ Tests session management, reuse, and cleanup.
from __future__ import annotations
+import asyncio
+import threading
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
import pytest
import aiohttp
+import httpx
from aiohttp import web
from langbot.pkg.utils import httpclient
@@ -25,6 +31,7 @@ class TestGetSession:
assert isinstance(session, aiohttp.ClientSession)
assert not session.closed
+ assert isinstance(session.cookie_jar, aiohttp.DummyCookieJar)
# Cleanup
await session.close()
@@ -87,6 +94,124 @@ class TestCloseAll:
assert len(httpclient._sessions) == 0
+
+class TestReadLimited:
+ async def test_rejects_oversized_content_length_before_reading(self):
+ content = SimpleNamespace(iter_chunked=None)
+ response = SimpleNamespace(headers={'Content-Length': '11'}, content=content)
+
+ with pytest.raises(httpclient.RemoteResponseTooLargeError):
+ await httpclient.read_limited(response, max_bytes=10)
+
+ async def test_rejects_chunked_body_that_crosses_limit(self):
+ class Content:
+ async def iter_chunked(self, _chunk_size):
+ yield b'12345'
+ yield b'678901'
+
+ response = SimpleNamespace(headers={}, content=Content())
+
+ with pytest.raises(httpclient.RemoteResponseTooLargeError):
+ await httpclient.read_limited(response, max_bytes=10)
+
+ async def test_returns_body_within_limit(self):
+ class Content:
+ async def iter_chunked(self, _chunk_size):
+ yield b'12345'
+ yield b'67890'
+
+ response = SimpleNamespace(headers={}, content=Content())
+
+ assert await httpclient.read_limited(response, max_bytes=10) == b'1234567890'
+
+ async def test_json_reader_uses_same_limit(self):
+ class Content:
+ async def iter_chunked(self, _chunk_size):
+ yield b'{"ok":true}'
+
+ response = SimpleNamespace(
+ headers={},
+ content=Content(),
+ )
+
+ assert await httpclient.read_json_limited(response, max_bytes=16) == {'ok': True}
+
+ async def test_response_json_parse_runs_off_event_loop(self):
+ event_loop_thread = threading.get_ident()
+ response = SimpleNamespace(json=lambda: threading.get_ident())
+
+ assert await httpclient.parse_json_response(response) != event_loop_thread
+
+ async def test_response_json_parse_supports_async_test_doubles(self):
+ response = SimpleNamespace(json=AsyncMock(return_value={'ok': True}))
+
+ assert await httpclient.parse_json_response(response) == {'ok': True}
+
+ async def test_response_text_runs_off_loop_and_caps_diagnostics(self):
+ event_loop_thread = threading.get_ident()
+
+ class Response:
+ @property
+ def text(self):
+ return f'{threading.get_ident()}:abcdef'
+
+ value = await httpclient.response_text(Response(), max_chars=4)
+
+ assert not value.startswith(str(event_loop_thread))
+ assert value.endswith('[truncated]')
+
+ async def test_httpx_hook_rejects_before_automatic_buffer_grows(self):
+ class Source(httpx.AsyncByteStream):
+ def __init__(self):
+ self.closed = False
+
+ async def __aiter__(self):
+ yield b'123'
+ yield b'45'
+
+ async def aclose(self):
+ self.closed = True
+
+ source = Source()
+ transport = httpx.MockTransport(lambda _request: httpx.Response(200, stream=source))
+ async with httpx.AsyncClient(
+ transport=transport,
+ event_hooks=httpclient.httpx_response_limit_hooks(max_bytes=4),
+ ) as client:
+ with pytest.raises(httpclient.RemoteResponseTooLargeError, match='4-byte'):
+ await client.get('https://example.invalid')
+
+ assert source.closed
+
+ async def test_httpx_limited_stream_closes_source_when_consumer_is_cancelled(self):
+ class Source(httpx.AsyncByteStream):
+ def __init__(self):
+ self.closed = False
+
+ async def __aiter__(self):
+ yield b'123'
+ await asyncio.Event().wait()
+
+ async def aclose(self):
+ self.closed = True
+
+ source = Source()
+ stream = httpclient._LimitedHTTPXAsyncByteStream(source, max_bytes=4)
+ first_chunk_consumed = asyncio.Event()
+
+ async def consume():
+ async for _chunk in stream:
+ first_chunk_consumed.set()
+
+ task = asyncio.create_task(consume())
+ await first_chunk_consumed.wait()
+ task.cancel()
+
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+ assert source.closed
+
async def test_close_all_handles_already_closed(self):
"""close_all handles already closed sessions gracefully."""
session = httpclient.get_session()
@@ -144,3 +269,12 @@ class TestSessionPoolIntegration:
assert session is session2
await httpclient.close_all()
+
+ async def test_shared_session_does_not_persist_cookies(self):
+ """Shared transport pooling never creates cross-Workspace cookie state."""
+ session = httpclient.get_session()
+
+ session.cookie_jar.update_cookies({'workspace_session': 'secret'})
+
+ assert list(session.cookie_jar) == []
+ await httpclient.close_all()
diff --git a/tests/unit_tests/utils/test_image.py b/tests/unit_tests/utils/test_image.py
index 4a42717ba..5c4f9eb50 100644
--- a/tests/unit_tests/utils/test_image.py
+++ b/tests/unit_tests/utils/test_image.py
@@ -10,11 +10,28 @@ import pytest
import base64
from langbot.pkg.utils.image import (
+ decode_base64_limited,
+ encode_base64,
get_qq_image_downloadable_url,
extract_b64_and_format,
)
+@pytest.mark.asyncio
+async def test_base64_media_helpers_round_trip_within_limit():
+ encoded = await encode_base64(b'1234')
+
+ assert await decode_base64_limited(encoded, max_bytes=4) == b'1234'
+
+
+@pytest.mark.asyncio
+async def test_base64_media_decode_rejects_oversized_payload():
+ encoded = base64.b64encode(b'12345').decode()
+
+ with pytest.raises(ValueError, match='exceeds'):
+ await decode_base64_limited(encoded, max_bytes=4)
+
+
class TestGetQQImageDownloadableUrl:
"""Tests for get_qq_image_downloadable_url function."""
diff --git a/tests/unit_tests/utils/test_logcache.py b/tests/unit_tests/utils/test_logcache.py
index ed05d0ccd..775a3ed27 100644
--- a/tests/unit_tests/utils/test_logcache.py
+++ b/tests/unit_tests/utils/test_logcache.py
@@ -7,7 +7,13 @@ Tests log page management and pointer-based retrieval.
from __future__ import annotations
-from langbot.pkg.utils.logcache import LogPage, LogCache, LOG_PAGE_SIZE, MAX_CACHED_PAGES
+from langbot.pkg.utils.logcache import (
+ LogPage,
+ LogCache,
+ LOG_PAGE_SIZE,
+ MAX_CACHED_PAGES,
+ MAX_LOG_LINE_CHARS,
+)
class TestLogPage:
@@ -208,3 +214,11 @@ class TestLogCache:
"""LOG_PAGE_SIZE is defined and reasonable."""
assert LOG_PAGE_SIZE > 0
assert LOG_PAGE_SIZE <= 1000 # Reasonable upper bound
+
+ def test_single_log_line_is_bounded(self):
+ cache = LogCache()
+
+ cache.add_log('x' * (MAX_LOG_LINE_CHARS * 2))
+
+ assert len(cache.log_pages[0].logs[0]) == MAX_LOG_LINE_CHARS
+ assert cache.log_pages[0].logs[0].endswith('[log truncated]')
diff --git a/tests/unit_tests/utils/test_safe_regex.py b/tests/unit_tests/utils/test_safe_regex.py
new file mode 100644
index 000000000..6f0f985f5
--- /dev/null
+++ b/tests/unit_tests/utils/test_safe_regex.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import threading
+
+import pytest
+
+from langbot.pkg.utils import safe_regex
+from langbot.pkg.utils.bounded_executor import blocking_work_scope, current_blocking_work_scope
+
+
+@pytest.mark.asyncio
+async def test_matches_any_runs_off_event_loop_and_preserves_workspace_scope(monkeypatch):
+ event_loop_thread = threading.get_ident()
+ observed: dict[str, object] = {}
+ original = safe_regex._matches_any_sync
+
+ def observe(*args, **kwargs):
+ observed['thread'] = threading.get_ident()
+ observed['scope'] = current_blocking_work_scope()
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(safe_regex, '_matches_any_sync', observe)
+
+ with blocking_work_scope('workspace-a'):
+ assert await safe_regex.matches_any(['^hello'], 'hello world') is True
+
+ assert observed['scope'] == 'workspace-a'
+ assert observed['thread'] != event_loop_thread
+
+
+@pytest.mark.asyncio
+async def test_matches_any_interrupts_catastrophic_backtracking():
+ with pytest.raises(safe_regex.SafeRegexTimeoutError):
+ await safe_regex.matches_any(
+ [r'(a+)+$'],
+ ('a' * 100_000) + '!',
+ timeout_seconds=0.001,
+ )
+
+
+@pytest.mark.asyncio
+async def test_matches_any_rejects_pattern_and_input_amplification():
+ with pytest.raises(safe_regex.SafeRegexLimitError):
+ await safe_regex.matches_any(
+ ['a'] * (safe_regex.MAX_PATTERN_COUNT + 1),
+ 'a',
+ )
+
+ with pytest.raises(safe_regex.SafeRegexLimitError):
+ await safe_regex.matches_any(
+ ['a'],
+ 'a' * (safe_regex.MAX_INPUT_CHARS + 1),
+ )
+
+
+@pytest.mark.asyncio
+async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
+ found, masked = await safe_regex.mask_patterns(
+ [r'secret-\d+'],
+ 'a secret-42 value',
+ mask='*',
+ mask_word='[hidden]',
+ )
+ assert found is True
+ assert masked == 'a [hidden] value'
+
+ with pytest.raises(safe_regex.SafeRegexLimitError):
+ await safe_regex.mask_patterns(
+ ['a'],
+ 'a' * safe_regex.MAX_INPUT_CHARS,
+ mask='0123456789',
+ mask_word='',
+ )
diff --git a/tests/unit_tests/vector/test_mgr.py b/tests/unit_tests/vector/test_mgr.py
index 5c8927b54..608f35121 100644
--- a/tests/unit_tests/vector/test_mgr.py
+++ b/tests/unit_tests/vector/test_mgr.py
@@ -6,7 +6,9 @@ based on configuration, without actually creating real VDB instances.
from __future__ import annotations
-from unittest.mock import MagicMock
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
from tests.utils.import_isolation import isolated_sys_modules
@@ -138,6 +140,7 @@ class TestVectorDBManagerInitialization:
mgr = VectorDBManager(mock_app)
import asyncio
+
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_valkey_class.assert_called_once_with(mock_app)
@@ -207,7 +210,10 @@ class TestVectorDBManagerInitialization:
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
- mock_app, connection_string='postgresql://user:pass@host:5432/langbot'
+ mock_app,
+ connection_string='postgresql://user:pass@host:5432/langbot',
+ use_business_database=False,
+ allowed_dimensions=[384, 512, 768, 1024, 1536],
)
def test_initialize_pgvector_with_individual_params(self):
@@ -238,7 +244,14 @@ class TestVectorDBManagerInitialization:
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
- mock_app, host='db.example.com', port=5433, database='vectordb', user='admin', password='secret'
+ mock_app,
+ host='db.example.com',
+ port=5433,
+ database='vectordb',
+ user='admin',
+ password='secret',
+ use_business_database=False,
+ allowed_dimensions=[384, 512, 768, 1024, 1536],
)
def test_initialize_pgvector_defaults(self):
@@ -260,7 +273,42 @@ class TestVectorDBManagerInitialization:
asyncio.get_event_loop().run_until_complete(mgr.initialize())
mock_pgvector_class.assert_called_once_with(
- mock_app, host='localhost', port=5432, database='langbot', user='postgres', password='postgres'
+ mock_app,
+ host='localhost',
+ port=5432,
+ database='langbot',
+ user='postgres',
+ password='postgres',
+ use_business_database=False,
+ allowed_dimensions=[384, 512, 768, 1024, 1536],
+ )
+
+ def test_initialize_pgvector_with_shared_business_database(self):
+ vdb_config = {
+ 'use': 'pgvector',
+ 'pgvector': {
+ 'use_business_database': True,
+ 'allowed_dimensions': [768, 1536],
+ },
+ }
+ mock_app = self._create_mock_app(vdb_config)
+ mocks = self._make_vector_import_mocks()
+ mock_pgvector_class = MagicMock()
+ mocks['langbot.pkg.vector.vdbs.pgvector_db'].PgVectorDatabase = mock_pgvector_class
+
+ with isolated_sys_modules(mocks):
+ from langbot.pkg.vector.mgr import VectorDBManager
+
+ mgr = VectorDBManager(mock_app)
+
+ import asyncio
+
+ asyncio.get_event_loop().run_until_complete(mgr.initialize())
+
+ mock_pgvector_class.assert_called_once_with(
+ mock_app,
+ use_business_database=True,
+ allowed_dimensions=[768, 1536],
)
def test_initialize_unknown_backend_defaults_to_chroma(self):
@@ -337,3 +385,21 @@ class TestVectorDBManagerProxies:
result = mgr.get_supported_search_types()
assert result == ['vector', 'full_text']
+
+ @pytest.mark.asyncio
+ async def test_shutdown_closes_backend_and_releases_reference(self):
+ mock_app = MagicMock()
+ mocks = {'langbot.pkg.core.app': MagicMock()}
+
+ with isolated_sys_modules(mocks):
+ from langbot.pkg.vector.mgr import VectorDBManager
+
+ mgr = VectorDBManager(mock_app)
+ backend = MagicMock()
+ backend.close = AsyncMock()
+ mgr.vector_db = backend
+
+ await mgr.shutdown()
+
+ backend.close.assert_awaited_once_with()
+ assert mgr.vector_db is None
diff --git a/tests/unit_tests/vector/test_valkey_search_filter.py b/tests/unit_tests/vector/test_valkey_search_filter.py
index f2f35f074..c64c483cb 100644
--- a/tests/unit_tests/vector/test_valkey_search_filter.py
+++ b/tests/unit_tests/vector/test_valkey_search_filter.py
@@ -31,6 +31,7 @@ def make_backend():
# _ensure_client serializes creation through this lock; set it here since
# __init__ (which normally creates it) is bypassed.
backend._client_lock = asyncio.Lock()
+ backend._runtime_cache_limit = 1024
return backend
@@ -267,9 +268,9 @@ class TestDeleteByFilterGuard:
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
backend._ensure_client = AsyncMock(return_value=backend._client)
backend._index_exists = AsyncMock(return_value=True)
- # _search_keys must never be reached for an unusable filter.
- backend._search_keys = AsyncMock(
- side_effect=AssertionError('_search_keys must not be called for an unusable filter')
+ # The deletion scan must never be reached for an unusable filter.
+ backend._delete_search_results = AsyncMock(
+ side_effect=AssertionError('_delete_search_results must not be called for an unusable filter')
)
# Filter references only a non-indexed field -> maps to no FT conditions.
@@ -284,12 +285,57 @@ class TestDeleteByFilterGuard:
backend.ap = type('Ap', (), {'logger': AsyncMock()})()
backend._ensure_client = AsyncMock(return_value=backend._client)
backend._index_exists = AsyncMock(return_value=True)
- backend._search_keys = AsyncMock(return_value=['kb:col1:id1', 'kb:col1:id2'])
+ backend._delete_search_results = AsyncMock(return_value=2)
deleted = await backend.delete_by_filter('col1', {'file_id': 'f1'})
assert deleted == 2
- backend._client.delete.assert_awaited_once_with(['kb:col1:id1', 'kb:col1:id2'])
+ backend._delete_search_results.assert_awaited_once_with(
+ backend._client,
+ backend._index_name('col1'),
+ '@file_id:{f1}',
+ )
+
+
+class TestBatchedDelete:
+ async def test_matching_keys_are_deleted_in_fixed_pages(self, monkeypatch):
+ mod = get_valkey_module()
+ backend = make_backend()
+ client = AsyncMock()
+ search = AsyncMock(
+ side_effect=[
+ [3, {b'key-1': {}, b'key-2': {}}],
+ [1, {b'key-3': {}}],
+ ]
+ )
+ monkeypatch.setattr(mod, '_DELETE_SCAN_BATCH', 2)
+ monkeypatch.setattr(mod, 'FtSearchLimit', lambda offset, limit: (offset, limit), raising=False)
+ monkeypatch.setattr(mod, 'FtSearchOptions', lambda **kwargs: kwargs, raising=False)
+ monkeypatch.setattr(mod, 'ft', type('FT', (), {'search': search})(), raising=False)
+
+ deleted = await backend._delete_search_results(client, 'idx:col1', '@file_id:{f1}')
+
+ assert deleted == 3
+ assert search.await_count == 2
+ assert [call.args[3]['limit'] for call in search.await_args_list] == [(0, 2), (0, 2)]
+ assert client.delete.await_args_list[0].args == (['key-1', 'key-2'],)
+ assert client.delete.await_args_list[1].args == (['key-3'],)
+
+ async def test_delete_rounds_have_a_hard_stop(self, monkeypatch):
+ mod = get_valkey_module()
+ backend = make_backend()
+ client = AsyncMock()
+ search = AsyncMock(return_value=[2, {b'key': {}}])
+ monkeypatch.setattr(mod, '_DELETE_SCAN_BATCH', 1)
+ monkeypatch.setattr(mod, '_MAX_DELETE_SCAN_ROUNDS', 2)
+ monkeypatch.setattr(mod, 'FtSearchLimit', lambda offset, limit: (offset, limit), raising=False)
+ monkeypatch.setattr(mod, 'FtSearchOptions', lambda **kwargs: kwargs, raising=False)
+ monkeypatch.setattr(mod, 'ft', type('FT', (), {'search': search})(), raising=False)
+
+ with pytest.raises(RuntimeError, match='exceeded 2 batches'):
+ await backend._delete_search_results(client, 'idx:col1', '@file_id:{f1}')
+
+ assert client.delete.await_count == 2
class TestClose:
diff --git a/tests/unit_tests/vector/test_vdb_base.py b/tests/unit_tests/vector/test_vdb_base.py
index 427df9f19..662ed022b 100644
--- a/tests/unit_tests/vector/test_vdb_base.py
+++ b/tests/unit_tests/vector/test_vdb_base.py
@@ -5,7 +5,12 @@ from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
-from langbot.pkg.vector.vdb import SearchType, VectorDatabase
+from langbot.pkg.vector.vdb import (
+ SearchType,
+ VectorDatabase,
+ remember_bounded_mapping,
+ remember_bounded_set,
+)
class TestSearchType:
@@ -29,6 +34,18 @@ class TestSearchType:
assert SearchType('hybrid') == SearchType.HYBRID
+def test_runtime_cache_helpers_bound_mapping_and_set():
+ mapping = {}
+ values = set()
+
+ for index in range(100):
+ remember_bounded_mapping(mapping, str(index), object(), 8)
+ remember_bounded_set(values, str(index), 8)
+
+ assert len(mapping) == 8
+ assert len(values) == 8
+
+
class TestVectorDatabaseAbstractMethods:
"""Tests for VectorDatabase abstract methods."""
diff --git a/tests/unit_tests/workspace/__init__.py b/tests/unit_tests/workspace/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit_tests/workspace/test_invitation_delivery.py b/tests/unit_tests/workspace/test_invitation_delivery.py
new file mode 100644
index 000000000..34a7f5512
--- /dev/null
+++ b/tests/unit_tests/workspace/test_invitation_delivery.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from langbot.pkg.workspace.invitation_delivery import (
+ InvitationDeliveryResult,
+ InvitationDeliveryService,
+)
+
+
+pytestmark = pytest.mark.asyncio
+
+
+def _app(config: dict) -> SimpleNamespace:
+ return SimpleNamespace(
+ instance_config=SimpleNamespace(data=config),
+ logger=SimpleNamespace(warning=lambda *args, **kwargs: None),
+ )
+
+
+async def test_link_only_delivery_builds_public_web_link_without_secret_capability():
+ service = InvitationDeliveryService(
+ _app(
+ {
+ 'api': {
+ 'webui_url': 'https://public.langbot.example/',
+ 'webhook_prefix': 'http://internal:5300',
+ }
+ }
+ )
+ )
+
+ assert service.build_invitation_link('lbi_secret') == (
+ 'https://public.langbot.example/invitations/accept#token=lbi_secret'
+ )
+ assert service.capability() == {'enabled': False, 'provider': None}
+ result = await service.deliver_invitation(
+ recipient_email='member@example.com',
+ workspace_name='Workspace',
+ invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret',
+ )
+ assert result == InvitationDeliveryResult(status='link_only', provider=None)
+
+
+async def test_configured_provider_failure_returns_failed_without_raising():
+ service = InvitationDeliveryService(
+ _app(
+ {
+ 'workspace': {
+ 'invitations': {
+ 'email': {
+ 'provider': 'resend',
+ 'from': 'LangBot ',
+ 'resend': {'api_key': 'resend-secret'},
+ }
+ }
+ }
+ }
+ )
+ )
+ service._send_resend = AsyncMock(return_value=False)
+
+ assert service.capability() == {'enabled': True, 'provider': 'resend'}
+ result = await service.deliver_invitation(
+ recipient_email='member@example.com',
+ workspace_name='Workspace',
+ invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret',
+ )
+
+ assert result == InvitationDeliveryResult(status='failed', provider='resend')
+ service._send_resend.assert_awaited_once()
+
+
+async def test_environment_mapping_enables_provider_without_leaking_secret(monkeypatch):
+ monkeypatch.setenv('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', 'https://env.langbot.example/')
+ monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', 'smtp')
+ monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__FROM', 'LangBot ')
+ monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', 'smtp.example.com')
+ monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', 'smtp-secret')
+ service = InvitationDeliveryService(_app({'api': {'webui_url': 'https://config.example'}}))
+
+ assert service.build_invitation_link('lbi_secret') == (
+ 'https://env.langbot.example/invitations/accept#token=lbi_secret'
+ )
+ assert service.capability() == {'enabled': True, 'provider': 'smtp'}
+
+
+async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
+ service = InvitationDeliveryService(_app({}))
+ link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next='
+
+ text = service._plain_text('Research & Development', link)
+ html = service._html('Research & Development', link)
+
+ assert 'LangBot Cloud' in text
+ assert 'Research & Development' in text
+ assert '7 days' in text
+ assert link in text
+ assert 'Accept invitation' in html
+ assert 'Research & Development' in html
+ assert 'expires in 7 days' in html
+ assert 'lbi_secret&next=<unsafe>' in html
diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py
new file mode 100644
index 000000000..a9a79c42b
--- /dev/null
+++ b/tests/unit_tests/workspace/test_workspace_collaboration.py
@@ -0,0 +1,375 @@
+from __future__ import annotations
+
+import asyncio
+import datetime
+import uuid
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.entity.persistence.workspace import (
+ Workspace,
+ WorkspaceExecutionState,
+ WorkspaceInvitation,
+ WorkspaceMembership,
+)
+from langbot.pkg.workspace.collaboration import (
+ InvitationEmailMismatchError,
+ InvitationExpiredError,
+ InvitationRoleError,
+ InvitationUsedError,
+ LastOwnerError,
+ MembershipPermissionError,
+ WorkspaceCollaborationService,
+)
+from langbot.pkg.workspace.errors import WorkspaceNotFoundError
+from langbot.pkg.workspace.service import WorkspaceService
+from langbot.pkg.workspace.policy import CloudWorkspacePolicy
+
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+async def collaboration_context(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-collaboration.db"}')
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ persistence_mgr = SimpleNamespace(get_db_engine=lambda: engine)
+ application = SimpleNamespace(persistence_mgr=persistence_mgr)
+ workspace_service = WorkspaceService(application, instance_uuid='instance-collaboration-test')
+ service = WorkspaceCollaborationService(application, workspace_service)
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
+
+ async with session_factory() as session:
+ async with session.begin():
+ owner = User(
+ uuid=str(uuid.uuid4()),
+ user='owner@example.com',
+ normalized_email='owner@example.com',
+ password='owner-hash',
+ account_type='local',
+ )
+ session.add(owner)
+ await session.flush()
+ workspace, owner_membership = await workspace_service.bootstrap_local_account(
+ owner.uuid,
+ session=session,
+ )
+
+ yield service, workspace_service, session_factory, owner, workspace, owner_membership
+ await engine.dispose()
+
+
+async def _add_account(session_factory, email: str) -> User:
+ async with session_factory() as session:
+ async with session.begin():
+ account = User(
+ uuid=str(uuid.uuid4()),
+ user=email,
+ normalized_email=email.strip().casefold(),
+ password='member-hash',
+ account_type='local',
+ )
+ session.add(account)
+ await session.flush()
+ return account
+
+
+async def test_invitation_secret_is_hashed_and_acceptance_is_one_time(collaboration_context):
+ service, _, session_factory, _, workspace, owner_membership = collaboration_context
+ created = await service.create_invitation(
+ workspace.uuid,
+ owner_membership,
+ 'member@example.com',
+ 'developer',
+ )
+
+ assert created.token.startswith('lbi_')
+ assert created.invitation.token_hash != created.token
+ assert created.token not in created.invitation.token_hash
+
+ account = await _add_account(session_factory, 'MEMBER@example.com')
+ membership = await service.accept_invitation(created.token, account.uuid)
+ assert membership.workspace_uuid == workspace.uuid
+ assert membership.role == 'developer'
+
+ with pytest.raises(InvitationUsedError):
+ await service.accept_invitation(created.token, account.uuid)
+
+ async with session_factory() as session:
+ persisted = await session.get(WorkspaceInvitation, created.invitation.uuid)
+ assert persisted is not None
+ assert persisted.status == 'accepted'
+ assert not hasattr(persisted, 'token')
+
+
+async def test_concurrent_invitation_acceptance_creates_one_membership(collaboration_context):
+ service, _, session_factory, _, workspace, owner_membership = collaboration_context
+ created = await service.create_invitation(
+ workspace.uuid,
+ owner_membership,
+ 'race@example.com',
+ 'viewer',
+ )
+ account = await _add_account(session_factory, 'race@example.com')
+
+ results = await asyncio.gather(
+ service.accept_invitation(created.token, account.uuid),
+ service.accept_invitation(created.token, account.uuid),
+ return_exceptions=True,
+ )
+
+ assert sum(isinstance(result, WorkspaceMembership) for result in results) == 1
+ assert sum(isinstance(result, InvitationUsedError) for result in results) == 1
+ async with session_factory() as session:
+ count = await session.scalar(
+ sqlalchemy.select(sqlalchemy.func.count())
+ .select_from(WorkspaceMembership)
+ .where(
+ WorkspaceMembership.workspace_uuid == workspace.uuid,
+ WorkspaceMembership.account_uuid == account.uuid,
+ )
+ )
+ assert count == 1
+
+
+async def test_invitation_rejects_owner_role_and_email_mismatch(collaboration_context):
+ service, _, session_factory, _, workspace, owner_membership = collaboration_context
+ with pytest.raises(InvitationRoleError):
+ await service.create_invitation(
+ workspace.uuid,
+ owner_membership,
+ 'member@example.com',
+ 'owner',
+ )
+
+ created = await service.create_invitation(
+ workspace.uuid,
+ owner_membership,
+ 'expected@example.com',
+ 'viewer',
+ )
+ wrong_account = await _add_account(session_factory, 'wrong@example.com')
+ with pytest.raises(InvitationEmailMismatchError):
+ await service.accept_invitation(created.token, wrong_account.uuid)
+
+
+async def test_expired_invitation_is_inspectable_before_periodic_cleanup_deletes_it(collaboration_context):
+ service, _, session_factory, _, workspace, owner_membership = collaboration_context
+ created = await service.create_invitation(workspace.uuid, owner_membership, 'expired@example.com', 'viewer')
+ async with session_factory.begin() as session:
+ invitation = await session.get(WorkspaceInvitation, created.invitation.uuid)
+ invitation.expires_at = service._utcnow() - datetime.timedelta(minutes=1)
+
+ with pytest.raises(InvitationExpiredError):
+ await service.inspect_invitation(created.token)
+
+ assert await service.cleanup_expired_invitations(retention=datetime.timedelta(0)) == 1
+ async with session_factory() as session:
+ assert await session.get(WorkspaceInvitation, created.invitation.uuid) is None
+
+
+async def test_last_owner_cannot_be_demoted(collaboration_context):
+ service, _, session_factory, _, workspace, owner_membership = collaboration_context
+ created = await service.create_invitation(
+ workspace.uuid,
+ owner_membership,
+ 'second@example.com',
+ 'admin',
+ )
+ second = await _add_account(session_factory, 'second@example.com')
+ second_membership = await service.accept_invitation(created.token, second.uuid)
+
+ with pytest.raises(LastOwnerError):
+ await service.update_member_role(
+ workspace.uuid,
+ owner_membership.account_uuid,
+ 'admin',
+ owner_membership,
+ )
+
+ with pytest.raises(MembershipPermissionError):
+ await service.update_member_role(
+ workspace.uuid,
+ owner_membership.account_uuid,
+ 'viewer',
+ second_membership,
+ )
+
+ promoted = await service.update_member_role(
+ workspace.uuid,
+ second.uuid,
+ 'owner',
+ owner_membership,
+ )
+ assert promoted.role == 'owner'
+ demoted = await service.update_member_role(
+ workspace.uuid,
+ owner_membership.account_uuid,
+ 'admin',
+ owner_membership,
+ )
+ assert demoted.role == 'admin'
+
+
+async def test_workspace_selector_requires_membership(collaboration_context):
+ service, _, session_factory, _, workspace, _ = collaboration_context
+ outsider = await _add_account(session_factory, 'outsider@example.com')
+
+ with pytest.raises(WorkspaceNotFoundError):
+ await service.resolve_account_workspace(outsider.uuid, workspace.uuid)
+
+
+async def test_cloud_member_listing_opens_workspace_uow_when_request_scope_has_closed(
+ collaboration_context,
+):
+ service, _, session_factory, _, workspace, owner_membership = collaboration_context
+ entered_workspaces: list[str] = []
+
+ @asynccontextmanager
+ async def tenant_uow(workspace_uuid: str):
+ entered_workspaces.append(workspace_uuid)
+ async with session_factory.begin() as session:
+ yield SimpleNamespace(session=session)
+
+ service.ap.persistence_mgr = SimpleNamespace(
+ mode=SimpleNamespace(value='cloud_runtime'),
+ current_session=lambda: None,
+ tenant_uow=tenant_uow,
+ require_current_session=lambda: (_ for _ in ()).throw(AssertionError('list_members must open a Workspace UoW')),
+ get_db_engine=lambda: session_factory.kw['bind'],
+ )
+
+ members = await service.list_members(workspace.uuid, owner_membership)
+
+ assert entered_workspaces == [workspace.uuid]
+ assert [(member.email, member.membership.role) for member in members] == [('owner@example.com', 'owner')]
+
+
+async def test_cloud_directory_requires_explicit_selector_and_allows_core_membership_management(
+ collaboration_context,
+):
+ _service, workspace_service, session_factory, owner, _workspace, _membership = collaboration_context
+ cloud_workspace_uuid = str(uuid.uuid4())
+ cloud_membership = WorkspaceMembership(
+ uuid=str(uuid.uuid4()),
+ workspace_uuid=cloud_workspace_uuid,
+ account_uuid=owner.uuid,
+ role='owner',
+ status='active',
+ projection_revision=4,
+ )
+ async with session_factory.begin() as session:
+ session.add(
+ Workspace(
+ uuid=cloud_workspace_uuid,
+ instance_uuid='instance-collaboration-test',
+ name='Projected Workspace',
+ slug='projected-workspace',
+ source='cloud_projection',
+ projection_revision=4,
+ )
+ )
+ session.add(
+ WorkspaceExecutionState(
+ workspace_uuid=cloud_workspace_uuid,
+ instance_uuid='instance-collaboration-test',
+ active_generation=8,
+ state='active',
+ write_fenced=False,
+ source='cloud',
+ desired_state_revision=8,
+ )
+ )
+ session.add(cloud_membership)
+
+ cloud_service = WorkspaceCollaborationService(
+ workspace_service.ap,
+ WorkspaceService(
+ workspace_service.ap,
+ policy=CloudWorkspacePolicy(),
+ instance_uuid='instance-collaboration-test',
+ ),
+ )
+ with pytest.raises(WorkspaceNotFoundError):
+ await cloud_service.resolve_account_workspace(owner.uuid, None)
+
+ access = await cloud_service.resolve_account_workspace(owner.uuid, cloud_workspace_uuid)
+ assert access.workspace.uuid == cloud_workspace_uuid
+ assert access.execution.placement_generation == 8
+
+ created = await cloud_service.create_invitation(
+ cloud_workspace_uuid,
+ cloud_membership,
+ 'member@example.com',
+ 'viewer',
+ )
+ assert created.invitation.workspace_uuid == cloud_workspace_uuid
+ assert created.token.startswith('lbi_')
+
+ member = await _add_account(session_factory, 'member@example.com')
+ membership = await cloud_service.accept_invitation(created.token, member.uuid)
+ assert membership.workspace_uuid == cloud_workspace_uuid
+ assert membership.role == 'viewer'
+
+ updated = await cloud_service.update_member_role(
+ cloud_workspace_uuid,
+ member.uuid,
+ 'developer',
+ cloud_membership,
+ )
+ assert updated.role == 'developer'
+
+ removed = await cloud_service.remove_member(
+ cloud_workspace_uuid,
+ member.uuid,
+ cloud_membership,
+ )
+ assert removed.status == 'removed'
+
+
+async def test_invitation_lock_registry_does_not_retain_sequential_tokens(collaboration_context):
+ service, *_ = collaboration_context
+
+ for index in range(100):
+ async with service._invitation_lock(f'token-{index}'):
+ pass
+
+ assert service._invitation_locks == {}
+
+
+async def test_invitation_lock_registry_keeps_waiters_serialized(collaboration_context):
+ service, *_ = collaboration_context
+ first_entered = asyncio.Event()
+ release_first = asyncio.Event()
+ order: list[str] = []
+
+ async def first_worker() -> None:
+ async with service._invitation_lock('same-token'):
+ order.append('first')
+ first_entered.set()
+ await release_first.wait()
+
+ async def second_worker() -> None:
+ await first_entered.wait()
+ async with service._invitation_lock('same-token'):
+ order.append('second')
+
+ first_task = asyncio.create_task(first_worker())
+ second_task = asyncio.create_task(second_worker())
+ await first_entered.wait()
+ await asyncio.sleep(0)
+
+ assert order == ['first']
+ release_first.set()
+ await asyncio.gather(first_task, second_task)
+
+ assert order == ['first', 'second']
+ assert service._invitation_locks == {}
diff --git a/tests/unit_tests/workspace/test_workspace_policy.py b/tests/unit_tests/workspace/test_workspace_policy.py
new file mode 100644
index 000000000..d2f747467
--- /dev/null
+++ b/tests/unit_tests/workspace/test_workspace_policy.py
@@ -0,0 +1,19 @@
+from langbot.pkg.workspace.policy import (
+ CloudWorkspacePolicy,
+ SingleWorkspacePolicy,
+ open_core_workspace_policy,
+)
+
+
+def test_open_source_policy_is_single_workspace() -> None:
+ policy = open_core_workspace_policy()
+
+ assert policy.workspace_limit == 1
+ assert policy.multi_workspace_enabled is False
+
+
+def test_cloud_policy_is_not_exported_as_the_default_policy() -> None:
+ # CloudWorkspacePolicy remains a contract for the future verified, closed
+ # bootstrap. Constructing it explicitly must not change the OSS default.
+ assert CloudWorkspacePolicy().multi_workspace_enabled is True
+ assert isinstance(open_core_workspace_policy(), SingleWorkspacePolicy)
diff --git a/tests/unit_tests/workspace/test_workspace_service.py b/tests/unit_tests/workspace/test_workspace_service.py
new file mode 100644
index 000000000..8657998a3
--- /dev/null
+++ b/tests/unit_tests/workspace/test_workspace_service.py
@@ -0,0 +1,264 @@
+from __future__ import annotations
+
+import uuid
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+import sqlalchemy
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from langbot.pkg.entity.persistence.base import Base
+from langbot.pkg.entity.persistence.user import User
+from langbot.pkg.entity.persistence.workspace import (
+ Workspace,
+ WorkspaceExecutionSource,
+ WorkspaceExecutionState,
+ WorkspaceMembership,
+ WorkspaceSource,
+)
+from langbot.pkg.workspace import (
+ WorkspaceExecutionUnavailableError,
+ WorkspaceExecutionBinding,
+ WorkspaceGenerationMismatchError,
+ WorkspaceInvariantError,
+ WorkspaceLimitExceededError,
+ WorkspaceOwnerAlreadyExistsError,
+ WorkspaceService,
+)
+from langbot.pkg.workspace.policy import CloudWorkspacePolicy
+
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+async def workspace_test_context(tmp_path):
+ engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-service.db"}')
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ persistence_mgr = SimpleNamespace(get_db_engine=lambda: engine)
+ application = SimpleNamespace(persistence_mgr=persistence_mgr)
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
+ service = WorkspaceService(application, instance_uuid='instance_service_test')
+ yield service, session_factory
+ await engine.dispose()
+
+
+async def _insert_account(session, email: str) -> str:
+ account_uuid = str(uuid.uuid4())
+ session.add(
+ User(
+ uuid=account_uuid,
+ user=email,
+ normalized_email=email.strip().casefold(),
+ password='hashed-password',
+ account_type='local',
+ )
+ )
+ await session.flush()
+ return account_uuid
+
+
+async def test_account_uuid_default_supports_existing_core_insert_path(workspace_test_context):
+ _service, session_factory = workspace_test_context
+
+ async with session_factory.begin() as session:
+ await session.execute(
+ sqlalchemy.insert(User).values(
+ user='core-insert@example.com',
+ normalized_email='core-insert@example.com',
+ password='hashed-password',
+ account_type='local',
+ )
+ )
+
+ async with session_factory() as session:
+ account = await session.scalar(sqlalchemy.select(User))
+ assert account is not None
+ uuid.UUID(account.uuid)
+ assert account.status == 'active'
+ assert account.source == 'local'
+
+
+async def test_bootstrap_local_account_uses_callers_transaction(workspace_test_context):
+ service, session_factory = workspace_test_context
+
+ async with session_factory() as session:
+ async with session.begin():
+ account_uuid = await _insert_account(session, 'owner@example.com')
+ workspace, membership = await service.bootstrap_local_account(account_uuid, session=session)
+ assert session.in_transaction()
+ assert workspace.created_by_account_uuid == account_uuid
+ assert membership.account_uuid == account_uuid
+ assert membership.role == 'owner'
+
+ async with session_factory() as session:
+ persisted_workspace = await session.scalar(sqlalchemy.select(Workspace))
+ persisted_membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
+ execution_state = await session.scalar(sqlalchemy.select(WorkspaceExecutionState))
+ assert persisted_workspace is not None
+ assert persisted_membership is not None
+ assert execution_state is not None
+ assert execution_state.workspace_uuid == persisted_workspace.uuid
+ assert execution_state.instance_uuid == 'instance_service_test'
+ assert execution_state.active_generation == 1
+ assert execution_state.write_fenced is False
+
+
+async def test_ensure_singleton_workspace_is_idempotent(workspace_test_context):
+ service, session_factory = workspace_test_context
+
+ first = await service.ensure_singleton_workspace()
+ second = await service.ensure_singleton_workspace()
+
+ assert second.uuid == first.uuid
+ async with session_factory() as session:
+ assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1
+ assert (
+ await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(WorkspaceExecutionState)) == 1
+ )
+
+
+async def test_create_local_workspace_rejects_second_workspace(workspace_test_context):
+ service, session_factory = workspace_test_context
+
+ await service.create_local_workspace(name='First', slug='first')
+ with pytest.raises(WorkspaceLimitExceededError) as exc_info:
+ await service.create_local_workspace(name='Second', slug='second')
+
+ assert exc_info.value.code == 'edition_limit'
+ async with session_factory() as session:
+ assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1
+
+
+async def test_initial_owner_cannot_be_claimed_by_another_account(workspace_test_context):
+ service, session_factory = workspace_test_context
+
+ async with session_factory() as session:
+ async with session.begin():
+ first_account_uuid = await _insert_account(session, 'first@example.com')
+ second_account_uuid = await _insert_account(session, 'second@example.com')
+ await service.bootstrap_local_account(first_account_uuid, session=session)
+
+ with pytest.raises(WorkspaceOwnerAlreadyExistsError):
+ await service.claim_initial_owner(second_account_uuid)
+
+ async with session_factory() as session:
+ owners = (
+ await session.scalars(sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.role == 'owner'))
+ ).all()
+ assert len(owners) == 1
+ assert owners[0].account_uuid == first_account_uuid
+
+
+async def test_execution_binding_returns_persisted_generation(workspace_test_context):
+ service, session_factory = workspace_test_context
+ workspace = await service.ensure_singleton_workspace()
+
+ async with session_factory.begin() as session:
+ execution_state = await session.get(WorkspaceExecutionState, workspace.uuid)
+ execution_state.active_generation = 7
+
+ binding = await service.get_local_execution_binding(
+ workspace.uuid,
+ expected_generation=7,
+ )
+
+ assert binding.instance_uuid == 'instance_service_test'
+ assert binding.workspace_uuid == workspace.uuid
+ assert binding.placement_generation == 7
+ assert binding.state == 'active'
+ assert binding.write_fenced is False
+
+ with pytest.raises(WorkspaceGenerationMismatchError):
+ await service.get_local_execution_binding(workspace.uuid, expected_generation=6)
+
+
+async def test_execution_binding_fails_closed_when_fenced(workspace_test_context):
+ service, session_factory = workspace_test_context
+ workspace = await service.ensure_singleton_workspace()
+
+ async with session_factory.begin() as session:
+ execution_state = await session.get(WorkspaceExecutionState, workspace.uuid)
+ execution_state.write_fenced = True
+
+ with pytest.raises(WorkspaceExecutionUnavailableError):
+ await service.get_local_execution_context(workspace.uuid)
+
+
+async def test_cloud_projection_requires_explicit_general_binding(workspace_test_context):
+ service, session_factory = workspace_test_context
+ await service.ensure_singleton_workspace()
+ cloud_workspace_uuid = str(uuid.uuid4())
+
+ async with session_factory.begin() as session:
+ session.add(
+ Workspace(
+ uuid=cloud_workspace_uuid,
+ instance_uuid='instance_service_test',
+ name='Cloud Workspace',
+ slug='cloud-workspace',
+ source=WorkspaceSource.CLOUD_PROJECTION.value,
+ )
+ )
+ session.add(
+ WorkspaceExecutionState(
+ workspace_uuid=cloud_workspace_uuid,
+ instance_uuid='instance_service_test',
+ active_generation=9,
+ state='active',
+ write_fenced=False,
+ source=WorkspaceExecutionSource.CLOUD.value,
+ )
+ )
+
+ binding = await service.get_execution_binding(
+ cloud_workspace_uuid,
+ expected_generation=9,
+ )
+ assert binding.workspace_uuid == cloud_workspace_uuid
+ assert binding.placement_generation == 9
+
+ with pytest.raises(WorkspaceInvariantError):
+ await service.get_local_execution_binding(cloud_workspace_uuid)
+
+
+async def test_cloud_policy_never_creates_or_guesses_a_workspace(workspace_test_context):
+ service, _session_factory = workspace_test_context
+ cloud_service = WorkspaceService(
+ service.ap,
+ policy=CloudWorkspacePolicy(),
+ instance_uuid='instance_service_test',
+ )
+
+ with pytest.raises(WorkspaceLimitExceededError):
+ await cloud_service.create_local_workspace(name='Forbidden', slug='forbidden')
+
+ assert cloud_service.policy.multi_workspace_enabled is True
+
+
+async def test_startup_binding_snapshot_avoids_repeated_discovery():
+ service = WorkspaceService(
+ SimpleNamespace(persistence_mgr=SimpleNamespace()),
+ policy=CloudWorkspacePolicy(),
+ instance_uuid='instance-service-test',
+ )
+ binding = WorkspaceExecutionBinding(
+ instance_uuid='instance-service-test',
+ workspace_uuid='workspace-a',
+ placement_generation=1,
+ write_fenced=False,
+ state='active',
+ )
+ service._discover_active_execution_bindings = AsyncMock(return_value=[binding])
+
+ assert await service.prime_startup_execution_bindings() == [binding]
+ assert await service.list_active_execution_bindings() == [binding]
+ assert await service.list_active_execution_bindings() == [binding]
+ service._discover_active_execution_bindings.assert_awaited_once()
+
+ service.release_startup_execution_bindings()
+ assert await service.list_active_execution_bindings() == [binding]
+ assert service._discover_active_execution_bindings.await_count == 2
diff --git a/tests/utils/import_isolation.py b/tests/utils/import_isolation.py
index 9f2b3c583..d9d2b3dc5 100644
--- a/tests/utils/import_isolation.py
+++ b/tests/utils/import_isolation.py
@@ -24,6 +24,9 @@ from typing import Generator
from unittest.mock import MagicMock
+_MISSING = object()
+
+
class MockLifecycleControlScope(enum.Enum):
"""Mock enum for breaking circular import in core.entities."""
@@ -74,19 +77,29 @@ def isolated_sys_modules(
if name in sys.modules:
saved[name] = sys.modules[name]
- # Save original package attributes that will be updated
- saved_attrs: dict[str, tuple[str, object]] = {}
- for mock_name, (pkg_name, attr_name) in _PACKAGE_ATTRIBUTE_UPDATES.items():
- if mock_name in mocks and pkg_name in sys.modules:
- pkg = sys.modules[pkg_name]
- if hasattr(pkg, attr_name):
- saved_attrs[mock_name] = (pkg_name, getattr(pkg, attr_name))
+ # Importing a submodule also mutates its parent package attribute. Preserve
+ # that state for every mocked or cleared module, otherwise restoring only
+ # sys.modules leaves stale class/enum identities attached to the package.
+ saved_attrs: dict[str, tuple[str, str, object]] = {}
+ for name in touched:
+ pkg_name, separator, attr_name = name.rpartition('.')
+ if separator and pkg_name in sys.modules:
+ saved_attrs[name] = (
+ pkg_name,
+ attr_name,
+ getattr(sys.modules[pkg_name], attr_name, _MISSING),
+ )
try:
# Clear modules first (force re-import)
for name in clear:
if name not in mocks: # Don't clear if we're mocking it
sys.modules.pop(name, None)
+ saved_attr = saved_attrs.get(name)
+ if saved_attr is not None:
+ pkg_name, attr_name, _ = saved_attr
+ if pkg_name in sys.modules and hasattr(sys.modules[pkg_name], attr_name):
+ delattr(sys.modules[pkg_name], attr_name)
# Apply mocks
for name, module in mocks.items():
@@ -110,10 +123,16 @@ def isolated_sys_modules(
# Wasn't in sys.modules originally, remove it
sys.modules.pop(name, None)
- # Restore package attributes
- for mock_name, (pkg_name, original_value) in saved_attrs.items():
- if pkg_name in sys.modules:
- setattr(sys.modules[pkg_name], _PACKAGE_ATTRIBUTE_UPDATES[mock_name][1], original_value)
+ # Restore package attributes (or remove ones that did not previously
+ # exist), keeping package lookup consistent with restored sys.modules.
+ for pkg_name, attr_name, original_value in saved_attrs.values():
+ if pkg_name not in sys.modules:
+ continue
+ if original_value is _MISSING:
+ if hasattr(sys.modules[pkg_name], attr_name):
+ delattr(sys.modules[pkg_name], attr_name)
+ else:
+ setattr(sys.modules[pkg_name], attr_name, original_value)
def make_pipeline_handler_import_mocks() -> dict[str, MagicMock]:
diff --git a/uv.lock b/uv.lock
index cc71d0888..a23277e25 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,4 @@
version = 1
-revision = 3
requires-python = ">=3.11, <4.0"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -20,9 +19,9 @@ resolution-markers = [
name = "aenum"
version = "3.1.16"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/09/7a/61ed58e8be9e30c3fe518899cc78c284896d246d51381bab59b5db11e1f3/aenum-3.1.16.tar.gz", hash = "sha256:bfaf9589bdb418ee3a986d85750c7318d9d2839c1b1a1d6fe8fc53ec201cf140", size = 137693, upload-time = "2026-01-12T22:34:38.819Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/7a/61ed58e8be9e30c3fe518899cc78c284896d246d51381bab59b5db11e1f3/aenum-3.1.16.tar.gz", hash = "sha256:bfaf9589bdb418ee3a986d85750c7318d9d2839c1b1a1d6fe8fc53ec201cf140", size = 137693 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e3/52/6ad8f63ec8da1bf40f96996d25d5b650fdd38f5975f8c813732c47388f18/aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf", size = 165627, upload-time = "2025-04-25T03:17:58.89Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/52/6ad8f63ec8da1bf40f96996d25d5b650fdd38f5975f8c813732c47388f18/aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf", size = 165627 },
]
[[package]]
@@ -33,24 +32,24 @@ dependencies = [
{ name = "httpx" },
{ name = "quart" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c2/91/e4bf8584695b065e223aa42ed4462fb67532a9f936d45df5d982eb3320b6/aiocqhttp-1.4.4.tar.gz", hash = "sha256:eb2b6996753cacee45bf615aba5db4625b495e7a184a2fd27d2e5408f472c03d", size = 22989, upload-time = "2023-06-11T05:20:08.649Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/91/e4bf8584695b065e223aa42ed4462fb67532a9f936d45df5d982eb3320b6/aiocqhttp-1.4.4.tar.gz", hash = "sha256:eb2b6996753cacee45bf615aba5db4625b495e7a184a2fd27d2e5408f472c03d", size = 22989 }
[[package]]
name = "aiofiles"
version = "24.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 },
]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 },
]
[[package]]
@@ -67,108 +66,108 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
- { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
- { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
- { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
- { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
- { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
- { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
- { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
- { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
- { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
- { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
- { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
- { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
- { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
- { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
- { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
- { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
- { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
- { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
- { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
- { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
- { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
- { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
- { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
- { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
- { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
- { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
- { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
- { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
- { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
- { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
- { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
- { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
- { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
- { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
- { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
- { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" },
- { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" },
- { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" },
- { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" },
- { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" },
- { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" },
- { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" },
- { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" },
- { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" },
- { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" },
- { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" },
- { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" },
- { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" },
- { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" },
- { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" },
- { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" },
- { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" },
- { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" },
- { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" },
- { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" },
- { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" },
- { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" },
- { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" },
- { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" },
- { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" },
- { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" },
- { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" },
- { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" },
- { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" },
- { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" },
- { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" },
- { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" },
- { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" },
- { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" },
- { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" },
- { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" },
- { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" },
- { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" },
- { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" },
- { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" },
- { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" },
- { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" },
- { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" },
- { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" },
- { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" },
- { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" },
- { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" },
- { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" },
- { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" },
- { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" },
- { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" },
- { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" },
- { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" },
- { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" },
- { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" },
- { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" },
- { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" },
- { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" },
- { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" },
- { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" },
- { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" },
- { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" },
- { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225 },
+ { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743 },
+ { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139 },
+ { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088 },
+ { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835 },
+ { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801 },
+ { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992 },
+ { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989 },
+ { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129 },
+ { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576 },
+ { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668 },
+ { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019 },
+ { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638 },
+ { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660 },
+ { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698 },
+ { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386 },
+ { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406 },
+ { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987 },
+ { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402 },
+ { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310 },
+ { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448 },
+ { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854 },
+ { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884 },
+ { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034 },
+ { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054 },
+ { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278 },
+ { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795 },
+ { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397 },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504 },
+ { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806 },
+ { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707 },
+ { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121 },
+ { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580 },
+ { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771 },
+ { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873 },
+ { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073 },
+ { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882 },
+ { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270 },
+ { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841 },
+ { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088 },
+ { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564 },
+ { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998 },
+ { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918 },
+ { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657 },
+ { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907 },
+ { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565 },
+ { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018 },
+ { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416 },
+ { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881 },
+ { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572 },
+ { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137 },
+ { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953 },
+ { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479 },
+ { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077 },
+ { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688 },
+ { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094 },
+ { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662 },
+ { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748 },
+ { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723 },
+ { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531 },
+ { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718 },
+ { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918 },
+ { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014 },
+ { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398 },
+ { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018 },
+ { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462 },
+ { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824 },
+ { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898 },
+ { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114 },
+ { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541 },
+ { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776 },
+ { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329 },
+ { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293 },
+ { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756 },
+ { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052 },
+ { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888 },
+ { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679 },
+ { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021 },
+ { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574 },
+ { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773 },
+ { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001 },
+ { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809 },
+ { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320 },
+ { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077 },
+ { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476 },
+ { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347 },
+ { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465 },
+ { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423 },
+ { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906 },
+ { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095 },
+ { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222 },
+ { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922 },
+ { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035 },
+ { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512 },
+ { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571 },
+ { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159 },
+ { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409 },
+ { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166 },
+ { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255 },
+ { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640 },
]
[[package]]
@@ -179,18 +178,18 @@ dependencies = [
{ name = "aiohttp" },
{ name = "python-socks" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556 },
]
[[package]]
name = "aioshutil"
version = "1.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d3/bd/dcea5abb1792269e70cc75d5f9ae9adbdfba0f0d08a207eb788ec3b469b6/aioshutil-1.6.tar.gz", hash = "sha256:9eae342b9a4cacc2c2c5877877a2d2f7a2b66c62aa1ab57d7e95c8cfd4ede507", size = 7843, upload-time = "2025-10-21T08:42:23.742Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/bd/dcea5abb1792269e70cc75d5f9ae9adbdfba0f0d08a207eb788ec3b469b6/aioshutil-1.6.tar.gz", hash = "sha256:9eae342b9a4cacc2c2c5877877a2d2f7a2b66c62aa1ab57d7e95c8cfd4ede507", size = 7843 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/68/92/7020e67ad83095ecc2ce751c24a63df332fb9a34ebfe14bc12a6b21b8f58/aioshutil-1.6-py3-none-any.whl", hash = "sha256:e0711de25ade421b70094b2a27c69bef6356127013744fec05f019f36732c1bd", size = 4705, upload-time = "2025-10-21T08:42:22.892Z" },
+ { url = "https://files.pythonhosted.org/packages/68/92/7020e67ad83095ecc2ce751c24a63df332fb9a34ebfe14bc12a6b21b8f58/aioshutil-1.6-py3-none-any.whl", hash = "sha256:e0711de25ade421b70094b2a27c69bef6356127013744fec05f019f36732c1bd", size = 4705 },
]
[[package]]
@@ -201,18 +200,18 @@ dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 },
]
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
+ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405 },
]
[[package]]
@@ -224,18 +223,18 @@ dependencies = [
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893 },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
@@ -252,9 +251,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/85/6cb5da3cf91de2eeea89726316e8c5c8c31e2d61ee7cb1233d7e95512c31/anthropic-0.77.0.tar.gz", hash = "sha256:ce36efeb80cb1e25430a88440dc0f9aa5c87f10d080ab70a1bdfd5c2c5fbedb4", size = 504575, upload-time = "2026-01-29T18:20:41.507Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/85/6cb5da3cf91de2eeea89726316e8c5c8c31e2d61ee7cb1233d7e95512c31/anthropic-0.77.0.tar.gz", hash = "sha256:ce36efeb80cb1e25430a88440dc0f9aa5c87f10d080ab70a1bdfd5c2c5fbedb4", size = 504575 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ac/27/9df785d3f94df9ac72f43ee9e14b8120b37d992b18f4952774ed46145022/anthropic-0.77.0-py3-none-any.whl", hash = "sha256:65cc83a3c82ce622d5c677d0d7706c77d29dc83958c6b10286e12fda6ffb2651", size = 397867, upload-time = "2026-01-29T18:20:39.481Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/27/9df785d3f94df9ac72f43ee9e14b8120b37d992b18f4952774ed46145022/anthropic-0.77.0-py3-none-any.whl", hash = "sha256:65cc83a3c82ce622d5c677d0d7706c77d29dc83958c6b10286e12fda6ffb2651", size = 397867 },
]
[[package]]
@@ -265,9 +264,9 @@ dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 },
]
[[package]]
@@ -277,9 +276,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439 },
]
[[package]]
@@ -289,9 +288,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657 },
]
[[package]]
@@ -301,229 +300,229 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
- { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
- { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
- { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
- { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
- { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
- { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
- { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
- { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
- { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
- { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
- { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
- { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
- { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
- { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
- { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
- { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
- { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
- { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
- { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
+ { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393 },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328 },
+ { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269 },
+ { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558 },
+ { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364 },
+ { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637 },
+ { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934 },
+ { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158 },
+ { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597 },
+ { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231 },
+ { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121 },
+ { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177 },
+ { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090 },
+ { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246 },
+ { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126 },
+ { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343 },
+ { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777 },
+ { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180 },
+ { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715 },
+ { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149 },
]
[[package]]
name = "async-lru"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ef/c3/bbf34f15ea88dfb649ab2c40f9d75081784a50573a9ea431563cab64adb8/async_lru-2.1.0.tar.gz", hash = "sha256:9eeb2fecd3fe42cc8a787fc32ead53a3a7158cc43d039c3c55ab3e4e5b2a80ed", size = 12041, upload-time = "2026-01-17T22:52:18.931Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/c3/bbf34f15ea88dfb649ab2c40f9d75081784a50573a9ea431563cab64adb8/async_lru-2.1.0.tar.gz", hash = "sha256:9eeb2fecd3fe42cc8a787fc32ead53a3a7158cc43d039c3c55ab3e4e5b2a80ed", size = 12041 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2e/e9/eb6a5db5ac505d5d45715388e92bced7a5bb556facc4d0865d192823f2d2/async_lru-2.1.0-py3-none-any.whl", hash = "sha256:fa12dcf99a42ac1280bc16c634bbaf06883809790f6304d85cdab3f666f33a7e", size = 6933, upload-time = "2026-01-17T22:52:17.389Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e9/eb6a5db5ac505d5d45715388e92bced7a5bb556facc4d0865d192823f2d2/async_lru-2.1.0-py3-none-any.whl", hash = "sha256:fa12dcf99a42ac1280bc16c634bbaf06883809790f6304d85cdab3f666f33a7e", size = 6933 },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
- { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
- { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
- { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
- { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
- { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
- { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
- { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
- { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
- { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
- { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
- { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
- { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
- { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
- { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
- { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
- { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
- { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
- { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
- { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
- { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
- { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
- { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
- { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
- { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
- { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
- { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
- { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
- { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
- { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
- { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
- { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
- { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
- { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
- { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
- { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
- { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
- { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
- { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
- { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159 },
+ { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157 },
+ { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051 },
+ { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640 },
+ { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050 },
+ { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574 },
+ { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076 },
+ { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980 },
+ { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042 },
+ { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504 },
+ { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241 },
+ { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321 },
+ { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685 },
+ { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858 },
+ { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852 },
+ { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175 },
+ { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111 },
+ { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928 },
+ { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067 },
+ { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156 },
+ { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636 },
+ { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079 },
+ { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606 },
+ { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569 },
+ { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867 },
+ { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349 },
+ { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428 },
+ { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678 },
+ { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505 },
+ { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744 },
+ { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251 },
+ { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901 },
+ { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280 },
+ { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931 },
+ { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608 },
+ { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738 },
+ { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026 },
+ { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426 },
+ { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495 },
+ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062 },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 },
]
[[package]]
name = "audioop-lts"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" },
- { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" },
- { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" },
- { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" },
- { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" },
- { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" },
- { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" },
- { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" },
- { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" },
- { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" },
- { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" },
- { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" },
- { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" },
- { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" },
- { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" },
- { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" },
- { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" },
- { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" },
- { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" },
- { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" },
- { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" },
- { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" },
- { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" },
- { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" },
- { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" },
- { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" },
- { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" },
- { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" },
- { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" },
- { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" },
- { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" },
- { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" },
- { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" },
- { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" },
- { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" },
- { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" },
- { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" },
- { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" },
- { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" },
- { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" },
- { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" },
- { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" },
- { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" },
- { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" },
- { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" },
- { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" },
- { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" },
- { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" },
+ { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523 },
+ { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455 },
+ { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997 },
+ { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844 },
+ { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056 },
+ { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892 },
+ { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660 },
+ { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143 },
+ { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313 },
+ { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044 },
+ { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766 },
+ { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640 },
+ { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052 },
+ { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185 },
+ { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503 },
+ { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173 },
+ { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096 },
+ { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748 },
+ { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329 },
+ { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407 },
+ { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811 },
+ { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470 },
+ { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878 },
+ { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867 },
+ { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001 },
+ { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046 },
+ { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788 },
+ { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472 },
+ { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279 },
+ { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568 },
+ { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942 },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603 },
+ { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104 },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754 },
+ { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332 },
+ { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396 },
+ { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811 },
+ { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483 },
+ { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885 },
+ { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899 },
+ { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998 },
+ { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046 },
+ { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843 },
+ { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490 },
+ { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297 },
+ { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331 },
+ { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697 },
+ { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206 },
]
[[package]]
name = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
+ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
- { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
- { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
- { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
- { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
- { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
- { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
- { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
- { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
- { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
- { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
- { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
- { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
- { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
- { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
- { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
- { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
- { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
- { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
- { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
- { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
- { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
- { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
- { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
- { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
- { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
- { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
- { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
- { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
- { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
- { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
- { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
- { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
- { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
- { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
- { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
- { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
- { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
- { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
- { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
- { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
- { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
- { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
- { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
- { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
- { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
- { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
- { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
- { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
- { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
- { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
- { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
- { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
- { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
- { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
- { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
- { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
- { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
- { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
- { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
- { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
- { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
+ { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806 },
+ { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626 },
+ { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853 },
+ { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793 },
+ { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930 },
+ { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194 },
+ { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381 },
+ { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750 },
+ { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757 },
+ { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740 },
+ { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197 },
+ { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974 },
+ { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498 },
+ { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853 },
+ { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626 },
+ { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862 },
+ { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544 },
+ { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787 },
+ { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753 },
+ { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587 },
+ { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178 },
+ { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295 },
+ { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700 },
+ { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034 },
+ { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766 },
+ { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449 },
+ { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310 },
+ { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761 },
+ { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553 },
+ { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009 },
+ { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029 },
+ { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907 },
+ { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500 },
+ { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412 },
+ { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486 },
+ { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940 },
+ { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776 },
+ { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922 },
+ { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367 },
+ { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187 },
+ { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752 },
+ { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881 },
+ { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931 },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313 },
+ { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290 },
+ { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253 },
+ { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084 },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185 },
+ { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656 },
+ { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662 },
+ { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240 },
+ { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152 },
+ { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284 },
+ { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643 },
+ { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698 },
+ { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725 },
+ { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912 },
+ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953 },
+ { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180 },
+ { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791 },
+ { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746 },
+ { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375 },
]
[[package]]
@@ -534,18 +533,18 @@ dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721 },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
]
[[package]]
@@ -557,9 +556,9 @@ dependencies = [
{ name = "jmespath" },
{ name = "s3transfer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b8/ea/b96c77da49fed28744ee0347374d8223994a2b8570e76e8380a4064a8c4a/boto3-1.42.39.tar.gz", hash = "sha256:d03f82363314759eff7f84a27b9e6428125f89d8119e4588e8c2c1d79892c956", size = 112783, upload-time = "2026-01-30T20:38:31.226Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b8/ea/b96c77da49fed28744ee0347374d8223994a2b8570e76e8380a4064a8c4a/boto3-1.42.39.tar.gz", hash = "sha256:d03f82363314759eff7f84a27b9e6428125f89d8119e4588e8c2c1d79892c956", size = 112783 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b2/c4/3493b5c86e32d6dd558b30d16b55503e24a6e6cd7115714bc102b247d26e/boto3-1.42.39-py3-none-any.whl", hash = "sha256:d9d6ce11df309707b490d2f5f785b761cfddfd6d1f665385b78c9d8ed097184b", size = 140606, upload-time = "2026-01-30T20:38:28.635Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/c4/3493b5c86e32d6dd558b30d16b55503e24a6e6cd7115714bc102b247d26e/boto3-1.42.39-py3-none-any.whl", hash = "sha256:d9d6ce11df309707b490d2f5f785b761cfddfd6d1f665385b78c9d8ed097184b", size = 140606 },
]
[[package]]
@@ -571,18 +570,18 @@ dependencies = [
{ name = "python-dateutil" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ac/a6/3a34d1b74effc0f759f5ff4e91c77729d932bc34dd3207905e9ecbba1103/botocore-1.42.39.tar.gz", hash = "sha256:0f00355050821e91a5fe6d932f7bf220f337249b752899e3e4cf6ed54326249e", size = 14914927, upload-time = "2026-01-30T20:38:19.265Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/a6/3a34d1b74effc0f759f5ff4e91c77729d932bc34dd3207905e9ecbba1103/botocore-1.42.39.tar.gz", hash = "sha256:0f00355050821e91a5fe6d932f7bf220f337249b752899e3e4cf6ed54326249e", size = 14914927 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/71/9a2c88abb5fe47b46168b262254d5b5d635de371eba4bd01ea5c8c109575/botocore-1.42.39-py3-none-any.whl", hash = "sha256:9e0d0fed9226449cc26fcf2bbffc0392ac698dd8378e8395ce54f3ec13f81d58", size = 14591958, upload-time = "2026-01-30T20:38:14.814Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/71/9a2c88abb5fe47b46168b262254d5b5d635de371eba4bd01ea5c8c109575/botocore-1.42.39-py3-none-any.whl", hash = "sha256:9e0d0fed9226449cc26fcf2bbffc0392ac698dd8378e8395ce54f3ec13f81d58", size = 14591958 },
]
[[package]]
name = "bracex"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508 },
]
[[package]]
@@ -594,27 +593,27 @@ dependencies = [
{ name = "packaging" },
{ name = "pyproject-hooks" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054, upload-time = "2026-01-08T16:41:47.696Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141 },
]
[[package]]
name = "cachetools"
version = "6.2.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
+ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668 },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900 },
]
[[package]]
@@ -624,158 +623,158 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
- { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
- { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
- { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
- { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
- { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
- { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
- { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
- { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
- { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
- { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
- { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
- { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
- { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
- { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
- { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
- { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
- { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
- { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
- { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
- { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
- { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
- { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
- { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
- { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
- { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
- { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
- { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
- { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
- { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
- { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
- { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
- { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
- { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
- { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
- { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
- { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
- { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
- { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
- { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
- { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
- { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
- { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
- { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
- { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
- { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
- { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
- { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
- { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
- { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
- { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
- { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
- { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
- { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
- { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
- { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
- { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 },
]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
+ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 },
]
[[package]]
name = "chardet"
version = "5.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
- { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
- { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
- { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
- { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
- { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
- { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
- { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
- { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
- { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
- { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
- { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
- { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
- { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
- { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
- { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
- { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
- { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
- { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
- { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
- { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
- { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
- { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
- { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
- { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
- { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
- { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
- { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
- { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
- { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
- { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
- { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
- { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
- { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
- { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
- { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
- { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
- { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
- { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
- { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
- { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
- { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
- { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
- { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
- { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
- { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
- { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
- { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
- { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
- { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
- { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
- { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
- { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
- { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
- { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
- { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
- { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
- { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
- { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
- { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
- { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
- { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
- { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 },
+ { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 },
+ { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 },
+ { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 },
+ { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 },
+ { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 },
+ { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 },
+ { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 },
+ { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 },
+ { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 },
+ { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 },
+ { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 },
+ { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 },
+ { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 },
+ { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 },
+ { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 },
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 },
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 },
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 },
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 },
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 },
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 },
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 },
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 },
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 },
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 },
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 },
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 },
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 },
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 },
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 },
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 },
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 },
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 },
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 },
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 },
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 },
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 },
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 },
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 },
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 },
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 },
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 },
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 },
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 },
]
[[package]]
@@ -811,13 +810,13 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uvicorn", extra = ["standard"] },
]
-sdist = { url = "https://files.pythonhosted.org/packages/03/35/24479ac00e74b86e388854a573a9ebe6d41c51c37e03d00864bb967d861f/chromadb-1.4.1.tar.gz", hash = "sha256:3cceb83e0a7a3c2db0752ebf62e9cfe652da657594c093fe07e74022581a58eb", size = 2226347, upload-time = "2026-01-14T19:18:15.189Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/35/24479ac00e74b86e388854a573a9ebe6d41c51c37e03d00864bb967d861f/chromadb-1.4.1.tar.gz", hash = "sha256:3cceb83e0a7a3c2db0752ebf62e9cfe652da657594c093fe07e74022581a58eb", size = 2226347 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/f0/7c815bb80a2aaa349757ed0c743fa7e85bbe16f612057b25cf1809456a32/chromadb-1.4.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05d98ffe4a9a5549c9a78eee7624277f9d99c53200a01f1176ecb1d31ea3c819", size = 20313209, upload-time = "2026-01-14T19:18:12.111Z" },
- { url = "https://files.pythonhosted.org/packages/a1/4b/c16236d56bf6bf144edbe5a03c431b59ba089bd6f86baefa8ebc288bf8b8/chromadb-1.4.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:38336431c01562cffdb3ef693f22f7a88df5304f942e01ed66ee0bbaf08f35da", size = 19634405, upload-time = "2026-01-14T19:18:08.264Z" },
- { url = "https://files.pythonhosted.org/packages/70/9c/33c6c3036e30632c2b64d333e92af3972e6bef423a8285e0edc5f487d322/chromadb-1.4.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaaf9c7d4ddbbdc74bd7cac45d9729032020cc6e65a2b8f313257e6c949beed", size = 20276410, upload-time = "2026-01-14T19:18:00.226Z" },
- { url = "https://files.pythonhosted.org/packages/29/bc/0c6a6255cd55fe384c1bda6bebb47b5ff9d5c535d993fd3451e4a3fbe42f/chromadb-1.4.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad50fbb5799dcaef5ae7613be583a06b44b637283db066396490863266f48623", size = 21082323, upload-time = "2026-01-14T19:18:04.604Z" },
- { url = "https://files.pythonhosted.org/packages/79/be/5092571f87ddf08022a3d9434d3374d3f5aa20ebad1c75d63107c0c046d6/chromadb-1.4.1-cp39-abi3-win_amd64.whl", hash = "sha256:cedc9941dad1081eb9be89a7f5f66374715d4f99f731f1eb9da900636c501330", size = 21376957, upload-time = "2026-01-14T19:18:16.95Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/f0/7c815bb80a2aaa349757ed0c743fa7e85bbe16f612057b25cf1809456a32/chromadb-1.4.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:05d98ffe4a9a5549c9a78eee7624277f9d99c53200a01f1176ecb1d31ea3c819", size = 20313209 },
+ { url = "https://files.pythonhosted.org/packages/a1/4b/c16236d56bf6bf144edbe5a03c431b59ba089bd6f86baefa8ebc288bf8b8/chromadb-1.4.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:38336431c01562cffdb3ef693f22f7a88df5304f942e01ed66ee0bbaf08f35da", size = 19634405 },
+ { url = "https://files.pythonhosted.org/packages/70/9c/33c6c3036e30632c2b64d333e92af3972e6bef423a8285e0edc5f487d322/chromadb-1.4.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaaf9c7d4ddbbdc74bd7cac45d9729032020cc6e65a2b8f313257e6c949beed", size = 20276410 },
+ { url = "https://files.pythonhosted.org/packages/29/bc/0c6a6255cd55fe384c1bda6bebb47b5ff9d5c535d993fd3451e4a3fbe42f/chromadb-1.4.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad50fbb5799dcaef5ae7613be583a06b44b637283db066396490863266f48623", size = 21082323 },
+ { url = "https://files.pythonhosted.org/packages/79/be/5092571f87ddf08022a3d9434d3374d3f5aa20ebad1c75d63107c0c046d6/chromadb-1.4.1-cp39-abi3-win_amd64.whl", hash = "sha256:cedc9941dad1081eb9be89a7f5f66374715d4f99f731f1eb9da900636c501330", size = 21376957 },
]
[[package]]
@@ -827,18 +826,18 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 },
]
[[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" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
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" },
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
@@ -848,9 +847,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "humanfriendly" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018 },
]
[[package]]
@@ -860,96 +859,96 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8e/8f/1537ebed273d43edd3bb21f1e5861549b7cfcb1d47523d7277cab988cec2/colorlog-6.6.0.tar.gz", hash = "sha256:344f73204009e4c83c5b6beb00b3c45dc70fcdae3c80db919e0a4171d006fde8", size = 30712, upload-time = "2021-11-08T16:56:44.532Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/8f/1537ebed273d43edd3bb21f1e5861549b7cfcb1d47523d7277cab988cec2/colorlog-6.6.0.tar.gz", hash = "sha256:344f73204009e4c83c5b6beb00b3c45dc70fcdae3c80db919e0a4171d006fde8", size = 30712 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7d/54/e24efe5469ecb2710112055de87a2900e9494810bcfc25c12c7a0723eb64/colorlog-6.6.0-py2.py3-none-any.whl", hash = "sha256:351c51e866c86c3217f08e4b067a7974a678be78f07f85fc2d55b8babde6d94e", size = 11230, upload-time = "2021-11-08T16:56:43.532Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/54/e24efe5469ecb2710112055de87a2900e9494810bcfc25c12c7a0723eb64/colorlog-6.6.0-py2.py3-none-any.whl", hash = "sha256:351c51e866c86c3217f08e4b067a7974a678be78f07f85fc2d55b8babde6d94e", size = 11230 },
]
[[package]]
name = "coverage"
version = "7.13.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971, upload-time = "2026-01-25T12:57:36.953Z" },
- { url = "https://files.pythonhosted.org/packages/eb/0e/b6489f344d99cd1e5b4d5e1be52dfd3f8a3dc5112aa6c33948da8cabad4e/coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", size = 219473, upload-time = "2026-01-25T12:57:38.934Z" },
- { url = "https://files.pythonhosted.org/packages/17/11/db2f414915a8e4ec53f60b17956c27f21fb68fcf20f8a455ce7c2ccec638/coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", size = 249896, upload-time = "2026-01-25T12:57:40.365Z" },
- { url = "https://files.pythonhosted.org/packages/80/06/0823fe93913663c017e508e8810c998c8ebd3ec2a5a85d2c3754297bdede/coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", size = 251810, upload-time = "2026-01-25T12:57:42.045Z" },
- { url = "https://files.pythonhosted.org/packages/61/dc/b151c3cc41b28cdf7f0166c5fa1271cbc305a8ec0124cce4b04f74791a18/coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", size = 253920, upload-time = "2026-01-25T12:57:44.026Z" },
- { url = "https://files.pythonhosted.org/packages/2d/35/e83de0556e54a4729a2b94ea816f74ce08732e81945024adee46851c2264/coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", size = 250025, upload-time = "2026-01-25T12:57:45.624Z" },
- { url = "https://files.pythonhosted.org/packages/39/67/af2eb9c3926ce3ea0d58a0d2516fcbdacf7a9fc9559fe63076beaf3f2596/coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", size = 251612, upload-time = "2026-01-25T12:57:47.713Z" },
- { url = "https://files.pythonhosted.org/packages/26/62/5be2e25f3d6c711d23b71296f8b44c978d4c8b4e5b26871abfc164297502/coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", size = 249670, upload-time = "2026-01-25T12:57:49.378Z" },
- { url = "https://files.pythonhosted.org/packages/b3/51/400d1b09a8344199f9b6a6fc1868005d766b7ea95e7882e494fa862ca69c/coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", size = 249395, upload-time = "2026-01-25T12:57:50.86Z" },
- { url = "https://files.pythonhosted.org/packages/e0/36/f02234bc6e5230e2f0a63fd125d0a2093c73ef20fdf681c7af62a140e4e7/coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", size = 250298, upload-time = "2026-01-25T12:57:52.287Z" },
- { url = "https://files.pythonhosted.org/packages/b0/06/713110d3dd3151b93611c9cbfc65c15b4156b44f927fced49ac0b20b32a4/coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", size = 221485, upload-time = "2026-01-25T12:57:53.876Z" },
- { url = "https://files.pythonhosted.org/packages/16/0c/3ae6255fa1ebcb7dec19c9a59e85ef5f34566d1265c70af5b2fc981da834/coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", size = 222421, upload-time = "2026-01-25T12:57:55.433Z" },
- { url = "https://files.pythonhosted.org/packages/b5/37/fabc3179af4d61d89ea47bd04333fec735cd5e8b59baad44fed9fc4170d7/coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", size = 221088, upload-time = "2026-01-25T12:57:57.41Z" },
- { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" },
- { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" },
- { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" },
- { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" },
- { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" },
- { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" },
- { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" },
- { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" },
- { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" },
- { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" },
- { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" },
- { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" },
- { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" },
- { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" },
- { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" },
- { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" },
- { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" },
- { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" },
- { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" },
- { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" },
- { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" },
- { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" },
- { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" },
- { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" },
- { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" },
- { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" },
- { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" },
- { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" },
- { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" },
- { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" },
- { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" },
- { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" },
- { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" },
- { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" },
- { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" },
- { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" },
- { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" },
- { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" },
- { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" },
- { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" },
- { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" },
- { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" },
- { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" },
- { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" },
- { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" },
- { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" },
- { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" },
- { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" },
- { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" },
- { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" },
- { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" },
- { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" },
- { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" },
- { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" },
- { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" },
- { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" },
- { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" },
- { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" },
- { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" },
- { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" },
- { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" },
- { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" },
- { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" },
- { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" },
- { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971 },
+ { url = "https://files.pythonhosted.org/packages/eb/0e/b6489f344d99cd1e5b4d5e1be52dfd3f8a3dc5112aa6c33948da8cabad4e/coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", size = 219473 },
+ { url = "https://files.pythonhosted.org/packages/17/11/db2f414915a8e4ec53f60b17956c27f21fb68fcf20f8a455ce7c2ccec638/coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", size = 249896 },
+ { url = "https://files.pythonhosted.org/packages/80/06/0823fe93913663c017e508e8810c998c8ebd3ec2a5a85d2c3754297bdede/coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", size = 251810 },
+ { url = "https://files.pythonhosted.org/packages/61/dc/b151c3cc41b28cdf7f0166c5fa1271cbc305a8ec0124cce4b04f74791a18/coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", size = 253920 },
+ { url = "https://files.pythonhosted.org/packages/2d/35/e83de0556e54a4729a2b94ea816f74ce08732e81945024adee46851c2264/coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", size = 250025 },
+ { url = "https://files.pythonhosted.org/packages/39/67/af2eb9c3926ce3ea0d58a0d2516fcbdacf7a9fc9559fe63076beaf3f2596/coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", size = 251612 },
+ { url = "https://files.pythonhosted.org/packages/26/62/5be2e25f3d6c711d23b71296f8b44c978d4c8b4e5b26871abfc164297502/coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", size = 249670 },
+ { url = "https://files.pythonhosted.org/packages/b3/51/400d1b09a8344199f9b6a6fc1868005d766b7ea95e7882e494fa862ca69c/coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", size = 249395 },
+ { url = "https://files.pythonhosted.org/packages/e0/36/f02234bc6e5230e2f0a63fd125d0a2093c73ef20fdf681c7af62a140e4e7/coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", size = 250298 },
+ { url = "https://files.pythonhosted.org/packages/b0/06/713110d3dd3151b93611c9cbfc65c15b4156b44f927fced49ac0b20b32a4/coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", size = 221485 },
+ { url = "https://files.pythonhosted.org/packages/16/0c/3ae6255fa1ebcb7dec19c9a59e85ef5f34566d1265c70af5b2fc981da834/coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", size = 222421 },
+ { url = "https://files.pythonhosted.org/packages/b5/37/fabc3179af4d61d89ea47bd04333fec735cd5e8b59baad44fed9fc4170d7/coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", size = 221088 },
+ { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142 },
+ { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503 },
+ { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006 },
+ { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750 },
+ { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862 },
+ { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420 },
+ { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786 },
+ { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928 },
+ { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496 },
+ { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373 },
+ { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696 },
+ { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504 },
+ { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120 },
+ { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168 },
+ { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537 },
+ { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528 },
+ { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132 },
+ { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374 },
+ { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762 },
+ { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502 },
+ { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463 },
+ { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288 },
+ { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063 },
+ { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716 },
+ { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522 },
+ { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145 },
+ { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861 },
+ { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207 },
+ { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504 },
+ { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582 },
+ { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008 },
+ { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762 },
+ { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571 },
+ { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200 },
+ { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095 },
+ { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284 },
+ { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389 },
+ { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450 },
+ { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707 },
+ { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213 },
+ { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549 },
+ { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586 },
+ { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093 },
+ { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446 },
+ { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615 },
+ { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452 },
+ { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418 },
+ { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231 },
+ { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888 },
+ { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968 },
+ { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783 },
+ { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348 },
+ { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950 },
+ { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209 },
+ { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576 },
+ { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704 },
+ { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109 },
+ { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686 },
+ { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568 },
+ { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174 },
+ { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017 },
+ { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337 },
+ { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749 },
+ { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857 },
+ { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881 },
+ { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943 },
]
[package.optional-dependencies]
@@ -964,53 +963,53 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
- { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
- { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
- { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
- { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
- { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
- { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
- { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
- { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
- { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
- { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
- { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
- { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
- { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
- { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
- { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
- { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
- { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
- { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
- { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
- { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
- { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
- { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
- { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
- { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
- { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
- { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
- { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
- { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
- { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
- { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
- { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
- { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
- { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
- { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
- { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
- { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
- { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
- { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
- { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
- { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
- { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
- { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
- { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
- { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100 },
+ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978 },
+ { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422 },
+ { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503 },
+ { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779 },
+ { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683 },
+ { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874 },
+ { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283 },
+ { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844 },
+ { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290 },
+ { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612 },
+ { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804 },
+ { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026 },
+ { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892 },
+ { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835 },
+ { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239 },
+ { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593 },
+ { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961 },
+ { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145 },
+ { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719 },
+ { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209 },
+ { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285 },
+ { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441 },
+ { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869 },
+ { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948 },
+ { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153 },
+ { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947 },
+ { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429 },
+ { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968 },
+ { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758 },
+ { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863 },
+ { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983 },
+ { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173 },
+ { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298 },
+ { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338 },
+ { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650 },
+ { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820 },
+ { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968 },
+ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547 },
+ { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685 },
+ { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239 },
+ { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584 },
+ { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885 },
+ { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449 },
+ { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731 },
]
[[package]]
@@ -1021,16 +1020,16 @@ dependencies = [
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
- { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" },
- { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" },
- { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" },
- { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" },
- { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" },
- { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" },
- { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" },
- { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" },
- { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" },
+ { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539 },
+ { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166 },
+ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351 },
+ { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965 },
+ { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504 },
+ { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660 },
+ { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639 },
+ { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419 },
+ { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771 },
+ { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584 },
]
[[package]]
@@ -1038,7 +1037,7 @@ name = "cuda-pathfinder"
version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" },
+ { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671 },
]
[[package]]
@@ -1046,7 +1045,7 @@ name = "cuda-toolkit"
version = "13.0.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364 },
]
[package.optional-dependencies]
@@ -1093,7 +1092,7 @@ dependencies = [
{ name = "websocket-client" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/25/70/776c2bf6c6c454ab73d2066a1dc976b912f49e0ca2bb0962e6acd375c3f1/dashscope-1.25.10-py3-none-any.whl", hash = "sha256:b748a5dd371e7b6230322c94ebc3151c9be1f9301148a807d69501b6e828fc1d", size = 1341923, upload-time = "2026-01-29T03:48:45.115Z" },
+ { url = "https://files.pythonhosted.org/packages/25/70/776c2bf6c6c454ab73d2066a1dc976b912f49e0ca2bb0962e6acd375c3f1/dashscope-1.25.10-py3-none-any.whl", hash = "sha256:b748a5dd371e7b6230322c94ebc3151c9be1f9301148a807d69501b6e828fc1d", size = 1341923 },
]
[[package]]
@@ -1103,9 +1102,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
+ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298 },
]
[[package]]
@@ -1118,7 +1117,7 @@ dependencies = [
{ name = "websockets" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813 },
]
[[package]]
@@ -1129,45 +1128,45 @@ dependencies = [
{ name = "aiohttp" },
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623, upload-time = "2025-10-08T21:45:43.593Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284 },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 },
]
[[package]]
name = "dockerfile-parse"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845 },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
+ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896 },
]
[[package]]
@@ -1178,16 +1177,16 @@ dependencies = [
{ name = "python-dotenv" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 },
]
[[package]]
name = "durationpy"
version = "0.10"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922 },
]
[[package]]
@@ -1207,9 +1206,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "wcmatch" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f7/97/0e86ccb9e05c18e6e795e0808f14e2dc9f5c9ffb7be2a5cb77afd6d9f59e/e2b-2.21.1.tar.gz", hash = "sha256:2eff473ca03173cee1ccd9f9ec9e90c2b4705cca418e080e3104753d7ec33490", size = 157458, upload-time = "2026-05-14T17:36:02.318Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/97/0e86ccb9e05c18e6e795e0808f14e2dc9f5c9ffb7be2a5cb77afd6d9f59e/e2b-2.21.1.tar.gz", hash = "sha256:2eff473ca03173cee1ccd9f9ec9e90c2b4705cca418e080e3104753d7ec33490", size = 157458 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6f/d4/8b6a9a120e724dd8f91aededa89348a667a01fffbba28ae1a42cb397b0f0/e2b-2.21.1-py3-none-any.whl", hash = "sha256:9ec4646f3dba4a6da855baa8adeab239aa988e15904611e38bf12aeec2562ac9", size = 297476, upload-time = "2026-05-14T17:36:00.351Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d4/8b6a9a120e724dd8f91aededa89348a667a01fffbba28ae1a42cb397b0f0/e2b-2.21.1-py3-none-any.whl", hash = "sha256:9ec4646f3dba4a6da855baa8adeab239aa988e15904611e38bf12aeec2562ac9", size = 297476 },
]
[[package]]
@@ -1220,70 +1219,70 @@ dependencies = [
{ name = "lxml" },
{ name = "six" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/77/85/322e8882a582d4b707220d1929cfb74c125f2ba513991edbce40dbc462de/ebooklib-0.20.tar.gz", hash = "sha256:35e2f9d7d39907be8d39ae2deb261b19848945903ae3dbb6577b187ead69e985", size = 127066, upload-time = "2025-10-26T20:56:20.968Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/77/85/322e8882a582d4b707220d1929cfb74c125f2ba513991edbce40dbc462de/ebooklib-0.20.tar.gz", hash = "sha256:35e2f9d7d39907be8d39ae2deb261b19848945903ae3dbb6577b187ead69e985", size = 127066 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bf/ee/aa015c5de8b0dc42a8e507eae8c2de5d1c0e068c896858fec6d502402ed6/ebooklib-0.20-py3-none-any.whl", hash = "sha256:fff5322517a37e31c972d27be7d982cc3928c16b3dcc5fd7e8f7c0f5d7bcf42b", size = 40995, upload-time = "2025-10-26T20:56:19.104Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/aa015c5de8b0dc42a8e507eae8c2de5d1c0e068c896858fec6d502402ed6/ebooklib-0.20-py3-none-any.whl", hash = "sha256:fff5322517a37e31c972d27be7d982cc3928c16b3dcc5fd7e8f7c0f5d7bcf42b", size = 40995 },
]
[[package]]
name = "fastuuid"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" },
- { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" },
- { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" },
- { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" },
- { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" },
- { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" },
- { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" },
- { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" },
- { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" },
- { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" },
- { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" },
- { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" },
- { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" },
- { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" },
- { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" },
- { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" },
- { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" },
- { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" },
- { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" },
- { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" },
- { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" },
- { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" },
- { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" },
- { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" },
- { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" },
- { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" },
- { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" },
- { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" },
- { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" },
- { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" },
- { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" },
- { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" },
- { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" },
- { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" },
- { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" },
- { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" },
- { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" },
- { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" },
- { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" },
- { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" },
- { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" },
- { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" },
+ { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386 },
+ { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569 },
+ { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366 },
+ { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978 },
+ { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692 },
+ { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384 },
+ { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921 },
+ { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575 },
+ { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317 },
+ { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804 },
+ { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099 },
+ { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164 },
+ { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837 },
+ { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370 },
+ { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766 },
+ { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105 },
+ { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564 },
+ { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659 },
+ { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430 },
+ { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894 },
+ { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374 },
+ { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550 },
+ { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720 },
+ { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024 },
+ { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679 },
+ { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862 },
+ { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278 },
+ { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788 },
+ { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819 },
+ { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546 },
+ { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921 },
+ { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559 },
+ { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539 },
+ { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600 },
+ { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069 },
+ { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543 },
+ { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798 },
+ { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283 },
+ { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627 },
+ { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778 },
+ { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605 },
+ { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837 },
+ { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532 },
+ { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457 },
]
[[package]]
name = "filelock"
version = "3.20.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701 },
]
[[package]]
@@ -1298,9 +1297,9 @@ dependencies = [
{ name = "markupsafe" },
{ name = "werkzeug" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424 },
]
[[package]]
@@ -1308,130 +1307,130 @@ name = "flatbuffers"
version = "25.12.19"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661 },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
- { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
- { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
- { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
- { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
- { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
- { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
- { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
- { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
- { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
- { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
- { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
- { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
- { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
- { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
- { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
- { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
- { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
- { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
- { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
- { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
- { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
- { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
- { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
- { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
- { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
- { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
- { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
- { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
- { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
- { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
- { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
- { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
- { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
- { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
- { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
- { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
- { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
- { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
- { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
- { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
- { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
- { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
- { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
- { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
- { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
- { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
- { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
- { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
- { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
- { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
- { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
- { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
- { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
- { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
- { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
- { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
- { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
- { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
- { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
- { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
- { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
- { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
- { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
- { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
- { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
- { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
- { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
- { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
- { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
- { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
- { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
- { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
- { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
- { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
- { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
- { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
- { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
- { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
- { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
- { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
- { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
- { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
- { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
- { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
- { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
- { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
- { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
- { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
- { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
- { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
- { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
- { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
- { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
- { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
- { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
- { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912 },
+ { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046 },
+ { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119 },
+ { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067 },
+ { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160 },
+ { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544 },
+ { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797 },
+ { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923 },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886 },
+ { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731 },
+ { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544 },
+ { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806 },
+ { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382 },
+ { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647 },
+ { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064 },
+ { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937 },
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 },
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 },
]
[[package]]
name = "fsspec"
version = "2026.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" },
+ { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838 },
]
[[package]]
name = "future"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" },
+ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326 },
]
[[package]]
@@ -1442,9 +1441,9 @@ dependencies = [
{ name = "qrcode" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/69/97/7e5ba34551559de1da12ba8f33661d9a4597f79db956250cb787751bdbb3/gewechat_client-0.2.2.tar.gz", hash = "sha256:42a1ed176e2a806b97b76ba2a89d24b7898ee6868db62a6c9c8141cc62989702", size = 17394, upload-time = "2026-01-24T12:36:00.259Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/97/7e5ba34551559de1da12ba8f33661d9a4597f79db956250cb787751bdbb3/gewechat_client-0.2.2.tar.gz", hash = "sha256:42a1ed176e2a806b97b76ba2a89d24b7898ee6868db62a6c9c8141cc62989702", size = 17394 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4e/46/f2667017945c02a8fa36eaf57cd36490a2a344f456227d3e70fede7ea678/gewechat_client-0.2.2-py3-none-any.whl", hash = "sha256:4d1c39608d97d03490efb4b0482af55431a449bcda6a6440c285cb7347676a6f", size = 20324, upload-time = "2026-01-24T12:35:59.397Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/46/f2667017945c02a8fa36eaf57cd36490a2a344f456227d3e70fede7ea678/gewechat_client-0.2.2-py3-none-any.whl", hash = "sha256:4d1c39608d97d03490efb4b0482af55431a449bcda6a6440c285cb7347676a6f", size = 20324 },
]
[[package]]
@@ -1454,61 +1453,61 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515 },
]
[[package]]
name = "greenlet"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" },
- { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" },
- { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" },
- { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" },
- { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" },
- { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" },
- { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" },
- { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" },
- { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" },
- { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" },
- { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" },
- { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" },
- { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" },
- { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" },
- { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" },
- { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" },
- { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" },
- { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" },
- { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
- { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
- { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
- { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
- { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
- { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
- { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
- { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" },
- { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" },
- { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
- { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
- { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
- { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
- { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
- { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
- { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
- { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" },
- { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" },
- { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
- { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
- { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
- { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
- { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
- { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
- { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
- { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974 },
+ { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175 },
+ { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401 },
+ { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161 },
+ { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272 },
+ { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729 },
+ { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552 },
+ { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462 },
+ { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715 },
+ { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443 },
+ { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359 },
+ { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805 },
+ { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363 },
+ { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947 },
+ { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487 },
+ { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087 },
+ { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156 },
+ { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403 },
+ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205 },
+ { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284 },
+ { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274 },
+ { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375 },
+ { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904 },
+ { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316 },
+ { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549 },
+ { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042 },
+ { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294 },
+ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737 },
+ { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422 },
+ { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219 },
+ { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455 },
+ { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237 },
+ { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261 },
+ { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719 },
+ { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125 },
+ { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519 },
+ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706 },
+ { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209 },
+ { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300 },
+ { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574 },
+ { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842 },
+ { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917 },
+ { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092 },
+ { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181 },
]
[[package]]
@@ -1518,57 +1517,57 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" },
- { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" },
- { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" },
- { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" },
- { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" },
- { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" },
- { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" },
- { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" },
- { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" },
- { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" },
- { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" },
- { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" },
- { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" },
- { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" },
- { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" },
- { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" },
- { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" },
- { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" },
- { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" },
- { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" },
- { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" },
- { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" },
- { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" },
- { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" },
- { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" },
- { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" },
- { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" },
- { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" },
- { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" },
- { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" },
- { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" },
- { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" },
- { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" },
- { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" },
- { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" },
- { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" },
- { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" },
- { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" },
- { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" },
- { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567 },
+ { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017 },
+ { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027 },
+ { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913 },
+ { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417 },
+ { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683 },
+ { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109 },
+ { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676 },
+ { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688 },
+ { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315 },
+ { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718 },
+ { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627 },
+ { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167 },
+ { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267 },
+ { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963 },
+ { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484 },
+ { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777 },
+ { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014 },
+ { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750 },
+ { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003 },
+ { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716 },
+ { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522 },
+ { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558 },
+ { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990 },
+ { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387 },
+ { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668 },
+ { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928 },
+ { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983 },
+ { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727 },
+ { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799 },
+ { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417 },
+ { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219 },
+ { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826 },
+ { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550 },
+ { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564 },
+ { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236 },
+ { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795 },
+ { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214 },
+ { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961 },
+ { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462 },
]
[[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" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
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" },
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
@@ -1579,56 +1578,56 @@ dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
+ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779 },
]
[[package]]
name = "hf-xet"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" },
- { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" },
- { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" },
- { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" },
- { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" },
- { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" },
- { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" },
- { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" },
- { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" },
- { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" },
- { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" },
- { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" },
- { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" },
- { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" },
- { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" },
- { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" },
- { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" },
- { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" },
- { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" },
- { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" },
- { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870 },
+ { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584 },
+ { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004 },
+ { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636 },
+ { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448 },
+ { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401 },
+ { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866 },
+ { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861 },
+ { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699 },
+ { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885 },
+ { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550 },
+ { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010 },
+ { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264 },
+ { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071 },
+ { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099 },
+ { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178 },
+ { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214 },
+ { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054 },
+ { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812 },
+ { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920 },
+ { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735 },
]
[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 },
]
[[package]]
name = "html2text"
version = "2025.4.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f8/27/e158d86ba1e82967cc2f790b0cb02030d4a8bef58e0c79a8590e9678107f/html2text-2025.4.15.tar.gz", hash = "sha256:948a645f8f0bc3abe7fd587019a2197a12436cd73d0d4908af95bfc8da337588", size = 64316, upload-time = "2025-04-15T04:02:30.045Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/27/e158d86ba1e82967cc2f790b0cb02030d4a8bef58e0c79a8590e9678107f/html2text-2025.4.15.tar.gz", hash = "sha256:948a645f8f0bc3abe7fd587019a2197a12436cd73d0d4908af95bfc8da337588", size = 64316 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/84/1a0f9555fd5f2b1c924ff932d99b40a0f8a6b12f6dd625e2a47f415b00ea/html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc", size = 34656, upload-time = "2025-04-15T04:02:28.44Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/84/1a0f9555fd5f2b1c924ff932d99b40a0f8a6b12f6dd625e2a47f415b00ea/html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc", size = 34656 },
]
[[package]]
@@ -1639,45 +1638,45 @@ 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" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
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" },
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" },
- { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" },
- { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" },
- { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" },
- { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" },
- { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" },
- { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" },
- { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
- { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
- { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
- { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
- { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
- { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
- { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
- { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
- { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
- { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
- { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
- { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
- { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
- { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
- { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
- { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
- { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
- { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
- { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
- { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
- { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521 },
+ { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375 },
+ { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621 },
+ { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954 },
+ { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175 },
+ { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310 },
+ { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875 },
+ { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280 },
+ { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004 },
+ { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655 },
+ { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440 },
+ { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186 },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192 },
+ { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694 },
+ { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889 },
+ { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180 },
+ { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596 },
+ { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268 },
+ { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517 },
+ { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337 },
+ { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743 },
+ { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619 },
+ { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714 },
+ { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909 },
+ { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831 },
+ { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631 },
+ { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910 },
+ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205 },
]
[[package]]
@@ -1690,9 +1689,9 @@ dependencies = [
{ 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" }
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
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" },
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[package.optional-dependencies]
@@ -1704,9 +1703,9 @@ http2 = [
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 },
]
[[package]]
@@ -1725,9 +1724,9 @@ dependencies = [
{ name = "typer-slim" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/67/e9/2658cb9bc4c72a67b7f87650e827266139befaf499095883d30dabc4d49f/huggingface_hub-1.3.5.tar.gz", hash = "sha256:8045aca8ddab35d937138f3c386c6d43a275f53437c5c64cdc9aa8408653b4ed", size = 627456, upload-time = "2026-01-29T10:34:19.687Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/67/e9/2658cb9bc4c72a67b7f87650e827266139befaf499095883d30dabc4d49f/huggingface_hub-1.3.5.tar.gz", hash = "sha256:8045aca8ddab35d937138f3c386c6d43a275f53437c5c64cdc9aa8408653b4ed", size = 627456 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f9/84/a579b95c46fe8e319f89dc700c087596f665141575f4dcf136aaa97d856f/huggingface_hub-1.3.5-py3-none-any.whl", hash = "sha256:fe332d7f86a8af874768452295c22cd3f37730fb2463cf6cc3295e26036f8ef9", size = 536675, upload-time = "2026-01-29T10:34:17.713Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/84/a579b95c46fe8e319f89dc700c087596f665141575f4dcf136aaa97d856f/huggingface_hub-1.3.5-py3-none-any.whl", hash = "sha256:fe332d7f86a8af874768452295c22cd3f37730fb2463cf6cc3295e26036f8ef9", size = 536675 },
]
[[package]]
@@ -1737,9 +1736,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 },
]
[[package]]
@@ -1752,36 +1751,36 @@ dependencies = [
{ name = "priority" },
{ name = "wsproto" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" },
+ { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640 },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 },
]
[[package]]
name = "identify"
version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202 },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 },
]
[[package]]
@@ -1791,36 +1790,36 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865 },
]
[[package]]
name = "importlib-resources"
version = "6.5.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461 },
]
[[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" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
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" },
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
]
[[package]]
@@ -1830,112 +1829,104 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "jiter"
version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" },
- { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548, upload-time = "2025-11-09T20:47:03.409Z" },
- { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915, upload-time = "2025-11-09T20:47:05.171Z" },
- { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966, upload-time = "2025-11-09T20:47:06.508Z" },
- { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047, upload-time = "2025-11-09T20:47:08.382Z" },
- { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835, upload-time = "2025-11-09T20:47:09.81Z" },
- { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587, upload-time = "2025-11-09T20:47:11.529Z" },
- { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492, upload-time = "2025-11-09T20:47:12.993Z" },
- { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046, upload-time = "2025-11-09T20:47:14.6Z" },
- { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392, upload-time = "2025-11-09T20:47:16.011Z" },
- { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096, upload-time = "2025-11-09T20:47:17.344Z" },
- { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899, upload-time = "2025-11-09T20:47:19.365Z" },
- { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070, upload-time = "2025-11-09T20:47:20.75Z" },
- { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" },
- { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" },
- { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" },
- { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" },
- { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" },
- { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" },
- { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" },
- { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" },
- { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" },
- { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" },
- { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" },
- { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" },
- { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" },
- { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658, upload-time = "2025-11-09T20:47:44.424Z" },
- { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605, upload-time = "2025-11-09T20:47:45.973Z" },
- { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803, upload-time = "2025-11-09T20:47:47.535Z" },
- { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120, upload-time = "2025-11-09T20:47:49.284Z" },
- { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918, upload-time = "2025-11-09T20:47:50.807Z" },
- { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008, upload-time = "2025-11-09T20:47:52.211Z" },
- { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785, upload-time = "2025-11-09T20:47:53.512Z" },
- { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108, upload-time = "2025-11-09T20:47:54.893Z" },
- { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937, upload-time = "2025-11-09T20:47:56.253Z" },
- { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853, upload-time = "2025-11-09T20:47:58.32Z" },
- { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699, upload-time = "2025-11-09T20:47:59.686Z" },
- { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258, upload-time = "2025-11-09T20:48:01.01Z" },
- { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503, upload-time = "2025-11-09T20:48:02.35Z" },
- { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965, upload-time = "2025-11-09T20:48:03.783Z" },
- { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831, upload-time = "2025-11-09T20:48:05.55Z" },
- { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272, upload-time = "2025-11-09T20:48:06.951Z" },
- { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604, upload-time = "2025-11-09T20:48:08.328Z" },
- { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" },
- { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" },
- { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" },
- { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" },
- { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" },
- { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" },
- { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" },
- { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" },
- { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" },
- { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" },
- { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" },
- { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" },
- { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" },
- { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" },
- { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" },
- { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" },
- { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" },
- { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" },
- { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" },
- { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" },
- { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" },
- { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" },
- { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" },
- { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" },
- { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" },
- { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" },
- { url = "https://files.pythonhosted.org/packages/fe/54/5339ef1ecaa881c6948669956567a64d2670941925f245c434f494ffb0e5/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8", size = 311144, upload-time = "2025-11-09T20:49:10.503Z" },
- { url = "https://files.pythonhosted.org/packages/27/74/3446c652bffbd5e81ab354e388b1b5fc1d20daac34ee0ed11ff096b1b01a/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3", size = 305877, upload-time = "2025-11-09T20:49:12.269Z" },
- { url = "https://files.pythonhosted.org/packages/a1/f4/ed76ef9043450f57aac2d4fbeb27175aa0eb9c38f833be6ef6379b3b9a86/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e", size = 340419, upload-time = "2025-11-09T20:49:13.803Z" },
- { url = "https://files.pythonhosted.org/packages/21/01/857d4608f5edb0664aa791a3d45702e1a5bcfff9934da74035e7b9803846/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d", size = 347212, upload-time = "2025-11-09T20:49:15.643Z" },
- { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" },
- { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" },
- { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" },
- { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" },
+ { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435 },
+ { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548 },
+ { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915 },
+ { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966 },
+ { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047 },
+ { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835 },
+ { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587 },
+ { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492 },
+ { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046 },
+ { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392 },
+ { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096 },
+ { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899 },
+ { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070 },
+ { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449 },
+ { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855 },
+ { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171 },
+ { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590 },
+ { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462 },
+ { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983 },
+ { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328 },
+ { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740 },
+ { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875 },
+ { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457 },
+ { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546 },
+ { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196 },
+ { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100 },
+ { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658 },
+ { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605 },
+ { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803 },
+ { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120 },
+ { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918 },
+ { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008 },
+ { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785 },
+ { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108 },
+ { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937 },
+ { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853 },
+ { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699 },
+ { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258 },
+ { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503 },
+ { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965 },
+ { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831 },
+ { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272 },
+ { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604 },
+ { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628 },
+ { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478 },
+ { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706 },
+ { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894 },
+ { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714 },
+ { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989 },
+ { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615 },
+ { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745 },
+ { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502 },
+ { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845 },
+ { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701 },
+ { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029 },
+ { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960 },
+ { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529 },
+ { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974 },
+ { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932 },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243 },
+ { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315 },
+ { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714 },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168 },
+ { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893 },
+ { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828 },
+ { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009 },
+ { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110 },
+ { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223 },
+ { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564 },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
+ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419 },
]
[[package]]
name = "joblib"
version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071 },
]
[[package]]
@@ -1945,18 +1936,18 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpointer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
+ { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898 },
]
[[package]]
name = "jsonpointer"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
+ { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595 },
]
[[package]]
@@ -1969,9 +1960,9 @@ dependencies = [
{ name = "referencing" },
{ name = "rpds-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 },
]
[[package]]
@@ -1981,9 +1972,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 },
]
[[package]]
@@ -2001,9 +1992,9 @@ dependencies = [
{ name = "urllib3" },
{ name = "websocket-client" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642, upload-time = "2026-01-16T01:05:27.76Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/8f/85bf51ad4150f64e8c665daf0d9dfe9787ae92005efb9a4d1cba592bd79d/kubernetes-35.0.0.tar.gz", hash = "sha256:3d00d344944239821458b9efd484d6df9f011da367ecb155dadf9513f05f09ee", size = 1094642 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602, upload-time = "2026-01-16T01:05:25.991Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/70/05b685ea2dffcb2adbf3cdcea5d8865b7bc66f67249084cf845012a0ff13/kubernetes-35.0.0-py2.py3-none-any.whl", hash = "sha256:39e2b33b46e5834ef6c3985ebfe2047ab39135d41de51ce7641a7ca5b372a13d", size = 2017602 },
]
[[package]]
@@ -2074,6 +2065,7 @@ dependencies = [
{ name = "qrcode" },
{ name = "quart" },
{ name = "quart-cors" },
+ { name = "regex" },
{ name = "requests" },
{ name = "ruff" },
{ name = "slack-sdk" },
@@ -2124,7 +2116,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
- { name = "langbot-plugin", specifier = "==0.4.17" },
+ { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2163,6 +2155,7 @@ requires-dist = [
{ name = "qrcode", specifier = ">=7.4" },
{ name = "quart", specifier = ">=0.20.0" },
{ name = "quart-cors", specifier = ">=0.8.0" },
+ { name = "regex", specifier = ">=2026.1.15" },
{ name = "requests", specifier = ">=2.33.0" },
{ name = "ruff", specifier = ">=0.11.9" },
{ name = "slack-sdk", specifier = ">=3.35.0" },
@@ -2189,8 +2182,8 @@ dev = [
[[package]]
name = "langbot-plugin"
-version = "0.4.17"
-source = { registry = "https://pypi.org/simple" }
+version = "0.4.18"
+source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=1d65ed301a6afc52150a998043f73cd6032c8162#1d65ed301a6afc52150a998043f73cd6032c8162" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2210,10 +2203,6 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3a/96/336d7ac97ff5a9c413e7aaf0c329a7047ec91dec5fa4f9e0b1d0abb57d0e/langbot_plugin-0.4.17.tar.gz", hash = "sha256:b1539444f16568c0b3244f68c498ac9498c56e2c20f12e220e6ee735b8d6b5c2", size = 347080, upload-time = "2026-07-23T09:23:55.941Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e1/98/ac372a238e59894ac54189cb220a3fb75da48f319881eeb49d583712549a/langbot_plugin-0.4.17-py3-none-any.whl", hash = "sha256:6fd6c9d7e0583f04659f023a233c4201cb4c450d1ef696e79fa1e338933ee156", size = 229881, upload-time = "2026-07-23T09:23:54.582Z" },
-]
[[package]]
name = "langchain"
@@ -2224,9 +2213,9 @@ dependencies = [
{ name = "langgraph" },
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/e351d85c7828b9b90c5729de66170457c882c754efef0712904cfcd3192d/langchain-1.3.10.tar.gz", hash = "sha256:fd6ac9da86c479e4ff376e772d9e17a9232bd3113e9f2ddcb70cdc4bf7afc119", size = 632522, upload-time = "2026-06-18T19:43:00.86Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/e351d85c7828b9b90c5729de66170457c882c754efef0712904cfcd3192d/langchain-1.3.10.tar.gz", hash = "sha256:fd6ac9da86c479e4ff376e772d9e17a9232bd3113e9f2ddcb70cdc4bf7afc119", size = 632522 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/59/f6/a682e68d004a2e23cae6c5c42e3c0d071bc0e7768167bd12277992f096f9/langchain-1.3.10-py3-none-any.whl", hash = "sha256:5da67f21aa56119744ad51b3e46ffac570c88f4fae0876e3b1c6a1c4bc0e344e", size = 133038, upload-time = "2026-06-18T19:42:58.918Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f6/a682e68d004a2e23cae6c5c42e3c0d071bc0e7768167bd12277992f096f9/langchain-1.3.10-py3-none-any.whl", hash = "sha256:5da67f21aa56119744ad51b3e46ffac570c88f4fae0876e3b1c6a1c4bc0e344e", size = 133038 },
]
[[package]]
@@ -2244,9 +2233,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" },
+ { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416 },
]
[[package]]
@@ -2256,9 +2245,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
+ { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221 },
]
[[package]]
@@ -2268,9 +2257,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d3/26/1ef06f56198d631296d646a6223de35bcc6cf9795ceb2442816bc963b84c/langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10", size = 35903, upload-time = "2026-04-16T14:20:38.243Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/26/1ef06f56198d631296d646a6223de35bcc6cf9795ceb2442816bc963b84c/langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10", size = 35903 },
]
[[package]]
@@ -2285,9 +2274,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "xxhash" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400, upload-time = "2026-06-18T20:58:21.514Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132, upload-time = "2026-06-18T20:58:20.335Z" },
+ { url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132 },
]
[[package]]
@@ -2298,9 +2287,9 @@ dependencies = [
{ name = "langchain-core" },
{ name = "ormsgpack" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212 },
]
[[package]]
@@ -2311,9 +2300,9 @@ dependencies = [
{ name = "langchain-core" },
{ name = "langgraph-checkpoint" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043 },
]
[[package]]
@@ -2327,9 +2316,9 @@ dependencies = [
{ name = "orjson" },
{ name = "websockets" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521 },
]
[[package]]
@@ -2348,9 +2337,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" },
+ { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108 },
]
[[package]]
@@ -2364,72 +2353,72 @@ dependencies = [
{ name = "requests-toolbelt" },
{ name = "websockets" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/12/aa/db027c41fdfb4f42471634cfc2a6f69d64d68f58ee555914293d60dbaceb/lark_oapi-1.6.4.tar.gz", hash = "sha256:b2aceccd1a01e55a82927ba1ee187e2eae5392cc97bc00ce0b3f08da3fb9a4ce", size = 2078060, upload-time = "2026-05-12T11:03:07.041Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/12/aa/db027c41fdfb4f42471634cfc2a6f69d64d68f58ee555914293d60dbaceb/lark_oapi-1.6.4.tar.gz", hash = "sha256:b2aceccd1a01e55a82927ba1ee187e2eae5392cc97bc00ce0b3f08da3fb9a4ce", size = 2078060 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/89/9f/47ec3a6628acdd74229a91abf67a3b574002fe6e587be4265c3bd928bddf/lark_oapi-1.6.4-py3-none-any.whl", hash = "sha256:9013b2793f627612906090c5d960ca7bd1cf8896a66875528221c769e0ceecc0", size = 7142621, upload-time = "2026-05-12T11:03:03.882Z" },
+ { url = "https://files.pythonhosted.org/packages/89/9f/47ec3a6628acdd74229a91abf67a3b574002fe6e587be4265c3bd928bddf/lark_oapi-1.6.4-py3-none-any.whl", hash = "sha256:9013b2793f627612906090c5d960ca7bd1cf8896a66875528221c769e0ceecc0", size = 7142621 },
]
[[package]]
name = "librt"
version = "0.7.8"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" },
- { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" },
- { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" },
- { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" },
- { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" },
- { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" },
- { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" },
- { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" },
- { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" },
- { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" },
- { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" },
- { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" },
- { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" },
- { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" },
- { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" },
- { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" },
- { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" },
- { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" },
- { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" },
- { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" },
- { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" },
- { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" },
- { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" },
- { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" },
- { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" },
- { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" },
- { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" },
- { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" },
- { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" },
- { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" },
- { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" },
- { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" },
- { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" },
- { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" },
- { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" },
- { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" },
- { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" },
- { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" },
- { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" },
- { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" },
- { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" },
- { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" },
- { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" },
- { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" },
- { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" },
- { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" },
- { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" },
- { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" },
- { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" },
- { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" },
- { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" },
- { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" },
- { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" },
- { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" },
- { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507 },
+ { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455 },
+ { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956 },
+ { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364 },
+ { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034 },
+ { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295 },
+ { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470 },
+ { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713 },
+ { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803 },
+ { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080 },
+ { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383 },
+ { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472 },
+ { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986 },
+ { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422 },
+ { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478 },
+ { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439 },
+ { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483 },
+ { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376 },
+ { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234 },
+ { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057 },
+ { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293 },
+ { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574 },
+ { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500 },
+ { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019 },
+ { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015 },
+ { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161 },
+ { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015 },
+ { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038 },
+ { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006 },
+ { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888 },
+ { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126 },
+ { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262 },
+ { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600 },
+ { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049 },
+ { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689 },
+ { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808 },
+ { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614 },
+ { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955 },
+ { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370 },
+ { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224 },
+ { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541 },
+ { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657 },
+ { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835 },
+ { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885 },
+ { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161 },
+ { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008 },
+ { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199 },
+ { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317 },
+ { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334 },
+ { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031 },
+ { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581 },
+ { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731 },
+ { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173 },
+ { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668 },
+ { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550 },
]
[[package]]
@@ -2446,9 +2435,9 @@ dependencies = [
{ name = "requests" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/41/c7/e8b07aa532669e56baab8404b213b1902678d78d7339e36dc1ecc3a91b32/line_bot_sdk-3.22.0.tar.gz", hash = "sha256:f686586a5e576449b3f8612d761fc79f726b52d817e60f60b69aac1d59e9f25d", size = 468902, upload-time = "2026-01-21T11:24:14.06Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/c7/e8b07aa532669e56baab8404b213b1902678d78d7339e36dc1ecc3a91b32/line_bot_sdk-3.22.0.tar.gz", hash = "sha256:f686586a5e576449b3f8612d761fc79f726b52d817e60f60b69aac1d59e9f25d", size = 468902 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9d/9c/b03ee25728f76a1292110ed9a7b330bf0c744f0f5fc3ea9ab7db3e3fc01d/line_bot_sdk-3.22.0-py2.py3-none-any.whl", hash = "sha256:64e202330997e02fd7cfe77b51f9df812aff61dd9d0e7ba840e8ecd96c2dda67", size = 818871, upload-time = "2026-01-21T11:24:12.117Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/9c/b03ee25728f76a1292110ed9a7b330bf0c744f0f5fc3ea9ab7db3e3fc01d/line_bot_sdk-3.22.0-py2.py3-none-any.whl", hash = "sha256:64e202330997e02fd7cfe77b51f9df812aff61dd9d0e7ba840e8ecd96c2dda67", size = 818871 },
]
[[package]]
@@ -2458,9 +2447,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
+ { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820 },
]
[[package]]
@@ -2481,9 +2470,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "tokenizers" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/16/ea/f99ececb7f22703fe120f1d8be9ffb749ec9453fbbbbbebc0d6a6b4d7864/litellm-1.88.1.tar.gz", hash = "sha256:89c6b74cc7912d6365793006ff951c0450fe847625008dfe49de8a7dc4529aa5", size = 13885969, upload-time = "2026-06-09T01:06:25.192Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/16/ea/f99ececb7f22703fe120f1d8be9ffb749ec9453fbbbbbebc0d6a6b4d7864/litellm-1.88.1.tar.gz", hash = "sha256:89c6b74cc7912d6365793006ff951c0450fe847625008dfe49de8a7dc4529aa5", size = 13885969 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/42/9a/8f8909201b4bebaf96498c09226f6baa8540086a4c4188ad57d7dfbd97c1/litellm-1.88.1-py3-none-any.whl", hash = "sha256:369b84e57d9426582ddc35e731956ddb6618cda97cc44e4e4d2dfa75982a6e3a", size = 15276206, upload-time = "2026-06-09T01:06:16.72Z" },
+ { url = "https://files.pythonhosted.org/packages/42/9a/8f8909201b4bebaf96498c09226f6baa8540086a4c4188ad57d7dfbd97c1/litellm-1.88.1-py3-none-any.whl", hash = "sha256:369b84e57d9426582ddc35e731956ddb6618cda97cc44e4e4d2dfa75982a6e3a", size = 15276206 },
]
[[package]]
@@ -2493,160 +2482,160 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/e39c249003caaa7f84e2b00c11c7423892d525f8136ff349f9914914a744/logbook-1.9.2.tar.gz", hash = "sha256:0538cabfd8e8a02b8185fb7a2be20b3965d225fbd7f4a5726b007d8e26b39ee0", size = 481718, upload-time = "2025-11-27T21:12:02.539Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/e39c249003caaa7f84e2b00c11c7423892d525f8136ff349f9914914a744/logbook-1.9.2.tar.gz", hash = "sha256:0538cabfd8e8a02b8185fb7a2be20b3965d225fbd7f4a5726b007d8e26b39ee0", size = 481718 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/0c/ef05117fb10ee4ed233e2918662e248fcdc5a9b253fd1b7ce3cc2ea5ba9f/logbook-1.9.2-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:abaa8e1c99f01476077339a34ed6d39f624749bbef64a22bcc646906c23567e9", size = 608780, upload-time = "2025-11-27T21:10:30.98Z" },
- { url = "https://files.pythonhosted.org/packages/69/9f/0b9e6722dfecf62d657c19db0a5f4aa284b78c825badd6f76e5a651eca1c/logbook-1.9.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e244cec50ca4503d7d19e5dc8b93d0da12124b45205be36e1c327da0471eab55", size = 341044, upload-time = "2025-11-27T21:10:32.724Z" },
- { url = "https://files.pythonhosted.org/packages/c3/dc/796b97840ccc5440cdf8dbe78b2afebf0b9230c6d4519f5bca70a1c14cb9/logbook-1.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57cd0a668e1cd171f086e963dccc79fd55c160010603304782ed5af15e273032", size = 332264, upload-time = "2025-11-27T21:10:34.498Z" },
- { url = "https://files.pythonhosted.org/packages/fa/ec/80de5880169cd458299fceb19e1f9f14d5f8a75b9ac3f09c56789bb403c3/logbook-1.9.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:068da439a77c95863ee521b8e23281a5734ec15b683b371fbe3ae70250393ac5", size = 363382, upload-time = "2025-11-27T21:10:36.298Z" },
- { url = "https://files.pythonhosted.org/packages/f2/0e/da2dea269cd32a0866744f2d64ad499d44c722ae161d5a2048f534db4ee1/logbook-1.9.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c1c913500067560697db9b6fe17b158e3f20f06a1b8687920fac63b209ceae1b", size = 369993, upload-time = "2025-11-27T21:10:37.604Z" },
- { url = "https://files.pythonhosted.org/packages/44/67/b13dc09c44bb6226b427593a33ceb20d54d0b680defc135ddd9d42dcbd55/logbook-1.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7314bffd4c1354e5de6502774b609120aa5e881a820d8ac77431dc96ec18d1d5", size = 428097, upload-time = "2025-11-27T21:10:38.927Z" },
- { url = "https://files.pythonhosted.org/packages/5b/73/a501496f806c31c0d62bcc660626b12682b8ad871ca66b5330fc96d9898d/logbook-1.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3df4412d0d464f04843ab94aabbe503afb9ff20ec982091370eb5245daac842e", size = 443986, upload-time = "2025-11-27T21:10:40.307Z" },
- { url = "https://files.pythonhosted.org/packages/fc/22/6fc98fe475cf7c428be6cdb7400054608178c13b100666097590d37bf6be/logbook-1.9.2-cp311-cp311-win32.whl", hash = "sha256:4944b9052bfb450ccbc1bbada381fe9ba161aa024f02e7f72b6ad157fd0f1aff", size = 215222, upload-time = "2025-11-27T21:10:41.738Z" },
- { url = "https://files.pythonhosted.org/packages/fa/df/d5197125a12b55f50c640baf9744fdad4c8c05a77f9a20801b68224f0fdc/logbook-1.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:8dc11dd0ab88453de405620684b530a79110289136b1beb732c8ad3c6ad106f9", size = 221838, upload-time = "2025-11-27T21:10:43.359Z" },
- { url = "https://files.pythonhosted.org/packages/d3/4b/0d3f427ff7dcaa69d9cf159a76a1ccea4ebb09cfa602da2fe09850c89cd1/logbook-1.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:d3e5288a963180a336012d12f2cb5143dd565c4b879aa00a2d0fce0350089679", size = 217653, upload-time = "2025-11-27T21:10:44.706Z" },
- { url = "https://files.pythonhosted.org/packages/1c/5c/dd429f46497e3f6c15498b8bd57d5b744247b67675e7df649c8b28065e45/logbook-1.9.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1b60d31dad31da9b108a1c656be7560b930d90b1fff1efbd66a926f0562ec535", size = 605842, upload-time = "2025-11-27T21:10:46.532Z" },
- { url = "https://files.pythonhosted.org/packages/91/b5/212d37a83690277ac2f8269d4f2b4148909939b656d3ad0f6d3275e8f419/logbook-1.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:057743e915c929c9e910b4fa1424531e255de8647c4f3288a086b7c2bb53a3e0", size = 340453, upload-time = "2025-11-27T21:10:48.164Z" },
- { url = "https://files.pythonhosted.org/packages/17/b5/d36bccf730d7f7d127f0baf2807895f85d5a3f911b99bcbc8e18b3e439f9/logbook-1.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:684d9ade464eace999a3c32e912f3e92dcd6958dd74f2a908b2fffcf376d8433", size = 330194, upload-time = "2025-11-27T21:10:49.526Z" },
- { url = "https://files.pythonhosted.org/packages/01/35/eaea42346f62c785af2073fbbef115d53aa7422917e2c73ede14b2ab0775/logbook-1.9.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0e085878f9de9c62d9521e1b920253cd725b94ce291252e83843dd95fc16072f", size = 362301, upload-time = "2025-11-27T21:10:50.879Z" },
- { url = "https://files.pythonhosted.org/packages/b1/17/8a36848cf9eedf32975cc04000f8b7d8806461ef4702a316cb381e447a2f/logbook-1.9.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:42e258cea92ff422426a76937926675350bd85d9d23899ae21fa70112f041f97", size = 367958, upload-time = "2025-11-27T21:10:52.172Z" },
- { url = "https://files.pythonhosted.org/packages/3e/e3/8a5a7136421e7090c94ccb83f625c9f946bba17bdf58f02728f4d1a1f898/logbook-1.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6fd699fbf1aac8ccc06a131cbe0a652ae150e1bd902f1cacaff9b23ee18ab451", size = 426613, upload-time = "2025-11-27T21:10:53.939Z" },
- { url = "https://files.pythonhosted.org/packages/e8/0b/ad5c677d9eb8ed3b139c7d7033ccd0d48a5af2e4270a0ed452bd45337d50/logbook-1.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35a95af2e3485e5ddb2a6e26dc61f65ffc2895df54af6426e6d07e28efab0cb5", size = 442118, upload-time = "2025-11-27T21:10:55.475Z" },
- { url = "https://files.pythonhosted.org/packages/36/07/ef0498e8805eb9fa6f5a347ce7569302875d96d071b34f0cf52b1671d111/logbook-1.9.2-cp312-cp312-win32.whl", hash = "sha256:cccc1e347e80faab592e751f52297d8740d638202b191c5c1669373f78b31747", size = 213424, upload-time = "2025-11-27T21:10:57.518Z" },
- { url = "https://files.pythonhosted.org/packages/86/cf/313171c253e7d69deec15231fbf69132a3b704e7394d6af4fbfcd3568bc2/logbook-1.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:75fb010c56dbe3052924ec14f9a0a64b922839f71592fb7d2907496e253d153a", size = 220437, upload-time = "2025-11-27T21:10:59.576Z" },
- { url = "https://files.pythonhosted.org/packages/ec/b0/2685d40edaa3a4d036ebcf5b95206b2772408edd9768e8607bdfec38f3ba/logbook-1.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:3851490b920199573e6b57675da85b7cdfdc785a24d5236917b7c50f3e600f26", size = 215989, upload-time = "2025-11-27T21:11:00.814Z" },
- { url = "https://files.pythonhosted.org/packages/36/fc/3fb019204164b669c87cb62909d7d18b6019cf3a8c3b9768af7f4b605f1e/logbook-1.9.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ff48ba4c0b265f748b23b5ff84f600733d5a82f765db48e900105eecf7972454", size = 605326, upload-time = "2025-11-27T21:11:02.154Z" },
- { url = "https://files.pythonhosted.org/packages/e5/3b/e3987116a71e74ca69d890825fb935d25f9e9155a418135cfd5794eb3e39/logbook-1.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3b6ebbbb6eeb4ec9e181710485a3057349481d7a20d36080dae19056597cb862", size = 340225, upload-time = "2025-11-27T21:11:03.975Z" },
- { url = "https://files.pythonhosted.org/packages/21/88/d8d830865e799a02223fa2bd701b8d089d0d4f2c9d0b2c1edcdbb151440c/logbook-1.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5606f0449605806f559fddad017c67c5f72bacca04f22648fcd2fc8623283b9c", size = 329887, upload-time = "2025-11-27T21:11:05.634Z" },
- { url = "https://files.pythonhosted.org/packages/b8/2e/eb12e9228bdef4981d0eb6c14f5807491442b08337ba53b56118aa2c9606/logbook-1.9.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:90e73b301417b2d43b2821aec12203aee5e2cb1938be8ed04fb485012f009e42", size = 361574, upload-time = "2025-11-27T21:11:07.117Z" },
- { url = "https://files.pythonhosted.org/packages/02/3e/3c38f5407207e994c679e84fc11cb04bc38774d0aa53b8427d4d23b91bda/logbook-1.9.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:21d7993f2679e419199cfcec5673df77f977238ec16b7be3ef1514a4e44c8633", size = 368001, upload-time = "2025-11-27T21:11:08.423Z" },
- { url = "https://files.pythonhosted.org/packages/20/b0/eea01bb474a627b68cc5407ee089aa9a326c124622a92ae8f95831569311/logbook-1.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f0453818719e0d5bda294aa0cf7c717a103c86aff5b4fae3b3d92e318525b191", size = 425936, upload-time = "2025-11-27T21:11:09.751Z" },
- { url = "https://files.pythonhosted.org/packages/88/60/f179733480102ce85fea95607f001be062fb6aafef04c94703e0e5149558/logbook-1.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c6985a5af4379c07fed09f4ed51581d39ab0f3e9d1196077a8c49f99c883599f", size = 442161, upload-time = "2025-11-27T21:11:11.145Z" },
- { url = "https://files.pythonhosted.org/packages/1e/87/422ff192a7a1c992f7888ab508fc996957f297ddc1482c914f2f91e51041/logbook-1.9.2-cp313-cp313-win32.whl", hash = "sha256:7fbebf2a612f5c15309bbba0bef4f7d33af461c05a4d9d19cb376624c7030155", size = 213485, upload-time = "2025-11-27T21:11:12.476Z" },
- { url = "https://files.pythonhosted.org/packages/ae/9c/2fa7df1b676d9179d5f0040de2272d8b9cbdd820c20da9abfd4abcde3cf0/logbook-1.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:5ce7898b35b836da0e22120b965160ac2e797a19f4694ccb9e23e8ad45b1e99a", size = 220311, upload-time = "2025-11-27T21:11:14.179Z" },
- { url = "https://files.pythonhosted.org/packages/bc/77/7253384fbb7229622b5439fcf79fd3147fe4bdfa144fea4e1f77b532d935/logbook-1.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:f6a2d8481babaafacf68732ede6269e52c5a5d2a51fba98808d6dea65abb42f2", size = 215922, upload-time = "2025-11-27T21:11:15.523Z" },
- { url = "https://files.pythonhosted.org/packages/06/0f/fe3d23730a86b63d23b77c8495b9613f93d9bfa347221553c7f9bee2d3b0/logbook-1.9.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ed154dbb7ab1434893f7504ac6ad5c15d271007c612d9ebe4b07c22139243a8c", size = 603168, upload-time = "2025-11-27T21:11:16.941Z" },
- { url = "https://files.pythonhosted.org/packages/81/24/2b8752d157f3c2b6bb22997084c4eade225914b79ba9fd6558c991061366/logbook-1.9.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1334bfa54da7490dfb6fa2305d797fc087e78a5d52fc7e048bf949059710b605", size = 338959, upload-time = "2025-11-27T21:11:18.283Z" },
- { url = "https://files.pythonhosted.org/packages/94/96/52dc9eca5fb7e4703d2e1f405613a90a1e4341bc8123ee8d957d9b4259d8/logbook-1.9.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e37303eac7aeb36097a88727900fd850feba8a1d1307d01dad52376c14c192ec", size = 328772, upload-time = "2025-11-27T21:11:20.001Z" },
- { url = "https://files.pythonhosted.org/packages/76/24/3cc8ef94cdf7b04e169c58b49380d48b64a2b1be4a58f2e5186cdada2887/logbook-1.9.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2331cae6ca65bafae73308e6519f1b2022b26199f7c6c6943bfbec2a674ab26", size = 361215, upload-time = "2025-11-27T21:11:21.311Z" },
- { url = "https://files.pythonhosted.org/packages/dd/e5/0fa31ab9706b0ceb7d37800557f43d17a5e687104fb73c3fe9fe8db8cb1a/logbook-1.9.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5e3677cdc1aca9816ac7d773e08e8e211d91f629ceebb940fd8e9d43a45824e5", size = 366842, upload-time = "2025-11-27T21:11:22.645Z" },
- { url = "https://files.pythonhosted.org/packages/4a/43/496448225ebf671901b0c5400bcb1e2682c2c91710b3170edd08b6047e9f/logbook-1.9.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f97f84cd4a9ca39d5aada3e80aecb60fc2fa1c96fcf46d17462d25b89896dd4", size = 425554, upload-time = "2025-11-27T21:11:24.086Z" },
- { url = "https://files.pythonhosted.org/packages/3e/a9/6004567e3d4fe13094fe6b85df7d85b22c38716cc798bb370fa752f375a7/logbook-1.9.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5764da8ed1d3453a925900c99712b00f7573544c5ce325b29f511f5dc322f306", size = 441237, upload-time = "2025-11-27T21:11:25.435Z" },
- { url = "https://files.pythonhosted.org/packages/a5/d0/97aab3a303a667afa814acdd96b45364c94e1b835bd95f993dabc45b8a8f/logbook-1.9.2-cp314-cp314-win32.whl", hash = "sha256:c6ed6333bfd5370102cda007827bebaea389c1132fe097b44260600dc6817438", size = 216350, upload-time = "2025-11-27T21:11:26.733Z" },
- { url = "https://files.pythonhosted.org/packages/53/41/27381729d389a733f57a149869de62f0d74c82125d799ddb5f2b89eb2094/logbook-1.9.2-cp314-cp314-win_amd64.whl", hash = "sha256:9dbf2ebc09f004eef1bd1466a2bd1008b8088eef799a725e2413753fa613dfa0", size = 224447, upload-time = "2025-11-27T21:11:28.101Z" },
- { url = "https://files.pythonhosted.org/packages/56/a0/28cf82d3e5e1f3f0e7a7c6c6f36ea0e585f0bb75849443821db2af58e2fd/logbook-1.9.2-cp314-cp314-win_arm64.whl", hash = "sha256:1bf202c6d74e985fb0ded5b16918a28722651ff626271a18e9a250ac988ed2df", size = 220143, upload-time = "2025-11-27T21:11:29.442Z" },
- { url = "https://files.pythonhosted.org/packages/e0/62/9a29148bb26fb9461abdf81c896dd86f889e70c290f61cd1b8520c18c739/logbook-1.9.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:584694499a7699df50f1c2940d8613dea30917b9a79abf017b948987adfa96a9", size = 600607, upload-time = "2025-11-27T21:11:30.758Z" },
- { url = "https://files.pythonhosted.org/packages/a9/33/ed175cbc6f2e3e97d3be14b161f0e812bfd0bd1c0b78eb4081f94f64ce51/logbook-1.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d6498d3e96e263fb4bdd076c6439b5c480228baf45f42f0a9f94854ce5d9590f", size = 337999, upload-time = "2025-11-27T21:11:32.483Z" },
- { url = "https://files.pythonhosted.org/packages/d8/0f/b7ee380ed14c0b25149de187b05a360cb9e1871dd9816a35591d2f9aca57/logbook-1.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:31ac52761c90c254fec2a217b7f479ed91fc3adfb10543623d289b9514c55c46", size = 326879, upload-time = "2025-11-27T21:11:34.465Z" },
- { url = "https://files.pythonhosted.org/packages/51/22/a7a2f1fafb8c54eae52245033674bee488194a5a01c37fec628fd26fda22/logbook-1.9.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7ebbc57a32f01a70b19201bf4d5d93f13d785676b67e15cd334f6eee817481d", size = 358294, upload-time = "2025-11-27T21:11:36.314Z" },
- { url = "https://files.pythonhosted.org/packages/3b/26/4d06ab60ae173966a0f2c75ec3652ea660708d81699b4087b0b06fc8efe8/logbook-1.9.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b1f5d1f72400eee806dd3c34c2d2c5f73e3ad9f150c47e06b95fc6952be191d", size = 366145, upload-time = "2025-11-27T21:11:38.853Z" },
- { url = "https://files.pythonhosted.org/packages/8a/5a/62093c73cce824b46c0e591e1115fdcb8303050bdc563aedce6b246f2fe0/logbook-1.9.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9a1a4b6cd9018ec682dc7e198589c396a4315e9c955ee96c04e877385257fd52", size = 423034, upload-time = "2025-11-27T21:11:40.208Z" },
- { url = "https://files.pythonhosted.org/packages/37/3e/0fe12a5f19880060af72f81704642e3efd732595e45d581b945aad3b1e3e/logbook-1.9.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eed159eca40d3480a98d279c685ca6bea84f4f099cf9a51f09b4acdc9ce1c0b4", size = 439911, upload-time = "2025-11-27T21:11:42.353Z" },
- { url = "https://files.pythonhosted.org/packages/70/36/abbb24d01c8a3f5a4bef415bb7bb5412997012437dcdf70861d475614172/logbook-1.9.2-cp314-cp314t-win32.whl", hash = "sha256:2f97ab31a5c54c6428a43764df7c88d672a73cb5a01d84ff4b4e56552f33c97b", size = 214478, upload-time = "2025-11-27T21:11:43.682Z" },
- { url = "https://files.pythonhosted.org/packages/85/d9/26a57c62a3763ffb43338c6d1dbee13b9282e6185ba1092ed471f82e52a8/logbook-1.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:735f7178e370e65f14536cbd7b157d1b1bf8d7ca55b91ff437dc18a8dbdaaae2", size = 223130, upload-time = "2025-11-27T21:11:45.329Z" },
- { url = "https://files.pythonhosted.org/packages/ba/c5/5396f5aea4f39a1299bda2616c9d4a59e54eda2c3d229122de5a61e2db2c/logbook-1.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e1d743512d5bf9fd73047b16af5660cd9f3168dac4f5880a160cacacd3f53550", size = 217383, upload-time = "2025-11-27T21:11:46.601Z" },
+ { url = "https://files.pythonhosted.org/packages/84/0c/ef05117fb10ee4ed233e2918662e248fcdc5a9b253fd1b7ce3cc2ea5ba9f/logbook-1.9.2-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:abaa8e1c99f01476077339a34ed6d39f624749bbef64a22bcc646906c23567e9", size = 608780 },
+ { url = "https://files.pythonhosted.org/packages/69/9f/0b9e6722dfecf62d657c19db0a5f4aa284b78c825badd6f76e5a651eca1c/logbook-1.9.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e244cec50ca4503d7d19e5dc8b93d0da12124b45205be36e1c327da0471eab55", size = 341044 },
+ { url = "https://files.pythonhosted.org/packages/c3/dc/796b97840ccc5440cdf8dbe78b2afebf0b9230c6d4519f5bca70a1c14cb9/logbook-1.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57cd0a668e1cd171f086e963dccc79fd55c160010603304782ed5af15e273032", size = 332264 },
+ { url = "https://files.pythonhosted.org/packages/fa/ec/80de5880169cd458299fceb19e1f9f14d5f8a75b9ac3f09c56789bb403c3/logbook-1.9.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:068da439a77c95863ee521b8e23281a5734ec15b683b371fbe3ae70250393ac5", size = 363382 },
+ { url = "https://files.pythonhosted.org/packages/f2/0e/da2dea269cd32a0866744f2d64ad499d44c722ae161d5a2048f534db4ee1/logbook-1.9.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c1c913500067560697db9b6fe17b158e3f20f06a1b8687920fac63b209ceae1b", size = 369993 },
+ { url = "https://files.pythonhosted.org/packages/44/67/b13dc09c44bb6226b427593a33ceb20d54d0b680defc135ddd9d42dcbd55/logbook-1.9.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7314bffd4c1354e5de6502774b609120aa5e881a820d8ac77431dc96ec18d1d5", size = 428097 },
+ { url = "https://files.pythonhosted.org/packages/5b/73/a501496f806c31c0d62bcc660626b12682b8ad871ca66b5330fc96d9898d/logbook-1.9.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3df4412d0d464f04843ab94aabbe503afb9ff20ec982091370eb5245daac842e", size = 443986 },
+ { url = "https://files.pythonhosted.org/packages/fc/22/6fc98fe475cf7c428be6cdb7400054608178c13b100666097590d37bf6be/logbook-1.9.2-cp311-cp311-win32.whl", hash = "sha256:4944b9052bfb450ccbc1bbada381fe9ba161aa024f02e7f72b6ad157fd0f1aff", size = 215222 },
+ { url = "https://files.pythonhosted.org/packages/fa/df/d5197125a12b55f50c640baf9744fdad4c8c05a77f9a20801b68224f0fdc/logbook-1.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:8dc11dd0ab88453de405620684b530a79110289136b1beb732c8ad3c6ad106f9", size = 221838 },
+ { url = "https://files.pythonhosted.org/packages/d3/4b/0d3f427ff7dcaa69d9cf159a76a1ccea4ebb09cfa602da2fe09850c89cd1/logbook-1.9.2-cp311-cp311-win_arm64.whl", hash = "sha256:d3e5288a963180a336012d12f2cb5143dd565c4b879aa00a2d0fce0350089679", size = 217653 },
+ { url = "https://files.pythonhosted.org/packages/1c/5c/dd429f46497e3f6c15498b8bd57d5b744247b67675e7df649c8b28065e45/logbook-1.9.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1b60d31dad31da9b108a1c656be7560b930d90b1fff1efbd66a926f0562ec535", size = 605842 },
+ { url = "https://files.pythonhosted.org/packages/91/b5/212d37a83690277ac2f8269d4f2b4148909939b656d3ad0f6d3275e8f419/logbook-1.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:057743e915c929c9e910b4fa1424531e255de8647c4f3288a086b7c2bb53a3e0", size = 340453 },
+ { url = "https://files.pythonhosted.org/packages/17/b5/d36bccf730d7f7d127f0baf2807895f85d5a3f911b99bcbc8e18b3e439f9/logbook-1.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:684d9ade464eace999a3c32e912f3e92dcd6958dd74f2a908b2fffcf376d8433", size = 330194 },
+ { url = "https://files.pythonhosted.org/packages/01/35/eaea42346f62c785af2073fbbef115d53aa7422917e2c73ede14b2ab0775/logbook-1.9.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0e085878f9de9c62d9521e1b920253cd725b94ce291252e83843dd95fc16072f", size = 362301 },
+ { url = "https://files.pythonhosted.org/packages/b1/17/8a36848cf9eedf32975cc04000f8b7d8806461ef4702a316cb381e447a2f/logbook-1.9.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:42e258cea92ff422426a76937926675350bd85d9d23899ae21fa70112f041f97", size = 367958 },
+ { url = "https://files.pythonhosted.org/packages/3e/e3/8a5a7136421e7090c94ccb83f625c9f946bba17bdf58f02728f4d1a1f898/logbook-1.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6fd699fbf1aac8ccc06a131cbe0a652ae150e1bd902f1cacaff9b23ee18ab451", size = 426613 },
+ { url = "https://files.pythonhosted.org/packages/e8/0b/ad5c677d9eb8ed3b139c7d7033ccd0d48a5af2e4270a0ed452bd45337d50/logbook-1.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35a95af2e3485e5ddb2a6e26dc61f65ffc2895df54af6426e6d07e28efab0cb5", size = 442118 },
+ { url = "https://files.pythonhosted.org/packages/36/07/ef0498e8805eb9fa6f5a347ce7569302875d96d071b34f0cf52b1671d111/logbook-1.9.2-cp312-cp312-win32.whl", hash = "sha256:cccc1e347e80faab592e751f52297d8740d638202b191c5c1669373f78b31747", size = 213424 },
+ { url = "https://files.pythonhosted.org/packages/86/cf/313171c253e7d69deec15231fbf69132a3b704e7394d6af4fbfcd3568bc2/logbook-1.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:75fb010c56dbe3052924ec14f9a0a64b922839f71592fb7d2907496e253d153a", size = 220437 },
+ { url = "https://files.pythonhosted.org/packages/ec/b0/2685d40edaa3a4d036ebcf5b95206b2772408edd9768e8607bdfec38f3ba/logbook-1.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:3851490b920199573e6b57675da85b7cdfdc785a24d5236917b7c50f3e600f26", size = 215989 },
+ { url = "https://files.pythonhosted.org/packages/36/fc/3fb019204164b669c87cb62909d7d18b6019cf3a8c3b9768af7f4b605f1e/logbook-1.9.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ff48ba4c0b265f748b23b5ff84f600733d5a82f765db48e900105eecf7972454", size = 605326 },
+ { url = "https://files.pythonhosted.org/packages/e5/3b/e3987116a71e74ca69d890825fb935d25f9e9155a418135cfd5794eb3e39/logbook-1.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3b6ebbbb6eeb4ec9e181710485a3057349481d7a20d36080dae19056597cb862", size = 340225 },
+ { url = "https://files.pythonhosted.org/packages/21/88/d8d830865e799a02223fa2bd701b8d089d0d4f2c9d0b2c1edcdbb151440c/logbook-1.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5606f0449605806f559fddad017c67c5f72bacca04f22648fcd2fc8623283b9c", size = 329887 },
+ { url = "https://files.pythonhosted.org/packages/b8/2e/eb12e9228bdef4981d0eb6c14f5807491442b08337ba53b56118aa2c9606/logbook-1.9.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:90e73b301417b2d43b2821aec12203aee5e2cb1938be8ed04fb485012f009e42", size = 361574 },
+ { url = "https://files.pythonhosted.org/packages/02/3e/3c38f5407207e994c679e84fc11cb04bc38774d0aa53b8427d4d23b91bda/logbook-1.9.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:21d7993f2679e419199cfcec5673df77f977238ec16b7be3ef1514a4e44c8633", size = 368001 },
+ { url = "https://files.pythonhosted.org/packages/20/b0/eea01bb474a627b68cc5407ee089aa9a326c124622a92ae8f95831569311/logbook-1.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f0453818719e0d5bda294aa0cf7c717a103c86aff5b4fae3b3d92e318525b191", size = 425936 },
+ { url = "https://files.pythonhosted.org/packages/88/60/f179733480102ce85fea95607f001be062fb6aafef04c94703e0e5149558/logbook-1.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c6985a5af4379c07fed09f4ed51581d39ab0f3e9d1196077a8c49f99c883599f", size = 442161 },
+ { url = "https://files.pythonhosted.org/packages/1e/87/422ff192a7a1c992f7888ab508fc996957f297ddc1482c914f2f91e51041/logbook-1.9.2-cp313-cp313-win32.whl", hash = "sha256:7fbebf2a612f5c15309bbba0bef4f7d33af461c05a4d9d19cb376624c7030155", size = 213485 },
+ { url = "https://files.pythonhosted.org/packages/ae/9c/2fa7df1b676d9179d5f0040de2272d8b9cbdd820c20da9abfd4abcde3cf0/logbook-1.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:5ce7898b35b836da0e22120b965160ac2e797a19f4694ccb9e23e8ad45b1e99a", size = 220311 },
+ { url = "https://files.pythonhosted.org/packages/bc/77/7253384fbb7229622b5439fcf79fd3147fe4bdfa144fea4e1f77b532d935/logbook-1.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:f6a2d8481babaafacf68732ede6269e52c5a5d2a51fba98808d6dea65abb42f2", size = 215922 },
+ { url = "https://files.pythonhosted.org/packages/06/0f/fe3d23730a86b63d23b77c8495b9613f93d9bfa347221553c7f9bee2d3b0/logbook-1.9.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ed154dbb7ab1434893f7504ac6ad5c15d271007c612d9ebe4b07c22139243a8c", size = 603168 },
+ { url = "https://files.pythonhosted.org/packages/81/24/2b8752d157f3c2b6bb22997084c4eade225914b79ba9fd6558c991061366/logbook-1.9.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1334bfa54da7490dfb6fa2305d797fc087e78a5d52fc7e048bf949059710b605", size = 338959 },
+ { url = "https://files.pythonhosted.org/packages/94/96/52dc9eca5fb7e4703d2e1f405613a90a1e4341bc8123ee8d957d9b4259d8/logbook-1.9.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e37303eac7aeb36097a88727900fd850feba8a1d1307d01dad52376c14c192ec", size = 328772 },
+ { url = "https://files.pythonhosted.org/packages/76/24/3cc8ef94cdf7b04e169c58b49380d48b64a2b1be4a58f2e5186cdada2887/logbook-1.9.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2331cae6ca65bafae73308e6519f1b2022b26199f7c6c6943bfbec2a674ab26", size = 361215 },
+ { url = "https://files.pythonhosted.org/packages/dd/e5/0fa31ab9706b0ceb7d37800557f43d17a5e687104fb73c3fe9fe8db8cb1a/logbook-1.9.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5e3677cdc1aca9816ac7d773e08e8e211d91f629ceebb940fd8e9d43a45824e5", size = 366842 },
+ { url = "https://files.pythonhosted.org/packages/4a/43/496448225ebf671901b0c5400bcb1e2682c2c91710b3170edd08b6047e9f/logbook-1.9.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f97f84cd4a9ca39d5aada3e80aecb60fc2fa1c96fcf46d17462d25b89896dd4", size = 425554 },
+ { url = "https://files.pythonhosted.org/packages/3e/a9/6004567e3d4fe13094fe6b85df7d85b22c38716cc798bb370fa752f375a7/logbook-1.9.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5764da8ed1d3453a925900c99712b00f7573544c5ce325b29f511f5dc322f306", size = 441237 },
+ { url = "https://files.pythonhosted.org/packages/a5/d0/97aab3a303a667afa814acdd96b45364c94e1b835bd95f993dabc45b8a8f/logbook-1.9.2-cp314-cp314-win32.whl", hash = "sha256:c6ed6333bfd5370102cda007827bebaea389c1132fe097b44260600dc6817438", size = 216350 },
+ { url = "https://files.pythonhosted.org/packages/53/41/27381729d389a733f57a149869de62f0d74c82125d799ddb5f2b89eb2094/logbook-1.9.2-cp314-cp314-win_amd64.whl", hash = "sha256:9dbf2ebc09f004eef1bd1466a2bd1008b8088eef799a725e2413753fa613dfa0", size = 224447 },
+ { url = "https://files.pythonhosted.org/packages/56/a0/28cf82d3e5e1f3f0e7a7c6c6f36ea0e585f0bb75849443821db2af58e2fd/logbook-1.9.2-cp314-cp314-win_arm64.whl", hash = "sha256:1bf202c6d74e985fb0ded5b16918a28722651ff626271a18e9a250ac988ed2df", size = 220143 },
+ { url = "https://files.pythonhosted.org/packages/e0/62/9a29148bb26fb9461abdf81c896dd86f889e70c290f61cd1b8520c18c739/logbook-1.9.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:584694499a7699df50f1c2940d8613dea30917b9a79abf017b948987adfa96a9", size = 600607 },
+ { url = "https://files.pythonhosted.org/packages/a9/33/ed175cbc6f2e3e97d3be14b161f0e812bfd0bd1c0b78eb4081f94f64ce51/logbook-1.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d6498d3e96e263fb4bdd076c6439b5c480228baf45f42f0a9f94854ce5d9590f", size = 337999 },
+ { url = "https://files.pythonhosted.org/packages/d8/0f/b7ee380ed14c0b25149de187b05a360cb9e1871dd9816a35591d2f9aca57/logbook-1.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:31ac52761c90c254fec2a217b7f479ed91fc3adfb10543623d289b9514c55c46", size = 326879 },
+ { url = "https://files.pythonhosted.org/packages/51/22/a7a2f1fafb8c54eae52245033674bee488194a5a01c37fec628fd26fda22/logbook-1.9.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7ebbc57a32f01a70b19201bf4d5d93f13d785676b67e15cd334f6eee817481d", size = 358294 },
+ { url = "https://files.pythonhosted.org/packages/3b/26/4d06ab60ae173966a0f2c75ec3652ea660708d81699b4087b0b06fc8efe8/logbook-1.9.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b1f5d1f72400eee806dd3c34c2d2c5f73e3ad9f150c47e06b95fc6952be191d", size = 366145 },
+ { url = "https://files.pythonhosted.org/packages/8a/5a/62093c73cce824b46c0e591e1115fdcb8303050bdc563aedce6b246f2fe0/logbook-1.9.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9a1a4b6cd9018ec682dc7e198589c396a4315e9c955ee96c04e877385257fd52", size = 423034 },
+ { url = "https://files.pythonhosted.org/packages/37/3e/0fe12a5f19880060af72f81704642e3efd732595e45d581b945aad3b1e3e/logbook-1.9.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eed159eca40d3480a98d279c685ca6bea84f4f099cf9a51f09b4acdc9ce1c0b4", size = 439911 },
+ { url = "https://files.pythonhosted.org/packages/70/36/abbb24d01c8a3f5a4bef415bb7bb5412997012437dcdf70861d475614172/logbook-1.9.2-cp314-cp314t-win32.whl", hash = "sha256:2f97ab31a5c54c6428a43764df7c88d672a73cb5a01d84ff4b4e56552f33c97b", size = 214478 },
+ { url = "https://files.pythonhosted.org/packages/85/d9/26a57c62a3763ffb43338c6d1dbee13b9282e6185ba1092ed471f82e52a8/logbook-1.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:735f7178e370e65f14536cbd7b157d1b1bf8d7ca55b91ff437dc18a8dbdaaae2", size = 223130 },
+ { url = "https://files.pythonhosted.org/packages/ba/c5/5396f5aea4f39a1299bda2616c9d4a59e54eda2c3d229122de5a61e2db2c/logbook-1.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e1d743512d5bf9fd73047b16af5660cd9f3168dac4f5880a160cacacd3f53550", size = 217383 },
]
[[package]]
name = "lxml"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" },
- { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" },
- { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" },
- { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" },
- { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" },
- { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" },
- { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" },
- { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" },
- { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" },
- { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" },
- { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" },
- { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" },
- { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" },
- { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" },
- { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" },
- { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" },
- { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
- { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
- { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
- { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
- { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
- { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
- { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
- { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
- { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
- { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
- { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
- { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
- { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
- { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
- { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
- { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
- { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
- { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
- { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
- { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
- { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
- { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
- { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
- { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
- { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
- { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
- { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
- { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
- { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
- { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
- { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
- { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
- { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
- { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
- { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
- { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
- { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
- { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
- { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
- { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
- { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
- { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
- { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
- { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
- { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
- { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
- { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
- { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
- { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
- { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
- { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
- { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
- { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
- { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
- { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
- { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
- { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
- { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
- { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
- { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
- { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
- { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
- { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
- { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
- { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
- { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
- { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
- { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
- { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
- { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
- { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
- { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
- { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" },
- { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" },
- { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" },
- { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" },
- { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" },
- { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" },
+ { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461 },
+ { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375 },
+ { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654 },
+ { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921 },
+ { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456 },
+ { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776 },
+ { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945 },
+ { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237 },
+ { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904 },
+ { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225 },
+ { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721 },
+ { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549 },
+ { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877 },
+ { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072 },
+ { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469 },
+ { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640 },
+ { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821 },
+ { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252 },
+ { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746 },
+ { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723 },
+ { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557 },
+ { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036 },
+ { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367 },
+ { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171 },
+ { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874 },
+ { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492 },
+ { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232 },
+ { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023 },
+ { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773 },
+ { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088 },
+ { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995 },
+ { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382 },
+ { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255 },
+ { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610 },
+ { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780 },
+ { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006 },
+ { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139 },
+ { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329 },
+ { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564 },
+ { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467 },
+ { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304 },
+ { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607 },
+ { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168 },
+ { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487 },
+ { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231 },
+ { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450 },
+ { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874 },
+ { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987 },
+ { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276 },
+ { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903 },
+ { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869 },
+ { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490 },
+ { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146 },
+ { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866 },
+ { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022 },
+ { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695 },
+ { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642 },
+ { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338 },
+ { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528 },
+ { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730 },
+ { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530 },
+ { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670 },
+ { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485 },
+ { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635 },
+ { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681 },
+ { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229 },
+ { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191 },
+ { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202 },
+ { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497 },
+ { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991 },
+ { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545 },
+ { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736 },
+ { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291 },
+ { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822 },
+ { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923 },
+ { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843 },
+ { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515 },
+ { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511 },
+ { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206 },
+ { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404 },
+ { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769 },
+ { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936 },
+ { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296 },
+ { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598 },
+ { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845 },
+ { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345 },
+ { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350 },
+ { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223 },
+ { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127 },
+ { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769 },
+ { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163 },
+ { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945 },
+ { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664 },
+ { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989 },
]
[[package]]
@@ -2656,18 +2645,18 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521 },
]
[[package]]
name = "markdown"
version = "3.10.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" },
+ { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684 },
]
[[package]]
@@ -2677,9 +2666,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 },
]
[package.optional-dependencies]
@@ -2691,74 +2680,74 @@ linkify = [
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
- { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
- { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
- { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
- { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
- { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
- { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
- { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
- { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
- { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
- { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
- { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
- { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
- { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
- { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
- { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
- { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
- { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
- { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
- { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
- { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
- { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
- { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
- { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
- { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
- { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
- { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
- { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
- { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
- { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
- { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
- { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
- { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
- { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
- { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
- { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
- { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
- { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
- { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
- { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
- { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
- { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
- { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
- { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
- { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
- { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
- { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
- { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
- { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
- { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
- { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
- { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
- { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
- { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
- { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
- { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
- { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
- { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
- { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 },
]
[[package]]
@@ -2775,9 +2764,9 @@ dependencies = [
{ name = "pycryptodome" },
{ name = "unpaddedbase64" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/33/50/c20129fd6f0e1aad3510feefd3229427fc8163a111f3911ed834e414116b/matrix_nio-0.25.2.tar.gz", hash = "sha256:8ef8180c374e12368e5c83a692abfb3bab8d71efcd17c5560b5c40c9b6f2f600", size = 155480, upload-time = "2024-10-04T07:51:41.62Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/50/c20129fd6f0e1aad3510feefd3229427fc8163a111f3911ed834e414116b/matrix_nio-0.25.2.tar.gz", hash = "sha256:8ef8180c374e12368e5c83a692abfb3bab8d71efcd17c5560b5c40c9b6f2f600", size = 155480 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7b/0f/8b958d46e23ed4f69d2cffd63b46bb097a1155524e2e7f5c4279c8691c4a/matrix_nio-0.25.2-py3-none-any.whl", hash = "sha256:9c2880004b0e475db874456c0f79b7dd2b6285073a7663bcaca29e0754a67495", size = 181982, upload-time = "2024-10-04T07:51:39.451Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/0f/8b958d46e23ed4f69d2cffd63b46bb097a1155524e2e7f5c4279c8691c4a/matrix_nio-0.25.2-py3-none-any.whl", hash = "sha256:9c2880004b0e475db874456c0f79b7dd2b6285073a7663bcaca29e0754a67495", size = 181982 },
]
[[package]]
@@ -2800,9 +2789,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615 },
]
[[package]]
@@ -2812,123 +2801,123 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "mistletoe"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/11/96/ea46a376a7c4cd56955ecdfff0ea68de43996a4e6d1aee4599729453bd11/mistletoe-1.4.0.tar.gz", hash = "sha256:1630f906e5e4bbe66fdeb4d29d277e2ea515d642bb18a9b49b136361a9818c9d", size = 107203, upload-time = "2024-07-14T10:17:35.212Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/11/96/ea46a376a7c4cd56955ecdfff0ea68de43996a4e6d1aee4599729453bd11/mistletoe-1.4.0.tar.gz", hash = "sha256:1630f906e5e4bbe66fdeb4d29d277e2ea515d642bb18a9b49b136361a9818c9d", size = 107203 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/0f/b5e545f0c7962be90366af3418989b12cf441d9da1e5d89d88f2f3e5cf8f/mistletoe-1.4.0-py3-none-any.whl", hash = "sha256:44a477803861de1237ba22e375c6b617690a31d2902b47279d1f8f7ed498a794", size = 51304, upload-time = "2024-07-14T10:17:33.243Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/0f/b5e545f0c7962be90366af3418989b12cf441d9da1e5d89d88f2f3e5cf8f/mistletoe-1.4.0-py3-none-any.whl", hash = "sha256:44a477803861de1237ba22e375c6b617690a31d2902b47279d1f8f7ed498a794", size = 51304 },
]
[[package]]
name = "mmh3"
version = "5.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582, upload-time = "2025-07-29T07:43:48.49Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f7/87/399567b3796e134352e11a8b973cd470c06b2ecfad5468fe580833be442b/mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1", size = 56107, upload-time = "2025-07-29T07:41:57.07Z" },
- { url = "https://files.pythonhosted.org/packages/c3/09/830af30adf8678955b247d97d3d9543dd2fd95684f3cd41c0cd9d291da9f/mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051", size = 40635, upload-time = "2025-07-29T07:41:57.903Z" },
- { url = "https://files.pythonhosted.org/packages/07/14/eaba79eef55b40d653321765ac5e8f6c9ac38780b8a7c2a2f8df8ee0fb72/mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10", size = 40078, upload-time = "2025-07-29T07:41:58.772Z" },
- { url = "https://files.pythonhosted.org/packages/bb/26/83a0f852e763f81b2265d446b13ed6d49ee49e1fc0c47b9655977e6f3d81/mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c", size = 97262, upload-time = "2025-07-29T07:41:59.678Z" },
- { url = "https://files.pythonhosted.org/packages/00/7d/b7133b10d12239aeaebf6878d7eaf0bf7d3738c44b4aba3c564588f6d802/mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762", size = 103118, upload-time = "2025-07-29T07:42:01.197Z" },
- { url = "https://files.pythonhosted.org/packages/7b/3e/62f0b5dce2e22fd5b7d092aba285abd7959ea2b17148641e029f2eab1ffa/mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4", size = 106072, upload-time = "2025-07-29T07:42:02.601Z" },
- { url = "https://files.pythonhosted.org/packages/66/84/ea88bb816edfe65052c757a1c3408d65c4201ddbd769d4a287b0f1a628b2/mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363", size = 112925, upload-time = "2025-07-29T07:42:03.632Z" },
- { url = "https://files.pythonhosted.org/packages/2e/13/c9b1c022807db575fe4db806f442d5b5784547e2e82cff36133e58ea31c7/mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8", size = 120583, upload-time = "2025-07-29T07:42:04.991Z" },
- { url = "https://files.pythonhosted.org/packages/8a/5f/0e2dfe1a38f6a78788b7eb2b23432cee24623aeabbc907fed07fc17d6935/mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed", size = 99127, upload-time = "2025-07-29T07:42:05.929Z" },
- { url = "https://files.pythonhosted.org/packages/77/27/aefb7d663b67e6a0c4d61a513c83e39ba2237e8e4557fa7122a742a23de5/mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646", size = 98544, upload-time = "2025-07-29T07:42:06.87Z" },
- { url = "https://files.pythonhosted.org/packages/ab/97/a21cc9b1a7c6e92205a1b5fa030cdf62277d177570c06a239eca7bd6dd32/mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b", size = 106262, upload-time = "2025-07-29T07:42:07.804Z" },
- { url = "https://files.pythonhosted.org/packages/43/18/db19ae82ea63c8922a880e1498a75342311f8aa0c581c4dd07711473b5f7/mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779", size = 109824, upload-time = "2025-07-29T07:42:08.735Z" },
- { url = "https://files.pythonhosted.org/packages/9f/f5/41dcf0d1969125fc6f61d8618b107c79130b5af50b18a4651210ea52ab40/mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2", size = 97255, upload-time = "2025-07-29T07:42:09.706Z" },
- { url = "https://files.pythonhosted.org/packages/32/b3/cce9eaa0efac1f0e735bb178ef9d1d2887b4927fe0ec16609d5acd492dda/mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28", size = 40779, upload-time = "2025-07-29T07:42:10.546Z" },
- { url = "https://files.pythonhosted.org/packages/7c/e9/3fa0290122e6d5a7041b50ae500b8a9f4932478a51e48f209a3879fe0b9b/mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee", size = 41549, upload-time = "2025-07-29T07:42:11.399Z" },
- { url = "https://files.pythonhosted.org/packages/3a/54/c277475b4102588e6f06b2e9095ee758dfe31a149312cdbf62d39a9f5c30/mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9", size = 39336, upload-time = "2025-07-29T07:42:12.209Z" },
- { url = "https://files.pythonhosted.org/packages/bf/6a/d5aa7edb5c08e0bd24286c7d08341a0446f9a2fbbb97d96a8a6dd81935ee/mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be", size = 56141, upload-time = "2025-07-29T07:42:13.456Z" },
- { url = "https://files.pythonhosted.org/packages/08/49/131d0fae6447bc4a7299ebdb1a6fb9d08c9f8dcf97d75ea93e8152ddf7ab/mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd", size = 40681, upload-time = "2025-07-29T07:42:14.306Z" },
- { url = "https://files.pythonhosted.org/packages/8f/6f/9221445a6bcc962b7f5ff3ba18ad55bba624bacdc7aa3fc0a518db7da8ec/mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96", size = 40062, upload-time = "2025-07-29T07:42:15.08Z" },
- { url = "https://files.pythonhosted.org/packages/1e/d4/6bb2d0fef81401e0bb4c297d1eb568b767de4ce6fc00890bc14d7b51ecc4/mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094", size = 97333, upload-time = "2025-07-29T07:42:16.436Z" },
- { url = "https://files.pythonhosted.org/packages/44/e0/ccf0daff8134efbb4fbc10a945ab53302e358c4b016ada9bf97a6bdd50c1/mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037", size = 103310, upload-time = "2025-07-29T07:42:17.796Z" },
- { url = "https://files.pythonhosted.org/packages/02/63/1965cb08a46533faca0e420e06aff8bbaf9690a6f0ac6ae6e5b2e4544687/mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773", size = 106178, upload-time = "2025-07-29T07:42:19.281Z" },
- { url = "https://files.pythonhosted.org/packages/c2/41/c883ad8e2c234013f27f92061200afc11554ea55edd1bcf5e1accd803a85/mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5", size = 113035, upload-time = "2025-07-29T07:42:20.356Z" },
- { url = "https://files.pythonhosted.org/packages/df/b5/1ccade8b1fa625d634a18bab7bf08a87457e09d5ec8cf83ca07cbea9d400/mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50", size = 120784, upload-time = "2025-07-29T07:42:21.377Z" },
- { url = "https://files.pythonhosted.org/packages/77/1c/919d9171fcbdcdab242e06394464ccf546f7d0f3b31e0d1e3a630398782e/mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765", size = 99137, upload-time = "2025-07-29T07:42:22.344Z" },
- { url = "https://files.pythonhosted.org/packages/66/8a/1eebef5bd6633d36281d9fc83cf2e9ba1ba0e1a77dff92aacab83001cee4/mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43", size = 98664, upload-time = "2025-07-29T07:42:23.269Z" },
- { url = "https://files.pythonhosted.org/packages/13/41/a5d981563e2ee682b21fb65e29cc0f517a6734a02b581359edd67f9d0360/mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4", size = 106459, upload-time = "2025-07-29T07:42:24.238Z" },
- { url = "https://files.pythonhosted.org/packages/24/31/342494cd6ab792d81e083680875a2c50fa0c5df475ebf0b67784f13e4647/mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3", size = 110038, upload-time = "2025-07-29T07:42:25.629Z" },
- { url = "https://files.pythonhosted.org/packages/28/44/efda282170a46bb4f19c3e2b90536513b1d821c414c28469a227ca5a1789/mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c", size = 97545, upload-time = "2025-07-29T07:42:27.04Z" },
- { url = "https://files.pythonhosted.org/packages/68/8f/534ae319c6e05d714f437e7206f78c17e66daca88164dff70286b0e8ea0c/mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49", size = 40805, upload-time = "2025-07-29T07:42:28.032Z" },
- { url = "https://files.pythonhosted.org/packages/b8/f6/f6abdcfefcedab3c964868048cfe472764ed358c2bf6819a70dd4ed4ed3a/mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3", size = 41597, upload-time = "2025-07-29T07:42:28.894Z" },
- { url = "https://files.pythonhosted.org/packages/15/fd/f7420e8cbce45c259c770cac5718badf907b302d3a99ec587ba5ce030237/mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0", size = 39350, upload-time = "2025-07-29T07:42:29.794Z" },
- { url = "https://files.pythonhosted.org/packages/d8/fa/27f6ab93995ef6ad9f940e96593c5dd24744d61a7389532b0fec03745607/mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065", size = 40874, upload-time = "2025-07-29T07:42:30.662Z" },
- { url = "https://files.pythonhosted.org/packages/11/9c/03d13bcb6a03438bc8cac3d2e50f80908d159b31a4367c2e1a7a077ded32/mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de", size = 42012, upload-time = "2025-07-29T07:42:31.539Z" },
- { url = "https://files.pythonhosted.org/packages/4e/78/0865d9765408a7d504f1789944e678f74e0888b96a766d578cb80b040999/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044", size = 39197, upload-time = "2025-07-29T07:42:32.374Z" },
- { url = "https://files.pythonhosted.org/packages/3e/12/76c3207bd186f98b908b6706c2317abb73756d23a4e68ea2bc94825b9015/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73", size = 39840, upload-time = "2025-07-29T07:42:33.227Z" },
- { url = "https://files.pythonhosted.org/packages/5d/0d/574b6cce5555c9f2b31ea189ad44986755eb14e8862db28c8b834b8b64dc/mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504", size = 40644, upload-time = "2025-07-29T07:42:34.099Z" },
- { url = "https://files.pythonhosted.org/packages/52/82/3731f8640b79c46707f53ed72034a58baad400be908c87b0088f1f89f986/mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b", size = 56153, upload-time = "2025-07-29T07:42:35.031Z" },
- { url = "https://files.pythonhosted.org/packages/4f/34/e02dca1d4727fd9fdeaff9e2ad6983e1552804ce1d92cc796e5b052159bb/mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05", size = 40684, upload-time = "2025-07-29T07:42:35.914Z" },
- { url = "https://files.pythonhosted.org/packages/8f/36/3dee40767356e104967e6ed6d102ba47b0b1ce2a89432239b95a94de1b89/mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814", size = 40057, upload-time = "2025-07-29T07:42:36.755Z" },
- { url = "https://files.pythonhosted.org/packages/31/58/228c402fccf76eb39a0a01b8fc470fecf21965584e66453b477050ee0e99/mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093", size = 97344, upload-time = "2025-07-29T07:42:37.675Z" },
- { url = "https://files.pythonhosted.org/packages/34/82/fc5ce89006389a6426ef28e326fc065b0fbaaed230373b62d14c889f47ea/mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54", size = 103325, upload-time = "2025-07-29T07:42:38.591Z" },
- { url = "https://files.pythonhosted.org/packages/09/8c/261e85777c6aee1ebd53f2f17e210e7481d5b0846cd0b4a5c45f1e3761b8/mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a", size = 106240, upload-time = "2025-07-29T07:42:39.563Z" },
- { url = "https://files.pythonhosted.org/packages/70/73/2f76b3ad8a3d431824e9934403df36c0ddacc7831acf82114bce3c4309c8/mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908", size = 113060, upload-time = "2025-07-29T07:42:40.585Z" },
- { url = "https://files.pythonhosted.org/packages/9f/b9/7ea61a34e90e50a79a9d87aa1c0b8139a7eaf4125782b34b7d7383472633/mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5", size = 120781, upload-time = "2025-07-29T07:42:41.618Z" },
- { url = "https://files.pythonhosted.org/packages/0f/5b/ae1a717db98c7894a37aeedbd94b3f99e6472a836488f36b6849d003485b/mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a", size = 99174, upload-time = "2025-07-29T07:42:42.587Z" },
- { url = "https://files.pythonhosted.org/packages/e3/de/000cce1d799fceebb6d4487ae29175dd8e81b48e314cba7b4da90bcf55d7/mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266", size = 98734, upload-time = "2025-07-29T07:42:43.996Z" },
- { url = "https://files.pythonhosted.org/packages/79/19/0dc364391a792b72fbb22becfdeacc5add85cc043cd16986e82152141883/mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5", size = 106493, upload-time = "2025-07-29T07:42:45.07Z" },
- { url = "https://files.pythonhosted.org/packages/3c/b1/bc8c28e4d6e807bbb051fefe78e1156d7f104b89948742ad310612ce240d/mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9", size = 110089, upload-time = "2025-07-29T07:42:46.122Z" },
- { url = "https://files.pythonhosted.org/packages/3b/a2/d20f3f5c95e9c511806686c70d0a15479cc3941c5f322061697af1c1ff70/mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290", size = 97571, upload-time = "2025-07-29T07:42:47.18Z" },
- { url = "https://files.pythonhosted.org/packages/7b/23/665296fce4f33488deec39a750ffd245cfc07aafb0e3ef37835f91775d14/mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051", size = 40806, upload-time = "2025-07-29T07:42:48.166Z" },
- { url = "https://files.pythonhosted.org/packages/59/b0/92e7103f3b20646e255b699e2d0327ce53a3f250e44367a99dc8be0b7c7a/mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081", size = 41600, upload-time = "2025-07-29T07:42:49.371Z" },
- { url = "https://files.pythonhosted.org/packages/99/22/0b2bd679a84574647de538c5b07ccaa435dbccc37815067fe15b90fe8dad/mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b", size = 39349, upload-time = "2025-07-29T07:42:50.268Z" },
- { url = "https://files.pythonhosted.org/packages/f7/ca/a20db059a8a47048aaf550da14a145b56e9c7386fb8280d3ce2962dcebf7/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078", size = 39209, upload-time = "2025-07-29T07:42:51.559Z" },
- { url = "https://files.pythonhosted.org/packages/98/dd/e5094799d55c7482d814b979a0fd608027d0af1b274bfb4c3ea3e950bfd5/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501", size = 39843, upload-time = "2025-07-29T07:42:52.536Z" },
- { url = "https://files.pythonhosted.org/packages/f4/6b/7844d7f832c85400e7cc89a1348e4e1fdd38c5a38415bb5726bbb8fcdb6c/mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b", size = 40648, upload-time = "2025-07-29T07:42:53.392Z" },
- { url = "https://files.pythonhosted.org/packages/1f/bf/71f791f48a21ff3190ba5225807cbe4f7223360e96862c376e6e3fb7efa7/mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770", size = 56164, upload-time = "2025-07-29T07:42:54.267Z" },
- { url = "https://files.pythonhosted.org/packages/70/1f/f87e3d34d83032b4f3f0f528c6d95a98290fcacf019da61343a49dccfd51/mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110", size = 40692, upload-time = "2025-07-29T07:42:55.234Z" },
- { url = "https://files.pythonhosted.org/packages/a6/e2/db849eaed07117086f3452feca8c839d30d38b830ac59fe1ce65af8be5ad/mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647", size = 40068, upload-time = "2025-07-29T07:42:56.158Z" },
- { url = "https://files.pythonhosted.org/packages/df/6b/209af927207af77425b044e32f77f49105a0b05d82ff88af6971d8da4e19/mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63", size = 97367, upload-time = "2025-07-29T07:42:57.037Z" },
- { url = "https://files.pythonhosted.org/packages/ca/e0/78adf4104c425606a9ce33fb351f790c76a6c2314969c4a517d1ffc92196/mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12", size = 103306, upload-time = "2025-07-29T07:42:58.522Z" },
- { url = "https://files.pythonhosted.org/packages/a3/79/c2b89f91b962658b890104745b1b6c9ce38d50a889f000b469b91eeb1b9e/mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22", size = 106312, upload-time = "2025-07-29T07:42:59.552Z" },
- { url = "https://files.pythonhosted.org/packages/4b/14/659d4095528b1a209be90934778c5ffe312177d51e365ddcbca2cac2ec7c/mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5", size = 113135, upload-time = "2025-07-29T07:43:00.745Z" },
- { url = "https://files.pythonhosted.org/packages/8d/6f/cd7734a779389a8a467b5c89a48ff476d6f2576e78216a37551a97e9e42a/mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07", size = 120775, upload-time = "2025-07-29T07:43:02.124Z" },
- { url = "https://files.pythonhosted.org/packages/1d/ca/8256e3b96944408940de3f9291d7e38a283b5761fe9614d4808fcf27bd62/mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935", size = 99178, upload-time = "2025-07-29T07:43:03.182Z" },
- { url = "https://files.pythonhosted.org/packages/8a/32/39e2b3cf06b6e2eb042c984dab8680841ac2a0d3ca6e0bea30db1f27b565/mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7", size = 98738, upload-time = "2025-07-29T07:43:04.207Z" },
- { url = "https://files.pythonhosted.org/packages/61/d3/7bbc8e0e8cf65ebbe1b893ffa0467b7ecd1bd07c3bbf6c9db4308ada22ec/mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5", size = 106510, upload-time = "2025-07-29T07:43:05.656Z" },
- { url = "https://files.pythonhosted.org/packages/10/99/b97e53724b52374e2f3859046f0eb2425192da356cb19784d64bc17bb1cf/mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384", size = 110053, upload-time = "2025-07-29T07:43:07.204Z" },
- { url = "https://files.pythonhosted.org/packages/ac/62/3688c7d975ed195155671df68788c83fed6f7909b6ec4951724c6860cb97/mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e", size = 97546, upload-time = "2025-07-29T07:43:08.226Z" },
- { url = "https://files.pythonhosted.org/packages/ca/3b/c6153250f03f71a8b7634cded82939546cdfba02e32f124ff51d52c6f991/mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0", size = 41422, upload-time = "2025-07-29T07:43:09.216Z" },
- { url = "https://files.pythonhosted.org/packages/74/01/a27d98bab083a435c4c07e9d1d720d4c8a578bf4c270bae373760b1022be/mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b", size = 42135, upload-time = "2025-07-29T07:43:10.183Z" },
- { url = "https://files.pythonhosted.org/packages/cb/c9/dbba5507e95429b8b380e2ba091eff5c20a70a59560934dff0ad8392b8c8/mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115", size = 39879, upload-time = "2025-07-29T07:43:11.106Z" },
- { url = "https://files.pythonhosted.org/packages/b5/d1/c8c0ef839c17258b9de41b84f663574fabcf8ac2007b7416575e0f65ff6e/mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932", size = 57696, upload-time = "2025-07-29T07:43:11.989Z" },
- { url = "https://files.pythonhosted.org/packages/2f/55/95e2b9ff201e89f9fe37036037ab61a6c941942b25cdb7b6a9df9b931993/mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c", size = 41421, upload-time = "2025-07-29T07:43:13.269Z" },
- { url = "https://files.pythonhosted.org/packages/77/79/9be23ad0b7001a4b22752e7693be232428ecc0a35068a4ff5c2f14ef8b20/mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be", size = 40853, upload-time = "2025-07-29T07:43:14.888Z" },
- { url = "https://files.pythonhosted.org/packages/ac/1b/96b32058eda1c1dee8264900c37c359a7325c1f11f5ff14fd2be8e24eff9/mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb", size = 109694, upload-time = "2025-07-29T07:43:15.816Z" },
- { url = "https://files.pythonhosted.org/packages/8d/6f/a2ae44cd7dad697b6dea48390cbc977b1e5ca58fda09628cbcb2275af064/mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65", size = 117438, upload-time = "2025-07-29T07:43:16.865Z" },
- { url = "https://files.pythonhosted.org/packages/a0/08/bfb75451c83f05224a28afeaf3950c7b793c0b71440d571f8e819cfb149a/mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991", size = 120409, upload-time = "2025-07-29T07:43:18.207Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ea/8b118b69b2ff8df568f742387d1a159bc654a0f78741b31437dd047ea28e/mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645", size = 125909, upload-time = "2025-07-29T07:43:19.39Z" },
- { url = "https://files.pythonhosted.org/packages/3e/11/168cc0b6a30650032e351a3b89b8a47382da541993a03af91e1ba2501234/mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3", size = 135331, upload-time = "2025-07-29T07:43:20.435Z" },
- { url = "https://files.pythonhosted.org/packages/31/05/e3a9849b1c18a7934c64e831492c99e67daebe84a8c2f2c39a7096a830e3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279", size = 110085, upload-time = "2025-07-29T07:43:21.92Z" },
- { url = "https://files.pythonhosted.org/packages/d9/d5/a96bcc306e3404601418b2a9a370baec92af84204528ba659fdfe34c242f/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513", size = 111195, upload-time = "2025-07-29T07:43:23.066Z" },
- { url = "https://files.pythonhosted.org/packages/af/29/0fd49801fec5bff37198684e0849b58e0dab3a2a68382a357cfffb0fafc3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db", size = 116919, upload-time = "2025-07-29T07:43:24.178Z" },
- { url = "https://files.pythonhosted.org/packages/2d/04/4f3c32b0a2ed762edca45d8b46568fc3668e34f00fb1e0a3b5451ec1281c/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667", size = 123160, upload-time = "2025-07-29T07:43:25.26Z" },
- { url = "https://files.pythonhosted.org/packages/91/76/3d29eaa38821730633d6a240d36fa8ad2807e9dfd432c12e1a472ed211eb/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5", size = 110206, upload-time = "2025-07-29T07:43:26.699Z" },
- { url = "https://files.pythonhosted.org/packages/44/1c/ccf35892684d3a408202e296e56843743e0b4fb1629e59432ea88cdb3909/mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7", size = 41970, upload-time = "2025-07-29T07:43:27.666Z" },
- { url = "https://files.pythonhosted.org/packages/75/b2/b9e4f1e5adb5e21eb104588fcee2cd1eaa8308255173481427d5ecc4284e/mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d", size = 43063, upload-time = "2025-07-29T07:43:28.582Z" },
- { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455, upload-time = "2025-07-29T07:43:29.563Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/87/399567b3796e134352e11a8b973cd470c06b2ecfad5468fe580833be442b/mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1", size = 56107 },
+ { url = "https://files.pythonhosted.org/packages/c3/09/830af30adf8678955b247d97d3d9543dd2fd95684f3cd41c0cd9d291da9f/mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051", size = 40635 },
+ { url = "https://files.pythonhosted.org/packages/07/14/eaba79eef55b40d653321765ac5e8f6c9ac38780b8a7c2a2f8df8ee0fb72/mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10", size = 40078 },
+ { url = "https://files.pythonhosted.org/packages/bb/26/83a0f852e763f81b2265d446b13ed6d49ee49e1fc0c47b9655977e6f3d81/mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c", size = 97262 },
+ { url = "https://files.pythonhosted.org/packages/00/7d/b7133b10d12239aeaebf6878d7eaf0bf7d3738c44b4aba3c564588f6d802/mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762", size = 103118 },
+ { url = "https://files.pythonhosted.org/packages/7b/3e/62f0b5dce2e22fd5b7d092aba285abd7959ea2b17148641e029f2eab1ffa/mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4", size = 106072 },
+ { url = "https://files.pythonhosted.org/packages/66/84/ea88bb816edfe65052c757a1c3408d65c4201ddbd769d4a287b0f1a628b2/mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363", size = 112925 },
+ { url = "https://files.pythonhosted.org/packages/2e/13/c9b1c022807db575fe4db806f442d5b5784547e2e82cff36133e58ea31c7/mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8", size = 120583 },
+ { url = "https://files.pythonhosted.org/packages/8a/5f/0e2dfe1a38f6a78788b7eb2b23432cee24623aeabbc907fed07fc17d6935/mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed", size = 99127 },
+ { url = "https://files.pythonhosted.org/packages/77/27/aefb7d663b67e6a0c4d61a513c83e39ba2237e8e4557fa7122a742a23de5/mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646", size = 98544 },
+ { url = "https://files.pythonhosted.org/packages/ab/97/a21cc9b1a7c6e92205a1b5fa030cdf62277d177570c06a239eca7bd6dd32/mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b", size = 106262 },
+ { url = "https://files.pythonhosted.org/packages/43/18/db19ae82ea63c8922a880e1498a75342311f8aa0c581c4dd07711473b5f7/mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779", size = 109824 },
+ { url = "https://files.pythonhosted.org/packages/9f/f5/41dcf0d1969125fc6f61d8618b107c79130b5af50b18a4651210ea52ab40/mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2", size = 97255 },
+ { url = "https://files.pythonhosted.org/packages/32/b3/cce9eaa0efac1f0e735bb178ef9d1d2887b4927fe0ec16609d5acd492dda/mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28", size = 40779 },
+ { url = "https://files.pythonhosted.org/packages/7c/e9/3fa0290122e6d5a7041b50ae500b8a9f4932478a51e48f209a3879fe0b9b/mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee", size = 41549 },
+ { url = "https://files.pythonhosted.org/packages/3a/54/c277475b4102588e6f06b2e9095ee758dfe31a149312cdbf62d39a9f5c30/mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9", size = 39336 },
+ { url = "https://files.pythonhosted.org/packages/bf/6a/d5aa7edb5c08e0bd24286c7d08341a0446f9a2fbbb97d96a8a6dd81935ee/mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be", size = 56141 },
+ { url = "https://files.pythonhosted.org/packages/08/49/131d0fae6447bc4a7299ebdb1a6fb9d08c9f8dcf97d75ea93e8152ddf7ab/mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd", size = 40681 },
+ { url = "https://files.pythonhosted.org/packages/8f/6f/9221445a6bcc962b7f5ff3ba18ad55bba624bacdc7aa3fc0a518db7da8ec/mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96", size = 40062 },
+ { url = "https://files.pythonhosted.org/packages/1e/d4/6bb2d0fef81401e0bb4c297d1eb568b767de4ce6fc00890bc14d7b51ecc4/mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094", size = 97333 },
+ { url = "https://files.pythonhosted.org/packages/44/e0/ccf0daff8134efbb4fbc10a945ab53302e358c4b016ada9bf97a6bdd50c1/mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037", size = 103310 },
+ { url = "https://files.pythonhosted.org/packages/02/63/1965cb08a46533faca0e420e06aff8bbaf9690a6f0ac6ae6e5b2e4544687/mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773", size = 106178 },
+ { url = "https://files.pythonhosted.org/packages/c2/41/c883ad8e2c234013f27f92061200afc11554ea55edd1bcf5e1accd803a85/mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5", size = 113035 },
+ { url = "https://files.pythonhosted.org/packages/df/b5/1ccade8b1fa625d634a18bab7bf08a87457e09d5ec8cf83ca07cbea9d400/mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50", size = 120784 },
+ { url = "https://files.pythonhosted.org/packages/77/1c/919d9171fcbdcdab242e06394464ccf546f7d0f3b31e0d1e3a630398782e/mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765", size = 99137 },
+ { url = "https://files.pythonhosted.org/packages/66/8a/1eebef5bd6633d36281d9fc83cf2e9ba1ba0e1a77dff92aacab83001cee4/mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43", size = 98664 },
+ { url = "https://files.pythonhosted.org/packages/13/41/a5d981563e2ee682b21fb65e29cc0f517a6734a02b581359edd67f9d0360/mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4", size = 106459 },
+ { url = "https://files.pythonhosted.org/packages/24/31/342494cd6ab792d81e083680875a2c50fa0c5df475ebf0b67784f13e4647/mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3", size = 110038 },
+ { url = "https://files.pythonhosted.org/packages/28/44/efda282170a46bb4f19c3e2b90536513b1d821c414c28469a227ca5a1789/mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c", size = 97545 },
+ { url = "https://files.pythonhosted.org/packages/68/8f/534ae319c6e05d714f437e7206f78c17e66daca88164dff70286b0e8ea0c/mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49", size = 40805 },
+ { url = "https://files.pythonhosted.org/packages/b8/f6/f6abdcfefcedab3c964868048cfe472764ed358c2bf6819a70dd4ed4ed3a/mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3", size = 41597 },
+ { url = "https://files.pythonhosted.org/packages/15/fd/f7420e8cbce45c259c770cac5718badf907b302d3a99ec587ba5ce030237/mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0", size = 39350 },
+ { url = "https://files.pythonhosted.org/packages/d8/fa/27f6ab93995ef6ad9f940e96593c5dd24744d61a7389532b0fec03745607/mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065", size = 40874 },
+ { url = "https://files.pythonhosted.org/packages/11/9c/03d13bcb6a03438bc8cac3d2e50f80908d159b31a4367c2e1a7a077ded32/mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de", size = 42012 },
+ { url = "https://files.pythonhosted.org/packages/4e/78/0865d9765408a7d504f1789944e678f74e0888b96a766d578cb80b040999/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044", size = 39197 },
+ { url = "https://files.pythonhosted.org/packages/3e/12/76c3207bd186f98b908b6706c2317abb73756d23a4e68ea2bc94825b9015/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73", size = 39840 },
+ { url = "https://files.pythonhosted.org/packages/5d/0d/574b6cce5555c9f2b31ea189ad44986755eb14e8862db28c8b834b8b64dc/mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504", size = 40644 },
+ { url = "https://files.pythonhosted.org/packages/52/82/3731f8640b79c46707f53ed72034a58baad400be908c87b0088f1f89f986/mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b", size = 56153 },
+ { url = "https://files.pythonhosted.org/packages/4f/34/e02dca1d4727fd9fdeaff9e2ad6983e1552804ce1d92cc796e5b052159bb/mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05", size = 40684 },
+ { url = "https://files.pythonhosted.org/packages/8f/36/3dee40767356e104967e6ed6d102ba47b0b1ce2a89432239b95a94de1b89/mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814", size = 40057 },
+ { url = "https://files.pythonhosted.org/packages/31/58/228c402fccf76eb39a0a01b8fc470fecf21965584e66453b477050ee0e99/mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093", size = 97344 },
+ { url = "https://files.pythonhosted.org/packages/34/82/fc5ce89006389a6426ef28e326fc065b0fbaaed230373b62d14c889f47ea/mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54", size = 103325 },
+ { url = "https://files.pythonhosted.org/packages/09/8c/261e85777c6aee1ebd53f2f17e210e7481d5b0846cd0b4a5c45f1e3761b8/mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a", size = 106240 },
+ { url = "https://files.pythonhosted.org/packages/70/73/2f76b3ad8a3d431824e9934403df36c0ddacc7831acf82114bce3c4309c8/mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908", size = 113060 },
+ { url = "https://files.pythonhosted.org/packages/9f/b9/7ea61a34e90e50a79a9d87aa1c0b8139a7eaf4125782b34b7d7383472633/mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5", size = 120781 },
+ { url = "https://files.pythonhosted.org/packages/0f/5b/ae1a717db98c7894a37aeedbd94b3f99e6472a836488f36b6849d003485b/mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a", size = 99174 },
+ { url = "https://files.pythonhosted.org/packages/e3/de/000cce1d799fceebb6d4487ae29175dd8e81b48e314cba7b4da90bcf55d7/mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266", size = 98734 },
+ { url = "https://files.pythonhosted.org/packages/79/19/0dc364391a792b72fbb22becfdeacc5add85cc043cd16986e82152141883/mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5", size = 106493 },
+ { url = "https://files.pythonhosted.org/packages/3c/b1/bc8c28e4d6e807bbb051fefe78e1156d7f104b89948742ad310612ce240d/mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9", size = 110089 },
+ { url = "https://files.pythonhosted.org/packages/3b/a2/d20f3f5c95e9c511806686c70d0a15479cc3941c5f322061697af1c1ff70/mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290", size = 97571 },
+ { url = "https://files.pythonhosted.org/packages/7b/23/665296fce4f33488deec39a750ffd245cfc07aafb0e3ef37835f91775d14/mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051", size = 40806 },
+ { url = "https://files.pythonhosted.org/packages/59/b0/92e7103f3b20646e255b699e2d0327ce53a3f250e44367a99dc8be0b7c7a/mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081", size = 41600 },
+ { url = "https://files.pythonhosted.org/packages/99/22/0b2bd679a84574647de538c5b07ccaa435dbccc37815067fe15b90fe8dad/mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b", size = 39349 },
+ { url = "https://files.pythonhosted.org/packages/f7/ca/a20db059a8a47048aaf550da14a145b56e9c7386fb8280d3ce2962dcebf7/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078", size = 39209 },
+ { url = "https://files.pythonhosted.org/packages/98/dd/e5094799d55c7482d814b979a0fd608027d0af1b274bfb4c3ea3e950bfd5/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501", size = 39843 },
+ { url = "https://files.pythonhosted.org/packages/f4/6b/7844d7f832c85400e7cc89a1348e4e1fdd38c5a38415bb5726bbb8fcdb6c/mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b", size = 40648 },
+ { url = "https://files.pythonhosted.org/packages/1f/bf/71f791f48a21ff3190ba5225807cbe4f7223360e96862c376e6e3fb7efa7/mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770", size = 56164 },
+ { url = "https://files.pythonhosted.org/packages/70/1f/f87e3d34d83032b4f3f0f528c6d95a98290fcacf019da61343a49dccfd51/mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110", size = 40692 },
+ { url = "https://files.pythonhosted.org/packages/a6/e2/db849eaed07117086f3452feca8c839d30d38b830ac59fe1ce65af8be5ad/mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647", size = 40068 },
+ { url = "https://files.pythonhosted.org/packages/df/6b/209af927207af77425b044e32f77f49105a0b05d82ff88af6971d8da4e19/mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63", size = 97367 },
+ { url = "https://files.pythonhosted.org/packages/ca/e0/78adf4104c425606a9ce33fb351f790c76a6c2314969c4a517d1ffc92196/mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12", size = 103306 },
+ { url = "https://files.pythonhosted.org/packages/a3/79/c2b89f91b962658b890104745b1b6c9ce38d50a889f000b469b91eeb1b9e/mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22", size = 106312 },
+ { url = "https://files.pythonhosted.org/packages/4b/14/659d4095528b1a209be90934778c5ffe312177d51e365ddcbca2cac2ec7c/mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5", size = 113135 },
+ { url = "https://files.pythonhosted.org/packages/8d/6f/cd7734a779389a8a467b5c89a48ff476d6f2576e78216a37551a97e9e42a/mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07", size = 120775 },
+ { url = "https://files.pythonhosted.org/packages/1d/ca/8256e3b96944408940de3f9291d7e38a283b5761fe9614d4808fcf27bd62/mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935", size = 99178 },
+ { url = "https://files.pythonhosted.org/packages/8a/32/39e2b3cf06b6e2eb042c984dab8680841ac2a0d3ca6e0bea30db1f27b565/mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7", size = 98738 },
+ { url = "https://files.pythonhosted.org/packages/61/d3/7bbc8e0e8cf65ebbe1b893ffa0467b7ecd1bd07c3bbf6c9db4308ada22ec/mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5", size = 106510 },
+ { url = "https://files.pythonhosted.org/packages/10/99/b97e53724b52374e2f3859046f0eb2425192da356cb19784d64bc17bb1cf/mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384", size = 110053 },
+ { url = "https://files.pythonhosted.org/packages/ac/62/3688c7d975ed195155671df68788c83fed6f7909b6ec4951724c6860cb97/mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e", size = 97546 },
+ { url = "https://files.pythonhosted.org/packages/ca/3b/c6153250f03f71a8b7634cded82939546cdfba02e32f124ff51d52c6f991/mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0", size = 41422 },
+ { url = "https://files.pythonhosted.org/packages/74/01/a27d98bab083a435c4c07e9d1d720d4c8a578bf4c270bae373760b1022be/mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b", size = 42135 },
+ { url = "https://files.pythonhosted.org/packages/cb/c9/dbba5507e95429b8b380e2ba091eff5c20a70a59560934dff0ad8392b8c8/mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115", size = 39879 },
+ { url = "https://files.pythonhosted.org/packages/b5/d1/c8c0ef839c17258b9de41b84f663574fabcf8ac2007b7416575e0f65ff6e/mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932", size = 57696 },
+ { url = "https://files.pythonhosted.org/packages/2f/55/95e2b9ff201e89f9fe37036037ab61a6c941942b25cdb7b6a9df9b931993/mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c", size = 41421 },
+ { url = "https://files.pythonhosted.org/packages/77/79/9be23ad0b7001a4b22752e7693be232428ecc0a35068a4ff5c2f14ef8b20/mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be", size = 40853 },
+ { url = "https://files.pythonhosted.org/packages/ac/1b/96b32058eda1c1dee8264900c37c359a7325c1f11f5ff14fd2be8e24eff9/mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb", size = 109694 },
+ { url = "https://files.pythonhosted.org/packages/8d/6f/a2ae44cd7dad697b6dea48390cbc977b1e5ca58fda09628cbcb2275af064/mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65", size = 117438 },
+ { url = "https://files.pythonhosted.org/packages/a0/08/bfb75451c83f05224a28afeaf3950c7b793c0b71440d571f8e819cfb149a/mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991", size = 120409 },
+ { url = "https://files.pythonhosted.org/packages/9f/ea/8b118b69b2ff8df568f742387d1a159bc654a0f78741b31437dd047ea28e/mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645", size = 125909 },
+ { url = "https://files.pythonhosted.org/packages/3e/11/168cc0b6a30650032e351a3b89b8a47382da541993a03af91e1ba2501234/mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3", size = 135331 },
+ { url = "https://files.pythonhosted.org/packages/31/05/e3a9849b1c18a7934c64e831492c99e67daebe84a8c2f2c39a7096a830e3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279", size = 110085 },
+ { url = "https://files.pythonhosted.org/packages/d9/d5/a96bcc306e3404601418b2a9a370baec92af84204528ba659fdfe34c242f/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513", size = 111195 },
+ { url = "https://files.pythonhosted.org/packages/af/29/0fd49801fec5bff37198684e0849b58e0dab3a2a68382a357cfffb0fafc3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db", size = 116919 },
+ { url = "https://files.pythonhosted.org/packages/2d/04/4f3c32b0a2ed762edca45d8b46568fc3668e34f00fb1e0a3b5451ec1281c/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667", size = 123160 },
+ { url = "https://files.pythonhosted.org/packages/91/76/3d29eaa38821730633d6a240d36fa8ad2807e9dfd432c12e1a472ed211eb/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5", size = 110206 },
+ { url = "https://files.pythonhosted.org/packages/44/1c/ccf35892684d3a408202e296e56843743e0b4fb1629e59432ea88cdb3909/mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7", size = 41970 },
+ { url = "https://files.pythonhosted.org/packages/75/b2/b9e4f1e5adb5e21eb104588fcee2cd1eaa8308255173481427d5ecc4284e/mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d", size = 43063 },
+ { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455 },
]
[[package]]
@@ -2944,135 +2933,135 @@ dependencies = [
{ name = "werkzeug" },
{ name = "xmltodict" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f6/e9/c38202162db2e76623176be9f1dbc9aa41228ffa91ee8da2d3986082c3e3/moto-5.2.1.tar.gz", hash = "sha256:ccb2f3e1dfa82e50e054bda98b0be708d244d2668364dcc1d45e8d3de6091bde", size = 8634437, upload-time = "2026-05-10T19:11:57.286Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/e9/c38202162db2e76623176be9f1dbc9aa41228ffa91ee8da2d3986082c3e3/moto-5.2.1.tar.gz", hash = "sha256:ccb2f3e1dfa82e50e054bda98b0be708d244d2668364dcc1d45e8d3de6091bde", size = 8634437 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/15/79/8085b7c1ecd48d0535c3c8444a1d8df2926e457dce8e55fabc332a382c9c/moto-5.2.1-py3-none-any.whl", hash = "sha256:19d2fbd6e613aa5b4e364c52cd5d3cea371643a0f4210689a703227bd2924c5c", size = 6671379, upload-time = "2026-05-10T19:11:53.543Z" },
+ { url = "https://files.pythonhosted.org/packages/15/79/8085b7c1ecd48d0535c3c8444a1d8df2926e457dce8e55fabc332a382c9c/moto-5.2.1-py3-none-any.whl", hash = "sha256:19d2fbd6e613aa5b4e364c52cd5d3cea371643a0f4210689a703227bd2924c5c", size = 6671379 },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 },
]
[[package]]
name = "multidict"
version = "6.7.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" },
- { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" },
- { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" },
- { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" },
- { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" },
- { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" },
- { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" },
- { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" },
- { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" },
- { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" },
- { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" },
- { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" },
- { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" },
- { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" },
- { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" },
- { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" },
- { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" },
- { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" },
- { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
- { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
- { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
- { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
- { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
- { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
- { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
- { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
- { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
- { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
- { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
- { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
- { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
- { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
- { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
- { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
- { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
- { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
- { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
- { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
- { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
- { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
- { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
- { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
- { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
- { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
- { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
- { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
- { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
- { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
- { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
- { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
- { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
- { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
- { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
- { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
- { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
- { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
- { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
- { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
- { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
- { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
- { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
- { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
- { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
- { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
- { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
- { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
- { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
- { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
- { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
- { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
- { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
- { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
- { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
- { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
- { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
- { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
- { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
- { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
- { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
- { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
- { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
- { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
- { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
- { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
- { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
- { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
- { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
- { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
- { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
- { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
- { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
- { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
- { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
- { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
- { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
- { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
- { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
- { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
- { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
- { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
- { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
- { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
- { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
- { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
- { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
- { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
- { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
- { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
- { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626 },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706 },
+ { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356 },
+ { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355 },
+ { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433 },
+ { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376 },
+ { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365 },
+ { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747 },
+ { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293 },
+ { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962 },
+ { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360 },
+ { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940 },
+ { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502 },
+ { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065 },
+ { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870 },
+ { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302 },
+ { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981 },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159 },
+ { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 },
+ { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 },
+ { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 },
+ { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 },
+ { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 },
+ { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 },
+ { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 },
+ { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 },
+ { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 },
+ { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 },
+ { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 },
+ { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 },
+ { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 },
+ { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 },
+ { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 },
+ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 },
+ { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 },
+ { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 },
+ { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174 },
+ { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116 },
+ { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524 },
+ { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368 },
+ { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952 },
+ { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317 },
+ { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132 },
+ { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140 },
+ { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277 },
+ { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291 },
+ { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156 },
+ { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742 },
+ { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221 },
+ { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664 },
+ { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490 },
+ { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695 },
+ { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884 },
+ { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122 },
+ { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175 },
+ { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460 },
+ { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930 },
+ { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582 },
+ { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031 },
+ { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596 },
+ { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492 },
+ { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899 },
+ { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970 },
+ { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060 },
+ { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888 },
+ { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554 },
+ { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341 },
+ { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391 },
+ { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422 },
+ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770 },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109 },
+ { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573 },
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190 },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486 },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219 },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132 },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420 },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510 },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094 },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786 },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483 },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403 },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315 },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528 },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784 },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980 },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602 },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930 },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074 },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471 },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401 },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143 },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507 },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358 },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884 },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878 },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542 },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403 },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889 },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982 },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415 },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337 },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788 },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842 },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237 },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008 },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542 },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719 },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 },
]
[[package]]
@@ -3085,42 +3074,42 @@ dependencies = [
{ name = "pathspec" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" },
- { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" },
- { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" },
- { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" },
- { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" },
- { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" },
- { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
- { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
- { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
- { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
- { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
- { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
- { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
- { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
- { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
- { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
- { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
- { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
- { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
- { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
- { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
- { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
- { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
- { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539 },
+ { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163 },
+ { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629 },
+ { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933 },
+ { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754 },
+ { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772 },
+ { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053 },
+ { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134 },
+ { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616 },
+ { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847 },
+ { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976 },
+ { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104 },
+ { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927 },
+ { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730 },
+ { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581 },
+ { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252 },
+ { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848 },
+ { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510 },
+ { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744 },
+ { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815 },
+ { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047 },
+ { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998 },
+ { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476 },
+ { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872 },
+ { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239 },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
]
[[package]]
@@ -3133,106 +3122,106 @@ dependencies = [
{ name = "logbook" },
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/4d/02/2e59ed6a70452b669412a7b89fb9c14396f61fb63b30797c3263cae7ea04/nakuru_project_idk-0.0.2.1.tar.gz", hash = "sha256:3012a0076d3faeeb1d8b790e7c2cf9eb14962752e50340ab50d735a2f42c2a8e", size = 350709, upload-time = "2023-05-07T15:00:28.218Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/02/2e59ed6a70452b669412a7b89fb9c14396f61fb63b30797c3263cae7ea04/nakuru_project_idk-0.0.2.1.tar.gz", hash = "sha256:3012a0076d3faeeb1d8b790e7c2cf9eb14962752e50340ab50d735a2f42c2a8e", size = 350709 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4a/67/5c9c8f1ba4a599e35a77ca7e0a0210ab6cd732f719bc3b0fc95c69aaca10/nakuru_project_idk-0.0.2.1-py3-none-any.whl", hash = "sha256:bddd8af8a46ef381bd05b806d6c07bd8ba407c58b47ce6148d750bd77c4420bc", size = 24281, upload-time = "2023-05-07T15:00:25.094Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/67/5c9c8f1ba4a599e35a77ca7e0a0210ab6cd732f719bc3b0fc95c69aaca10/nakuru_project_idk-0.0.2.1-py3-none-any.whl", hash = "sha256:bddd8af8a46ef381bd05b806d6c07bd8ba407c58b47ce6148d750bd77c4420bc", size = 24281 },
]
[[package]]
name = "networkx"
version = "3.6.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504 },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 },
]
[[package]]
name = "numpy"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" },
- { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" },
- { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" },
- { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" },
- { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" },
- { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" },
- { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" },
- { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" },
- { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" },
- { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" },
- { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" },
- { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
- { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
- { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
- { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
- { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
- { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
- { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
- { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
- { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
- { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
- { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
- { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
- { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
- { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
- { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
- { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
- { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
- { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
- { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
- { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
- { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
- { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
- { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
- { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
- { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
- { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
- { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
- { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
- { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
- { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
- { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
- { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
- { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
- { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
- { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
- { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
- { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
- { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
- { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
- { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
- { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
- { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
- { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
- { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
- { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
- { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
- { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
- { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
- { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
- { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
- { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
- { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
- { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" },
- { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" },
- { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" },
- { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" },
- { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" },
- { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" },
- { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478 },
+ { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467 },
+ { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172 },
+ { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145 },
+ { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084 },
+ { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477 },
+ { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429 },
+ { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109 },
+ { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915 },
+ { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972 },
+ { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763 },
+ { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963 },
+ { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571 },
+ { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469 },
+ { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820 },
+ { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067 },
+ { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782 },
+ { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128 },
+ { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324 },
+ { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282 },
+ { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210 },
+ { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171 },
+ { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696 },
+ { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322 },
+ { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157 },
+ { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330 },
+ { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968 },
+ { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311 },
+ { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850 },
+ { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210 },
+ { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199 },
+ { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848 },
+ { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082 },
+ { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866 },
+ { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631 },
+ { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254 },
+ { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138 },
+ { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398 },
+ { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064 },
+ { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680 },
+ { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433 },
+ { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181 },
+ { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756 },
+ { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092 },
+ { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770 },
+ { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562 },
+ { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710 },
+ { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205 },
+ { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738 },
+ { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888 },
+ { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556 },
+ { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899 },
+ { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072 },
+ { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886 },
+ { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567 },
+ { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372 },
+ { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306 },
+ { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394 },
+ { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343 },
+ { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045 },
+ { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024 },
+ { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937 },
+ { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844 },
+ { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379 },
+ { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179 },
+ { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755 },
+ { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500 },
+ { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252 },
+ { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142 },
+ { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979 },
+ { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577 },
]
[[package]]
@@ -3243,8 +3232,8 @@ dependencies = [
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
- { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918 },
+ { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758 },
]
[[package]]
@@ -3252,8 +3241,8 @@ name = "nvidia-cuda-cupti"
version = "13.0.85"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" },
- { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827 },
+ { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597 },
]
[[package]]
@@ -3261,8 +3250,8 @@ name = "nvidia-cuda-nvrtc"
version = "13.0.88"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" },
- { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200 },
+ { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449 },
]
[[package]]
@@ -3270,8 +3259,8 @@ name = "nvidia-cuda-runtime"
version = "13.0.96"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" },
- { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" },
+ { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060 },
+ { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632 },
]
[[package]]
@@ -3282,8 +3271,8 @@ dependencies = [
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
- { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" },
+ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296 },
+ { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588 },
]
[[package]]
@@ -3294,8 +3283,8 @@ dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
- { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554 },
+ { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489 },
]
[[package]]
@@ -3303,8 +3292,8 @@ name = "nvidia-cufile"
version = "1.15.1.6"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" },
- { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672 },
+ { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992 },
]
[[package]]
@@ -3312,8 +3301,8 @@ name = "nvidia-curand"
version = "10.4.0.35"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" },
- { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106 },
+ { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258 },
]
[[package]]
@@ -3326,8 +3315,8 @@ dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
- { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760 },
+ { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980 },
]
[[package]]
@@ -3338,8 +3327,8 @@ dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
- { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568 },
+ { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937 },
]
[[package]]
@@ -3347,8 +3336,8 @@ name = "nvidia-cusparselt-cu13"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" },
- { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" },
+ { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344 },
+ { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586 },
]
[[package]]
@@ -3356,8 +3345,8 @@ name = "nvidia-nccl-cu13"
version = "2.29.7"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" },
- { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" },
+ { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712 },
+ { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000 },
]
[[package]]
@@ -3365,8 +3354,8 @@ name = "nvidia-nvjitlink"
version = "13.0.88"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" },
- { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" },
+ { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933 },
+ { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748 },
]
[[package]]
@@ -3374,8 +3363,8 @@ name = "nvidia-nvshmem-cu13"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" },
- { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947 },
+ { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546 },
]
[[package]]
@@ -3383,17 +3372,17 @@ name = "nvidia-nvtx"
version = "13.0.85"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" },
- { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047 },
+ { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878 },
]
[[package]]
name = "oauthlib"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
+ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 },
]
[[package]]
@@ -3404,9 +3393,9 @@ dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" },
+ { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354 },
]
[[package]]
@@ -3422,23 +3411,23 @@ dependencies = [
{ name = "sympy" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" },
- { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" },
- { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" },
- { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" },
- { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" },
- { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" },
- { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" },
- { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" },
- { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" },
- { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" },
- { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" },
- { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" },
- { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" },
- { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" },
- { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" },
- { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" },
- { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" },
+ { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113 },
+ { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857 },
+ { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095 },
+ { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080 },
+ { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349 },
+ { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929 },
+ { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705 },
+ { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915 },
+ { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649 },
+ { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528 },
+ { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337 },
+ { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691 },
+ { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898 },
+ { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518 },
+ { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276 },
+ { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610 },
+ { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184 },
]
[[package]]
@@ -3455,9 +3444,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/40/36/4c926a91554483977608951360c18c2e911592785eb87a6437813f6123f7/openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57", size = 783584, upload-time = "2026-06-10T16:10:37.667Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/40/36/4c926a91554483977608951360c18c2e911592785eb87a6437813f6123f7/openai-2.41.1.tar.gz", hash = "sha256:23d617a0432457ad844973bee8f540be9da90894f7c5686852d2d365da058f57", size = 783584 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/74/925d7b3892927e9804aaf58d374a45dc28e4420ff90e992272b77286343e/openai-2.41.1-py3-none-any.whl", hash = "sha256:a939565f350cb7443cb843b801b88c716ac8024b492fb94ca269d5f6b1bbefd6", size = 1353380, upload-time = "2026-06-10T16:10:35.756Z" },
+ { url = "https://files.pythonhosted.org/packages/20/74/925d7b3892927e9804aaf58d374a45dc28e4420ff90e992272b77286343e/openai-2.41.1-py3-none-any.whl", hash = "sha256:a939565f350cb7443cb843b801b88c716ac8024b492fb94ca269d5f6b1bbefd6", size = 1353380 },
]
[[package]]
@@ -3468,9 +3457,9 @@ dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356 },
]
[[package]]
@@ -3480,9 +3469,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366 },
]
[[package]]
@@ -3498,9 +3487,9 @@ dependencies = [
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650, upload-time = "2025-12-11T13:32:41.429Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/48/b329fed2c610c2c32c9366d9dc597202c9d1e58e631c137ba15248d8850f/opentelemetry_exporter_otlp_proto_grpc-1.39.1.tar.gz", hash = "sha256:772eb1c9287485d625e4dbe9c879898e5253fea111d9181140f51291b5fec3ad", size = 24650 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766, upload-time = "2025-12-11T13:32:21.027Z" },
+ { url = "https://files.pythonhosted.org/packages/81/a3/cc9b66575bd6597b98b886a2067eea2693408d2d5f39dad9ab7fc264f5f3/opentelemetry_exporter_otlp_proto_grpc-1.39.1-py3-none-any.whl", hash = "sha256:fa1c136a05c7e9b4c09f739469cbdb927ea20b34088ab1d959a849b5cc589c18", size = 19766 },
]
[[package]]
@@ -3510,9 +3499,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
+ { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535 },
]
[[package]]
@@ -3524,9 +3513,9 @@ dependencies = [
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565 },
]
[[package]]
@@ -3537,143 +3526,143 @@ dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982 },
]
[[package]]
name = "orjson"
version = "3.11.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f3/fd/d6b0a36854179b93ed77839f107c4089d91cccc9f9ba1b752b6e3bac5f34/orjson-3.11.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e259e85a81d76d9665f03d6129e09e4435531870de5961ddcd0bf6e3a7fde7d7", size = 250029, upload-time = "2026-01-29T15:11:35.942Z" },
- { url = "https://files.pythonhosted.org/packages/a3/bb/22902619826641cf3b627c24aab62e2ad6b571bdd1d34733abb0dd57f67a/orjson-3.11.6-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:52263949f41b4a4822c6b1353bcc5ee2f7109d53a3b493501d3369d6d0e7937a", size = 134518, upload-time = "2026-01-29T15:11:37.347Z" },
- { url = "https://files.pythonhosted.org/packages/72/90/7a818da4bba1de711a9653c420749c0ac95ef8f8651cbc1dca551f462fe0/orjson-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6439e742fa7834a24698d358a27346bb203bff356ae0402e7f5df8f749c621a8", size = 137917, upload-time = "2026-01-29T15:11:38.511Z" },
- { url = "https://files.pythonhosted.org/packages/59/0f/02846c1cac8e205cb3822dd8aa8f9114acda216f41fd1999ace6b543418d/orjson-3.11.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b81ffd68f084b4e993e3867acb554a049fa7787cc8710bbcc1e26965580d99be", size = 134923, upload-time = "2026-01-29T15:11:39.711Z" },
- { url = "https://files.pythonhosted.org/packages/94/cf/aeaf683001b474bb3c3c757073a4231dfdfe8467fceaefa5bfd40902c99f/orjson-3.11.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5a5468e5e60f7ef6d7f9044b06c8f94a3c56ba528c6e4f7f06ae95164b595ec", size = 140752, upload-time = "2026-01-29T15:11:41.347Z" },
- { url = "https://files.pythonhosted.org/packages/fc/fe/dad52d8315a65f084044a0819d74c4c9daf9ebe0681d30f525b0d29a31f0/orjson-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72c5005eb45bd2535632d4f3bec7ad392832cfc46b62a3021da3b48a67734b45", size = 144201, upload-time = "2026-01-29T15:11:42.537Z" },
- { url = "https://files.pythonhosted.org/packages/36/bc/ab070dd421565b831801077f1e390c4d4af8bfcecafc110336680a33866b/orjson-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b14dd49f3462b014455a28a4d810d3549bf990567653eb43765cd847df09145", size = 142380, upload-time = "2026-01-29T15:11:44.309Z" },
- { url = "https://files.pythonhosted.org/packages/e6/d8/4b581c725c3a308717f28bf45a9fdac210bca08b67e8430143699413ff06/orjson-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bb2c1ea30ef302f0f89f9bf3e7f9ab5e2af29dc9f80eb87aa99788e4e2d65", size = 145582, upload-time = "2026-01-29T15:11:45.506Z" },
- { url = "https://files.pythonhosted.org/packages/5b/a2/09aab99b39f9a7f175ea8fa29adb9933a3d01e7d5d603cdee7f1c40c8da2/orjson-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:825e0a85d189533c6bff7e2fc417a28f6fcea53d27125c4551979aecd6c9a197", size = 147270, upload-time = "2026-01-29T15:11:46.782Z" },
- { url = "https://files.pythonhosted.org/packages/b8/2f/5ef8eaf7829dc50da3bf497c7775b21ee88437bc8c41f959aa3504ca6631/orjson-3.11.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b04575417a26530637f6ab4b1f7b4f666eb0433491091da4de38611f97f2fcf3", size = 421222, upload-time = "2026-01-29T15:11:48.106Z" },
- { url = "https://files.pythonhosted.org/packages/3b/b0/dd6b941294c2b5b13da5fdc7e749e58d0c55a5114ab37497155e83050e95/orjson-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b83eb2e40e8c4da6d6b340ee6b1d6125f5195eb1b0ebb7eac23c6d9d4f92d224", size = 155562, upload-time = "2026-01-29T15:11:49.408Z" },
- { url = "https://files.pythonhosted.org/packages/8e/09/43924331a847476ae2f9a16bd6d3c9dab301265006212ba0d3d7fd58763a/orjson-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f42da604ee65a6b87eef858c913ce3e5777872b19321d11e6fc6d21de89b64f", size = 147432, upload-time = "2026-01-29T15:11:50.635Z" },
- { url = "https://files.pythonhosted.org/packages/5d/e9/d9865961081816909f6b49d880749dbbd88425afd7c5bbce0549e2290d77/orjson-3.11.6-cp311-cp311-win32.whl", hash = "sha256:5ae45df804f2d344cffb36c43fdf03c82fb6cd247f5faa41e21891b40dfbf733", size = 139623, upload-time = "2026-01-29T15:11:51.82Z" },
- { url = "https://files.pythonhosted.org/packages/b4/f9/6836edb92f76eec1082919101eb1145d2f9c33c8f2c5e6fa399b82a2aaa8/orjson-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:f4295948d65ace0a2d8f2c4ccc429668b7eb8af547578ec882e16bf79b0050b2", size = 136647, upload-time = "2026-01-29T15:11:53.454Z" },
- { url = "https://files.pythonhosted.org/packages/b3/0c/4954082eea948c9ae52ee0bcbaa2f99da3216a71bcc314ab129bde22e565/orjson-3.11.6-cp311-cp311-win_arm64.whl", hash = "sha256:314e9c45e0b81b547e3a1cfa3df3e07a815821b3dac9fe8cb75014071d0c16a4", size = 135327, upload-time = "2026-01-29T15:11:56.616Z" },
- { url = "https://files.pythonhosted.org/packages/14/ba/759f2879f41910b7e5e0cdbd9cf82a4f017c527fb0e972e9869ca7fe4c8e/orjson-3.11.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6f03f30cd8953f75f2a439070c743c7336d10ee940da918d71c6f3556af3ddcf", size = 249988, upload-time = "2026-01-29T15:11:58.294Z" },
- { url = "https://files.pythonhosted.org/packages/f0/70/54cecb929e6c8b10104fcf580b0cc7dc551aa193e83787dd6f3daba28bb5/orjson-3.11.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:af44baae65ef386ad971469a8557a0673bb042b0b9fd4397becd9c2dfaa02588", size = 134445, upload-time = "2026-01-29T15:11:59.819Z" },
- { url = "https://files.pythonhosted.org/packages/f2/6f/ec0309154457b9ba1ad05f11faa4441f76037152f75e1ac577db3ce7ca96/orjson-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c310a48542094e4f7dbb6ac076880994986dda8ca9186a58c3cb70a3514d3231", size = 137708, upload-time = "2026-01-29T15:12:01.488Z" },
- { url = "https://files.pythonhosted.org/packages/20/52/3c71b80840f8bab9cb26417302707b7716b7d25f863f3a541bcfa232fe6e/orjson-3.11.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8dfa7a5d387f15ecad94cb6b2d2d5f4aeea64efd8d526bfc03c9812d01e1cc0", size = 134798, upload-time = "2026-01-29T15:12:02.705Z" },
- { url = "https://files.pythonhosted.org/packages/30/51/b490a43b22ff736282360bd02e6bded455cf31dfc3224e01cd39f919bbd2/orjson-3.11.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8daee3e999411b50f8b50dbb0a3071dd1845f3f9a1a0a6fa6de86d1689d84d", size = 140839, upload-time = "2026-01-29T15:12:03.956Z" },
- { url = "https://files.pythonhosted.org/packages/95/bc/4bcfe4280c1bc63c5291bb96f98298845b6355da2226d3400e17e7b51e53/orjson-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89d104c974eafd7436d7a5fdbc57f7a1e776789959a2f4f1b2eab5c62a339f4", size = 144080, upload-time = "2026-01-29T15:12:05.151Z" },
- { url = "https://files.pythonhosted.org/packages/01/74/22970f9ead9ab1f1b5f8c227a6c3aa8d71cd2c5acd005868a1d44f2362fa/orjson-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e2e2456788ca5ea75616c40da06fc885a7dc0389780e8a41bf7c5389ba257b", size = 142435, upload-time = "2026-01-29T15:12:06.641Z" },
- { url = "https://files.pythonhosted.org/packages/29/34/d564aff85847ab92c82ee43a7a203683566c2fca0723a5f50aebbe759603/orjson-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a42efebc45afabb1448001e90458c4020d5c64fbac8a8dc4045b777db76cb5a", size = 145631, upload-time = "2026-01-29T15:12:08.351Z" },
- { url = "https://files.pythonhosted.org/packages/e7/ef/016957a3890752c4aa2368326ea69fa53cdc1fdae0a94a542b6410dbdf52/orjson-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71b7cbef8471324966c3738c90ba38775563ef01b512feb5ad4805682188d1b9", size = 147058, upload-time = "2026-01-29T15:12:10.023Z" },
- { url = "https://files.pythonhosted.org/packages/56/cc/9a899c3972085645b3225569f91a30e221f441e5dc8126e6d060b971c252/orjson-3.11.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f8515e5910f454fe9a8e13c2bb9dc4bae4c1836313e967e72eb8a4ad874f0248", size = 421161, upload-time = "2026-01-29T15:12:11.308Z" },
- { url = "https://files.pythonhosted.org/packages/21/a8/767d3fbd6d9b8fdee76974db40619399355fd49bf91a6dd2c4b6909ccf05/orjson-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:300360edf27c8c9bf7047345a94fddf3a8b8922df0ff69d71d854a170cb375cf", size = 155757, upload-time = "2026-01-29T15:12:12.776Z" },
- { url = "https://files.pythonhosted.org/packages/ad/0b/205cd69ac87e2272e13ef3f5f03a3d4657e317e38c1b08aaa2ef97060bbc/orjson-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:caaed4dad39e271adfadc106fab634d173b2bb23d9cf7e67bd645f879175ebfc", size = 147446, upload-time = "2026-01-29T15:12:14.166Z" },
- { url = "https://files.pythonhosted.org/packages/de/c5/dd9f22aa9f27c54c7d05cc32f4580c9ac9b6f13811eeb81d6c4c3f50d6b1/orjson-3.11.6-cp312-cp312-win32.whl", hash = "sha256:955368c11808c89793e847830e1b1007503a5923ddadc108547d3b77df761044", size = 139717, upload-time = "2026-01-29T15:12:15.7Z" },
- { url = "https://files.pythonhosted.org/packages/23/a1/e62fc50d904486970315a1654b8cfb5832eb46abb18cd5405118e7e1fc79/orjson-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:2c68de30131481150073d90a5d227a4a421982f42c025ecdfb66157f9579e06f", size = 136711, upload-time = "2026-01-29T15:12:17.055Z" },
- { url = "https://files.pythonhosted.org/packages/04/3d/b4fefad8bdf91e0fe212eb04975aeb36ea92997269d68857efcc7eb1dda3/orjson-3.11.6-cp312-cp312-win_arm64.whl", hash = "sha256:65dfa096f4e3a5e02834b681f539a87fbe85adc82001383c0db907557f666bfc", size = 135212, upload-time = "2026-01-29T15:12:18.3Z" },
- { url = "https://files.pythonhosted.org/packages/ae/45/d9c71c8c321277bc1ceebf599bc55ba826ae538b7c61f287e9a7e71bd589/orjson-3.11.6-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e4ae1670caabb598a88d385798692ce2a1b2f078971b3329cfb85253c6097f5b", size = 249828, upload-time = "2026-01-29T15:12:20.14Z" },
- { url = "https://files.pythonhosted.org/packages/ac/7e/4afcf4cfa9c2f93846d70eee9c53c3c0123286edcbeb530b7e9bd2aea1b2/orjson-3.11.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2c6b81f47b13dac2caa5d20fbc953c75eb802543abf48403a4703ed3bff225f0", size = 134339, upload-time = "2026-01-29T15:12:22.01Z" },
- { url = "https://files.pythonhosted.org/packages/40/10/6d2b8a064c8d2411d3d0ea6ab43125fae70152aef6bea77bb50fa54d4097/orjson-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:647d6d034e463764e86670644bdcaf8e68b076e6e74783383b01085ae9ab334f", size = 137662, upload-time = "2026-01-29T15:12:23.307Z" },
- { url = "https://files.pythonhosted.org/packages/5a/50/5804ea7d586baf83ee88969eefda97a24f9a5bdba0727f73e16305175b26/orjson-3.11.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8523b9cc4ef174ae52414f7699e95ee657c16aa18b3c3c285d48d7966cce9081", size = 134626, upload-time = "2026-01-29T15:12:25.099Z" },
- { url = "https://files.pythonhosted.org/packages/9e/2e/f0492ed43e376722bb4afd648e06cc1e627fc7ec8ff55f6ee739277813ea/orjson-3.11.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313dfd7184cde50c733fc0d5c8c0e2f09017b573afd11dc36bd7476b30b4cb17", size = 140873, upload-time = "2026-01-29T15:12:26.369Z" },
- { url = "https://files.pythonhosted.org/packages/10/15/6f874857463421794a303a39ac5494786ad46a4ab46d92bda6705d78c5aa/orjson-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905ee036064ff1e1fd1fb800055ac477cdcb547a78c22c1bc2bbf8d5d1a6fb42", size = 144044, upload-time = "2026-01-29T15:12:28.082Z" },
- { url = "https://files.pythonhosted.org/packages/d2/c7/b7223a3a70f1d0cc2d86953825de45f33877ee1b124a91ca1f79aa6e643f/orjson-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce374cb98411356ba906914441fc993f271a7a666d838d8de0e0900dd4a4bc12", size = 142396, upload-time = "2026-01-29T15:12:30.529Z" },
- { url = "https://files.pythonhosted.org/packages/87/e3/aa1b6d3ad3cd80f10394134f73ae92a1d11fdbe974c34aa199cc18bb5fcf/orjson-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cded072b9f65fcfd188aead45efa5bd528ba552add619b3ad2a81f67400ec450", size = 145600, upload-time = "2026-01-29T15:12:31.848Z" },
- { url = "https://files.pythonhosted.org/packages/f6/cf/e4aac5a46cbd39d7e769ef8650efa851dfce22df1ba97ae2b33efe893b12/orjson-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ab85bdbc138e1f73a234db6bb2e4cc1f0fcec8f4bd2bd2430e957a01aadf746", size = 146967, upload-time = "2026-01-29T15:12:33.203Z" },
- { url = "https://files.pythonhosted.org/packages/0b/04/975b86a4bcf6cfeda47aad15956d52fbeda280811206e9967380fa9355c8/orjson-3.11.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:351b96b614e3c37a27b8ab048239ebc1e0be76cc17481a430d70a77fb95d3844", size = 421003, upload-time = "2026-01-29T15:12:35.097Z" },
- { url = "https://files.pythonhosted.org/packages/28/d1/0369d0baf40eea5ff2300cebfe209883b2473ab4aa4c4974c8bd5ee42bb2/orjson-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f9959c85576beae5cdcaaf39510b15105f1ee8b70d5dacd90152617f57be8c83", size = 155695, upload-time = "2026-01-29T15:12:36.589Z" },
- { url = "https://files.pythonhosted.org/packages/ab/1f/d10c6d6ae26ff1d7c3eea6fd048280ef2e796d4fb260c5424fd021f68ecf/orjson-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75682d62b1b16b61a30716d7a2ec1f4c36195de4a1c61f6665aedd947b93a5d5", size = 147392, upload-time = "2026-01-29T15:12:37.876Z" },
- { url = "https://files.pythonhosted.org/packages/8d/43/7479921c174441a0aa5277c313732e20713c0969ac303be9f03d88d3db5d/orjson-3.11.6-cp313-cp313-win32.whl", hash = "sha256:40dc277999c2ef227dcc13072be879b4cfd325502daeb5c35ed768f706f2bf30", size = 139718, upload-time = "2026-01-29T15:12:39.274Z" },
- { url = "https://files.pythonhosted.org/packages/88/bc/9ffe7dfbf8454bc4e75bb8bf3a405ed9e0598df1d3535bb4adcd46be07d0/orjson-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0f6e9f8ff7905660bc3c8a54cd4a675aa98f7f175cf00a59815e2ff42c0d916", size = 136635, upload-time = "2026-01-29T15:12:40.593Z" },
- { url = "https://files.pythonhosted.org/packages/6f/7e/51fa90b451470447ea5023b20d83331ec741ae28d1e6d8ed547c24e7de14/orjson-3.11.6-cp313-cp313-win_arm64.whl", hash = "sha256:1608999478664de848e5900ce41f25c4ecdfc4beacbc632b6fd55e1a586e5d38", size = 135175, upload-time = "2026-01-29T15:12:41.997Z" },
- { url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823, upload-time = "2026-01-29T15:12:43.332Z" },
- { url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328, upload-time = "2026-01-29T15:12:45.171Z" },
- { url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651, upload-time = "2026-01-29T15:12:46.602Z" },
- { url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596, upload-time = "2026-01-29T15:12:47.95Z" },
- { url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923, upload-time = "2026-01-29T15:12:49.333Z" },
- { url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068, upload-time = "2026-01-29T15:12:50.927Z" },
- { url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493, upload-time = "2026-01-29T15:12:52.432Z" },
- { url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616, upload-time = "2026-01-29T15:12:53.903Z" },
- { url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951, upload-time = "2026-01-29T15:12:55.698Z" },
- { url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024, upload-time = "2026-01-29T15:12:57.434Z" },
- { url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774, upload-time = "2026-01-29T15:12:59.397Z" },
- { url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393, upload-time = "2026-01-29T15:13:00.769Z" },
- { url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760, upload-time = "2026-01-29T15:13:02.173Z" },
- { url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633, upload-time = "2026-01-29T15:13:04.267Z" },
- { url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168, upload-time = "2026-01-29T15:13:05.932Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/fd/d6b0a36854179b93ed77839f107c4089d91cccc9f9ba1b752b6e3bac5f34/orjson-3.11.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e259e85a81d76d9665f03d6129e09e4435531870de5961ddcd0bf6e3a7fde7d7", size = 250029 },
+ { url = "https://files.pythonhosted.org/packages/a3/bb/22902619826641cf3b627c24aab62e2ad6b571bdd1d34733abb0dd57f67a/orjson-3.11.6-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:52263949f41b4a4822c6b1353bcc5ee2f7109d53a3b493501d3369d6d0e7937a", size = 134518 },
+ { url = "https://files.pythonhosted.org/packages/72/90/7a818da4bba1de711a9653c420749c0ac95ef8f8651cbc1dca551f462fe0/orjson-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6439e742fa7834a24698d358a27346bb203bff356ae0402e7f5df8f749c621a8", size = 137917 },
+ { url = "https://files.pythonhosted.org/packages/59/0f/02846c1cac8e205cb3822dd8aa8f9114acda216f41fd1999ace6b543418d/orjson-3.11.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b81ffd68f084b4e993e3867acb554a049fa7787cc8710bbcc1e26965580d99be", size = 134923 },
+ { url = "https://files.pythonhosted.org/packages/94/cf/aeaf683001b474bb3c3c757073a4231dfdfe8467fceaefa5bfd40902c99f/orjson-3.11.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5a5468e5e60f7ef6d7f9044b06c8f94a3c56ba528c6e4f7f06ae95164b595ec", size = 140752 },
+ { url = "https://files.pythonhosted.org/packages/fc/fe/dad52d8315a65f084044a0819d74c4c9daf9ebe0681d30f525b0d29a31f0/orjson-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72c5005eb45bd2535632d4f3bec7ad392832cfc46b62a3021da3b48a67734b45", size = 144201 },
+ { url = "https://files.pythonhosted.org/packages/36/bc/ab070dd421565b831801077f1e390c4d4af8bfcecafc110336680a33866b/orjson-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b14dd49f3462b014455a28a4d810d3549bf990567653eb43765cd847df09145", size = 142380 },
+ { url = "https://files.pythonhosted.org/packages/e6/d8/4b581c725c3a308717f28bf45a9fdac210bca08b67e8430143699413ff06/orjson-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bb2c1ea30ef302f0f89f9bf3e7f9ab5e2af29dc9f80eb87aa99788e4e2d65", size = 145582 },
+ { url = "https://files.pythonhosted.org/packages/5b/a2/09aab99b39f9a7f175ea8fa29adb9933a3d01e7d5d603cdee7f1c40c8da2/orjson-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:825e0a85d189533c6bff7e2fc417a28f6fcea53d27125c4551979aecd6c9a197", size = 147270 },
+ { url = "https://files.pythonhosted.org/packages/b8/2f/5ef8eaf7829dc50da3bf497c7775b21ee88437bc8c41f959aa3504ca6631/orjson-3.11.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b04575417a26530637f6ab4b1f7b4f666eb0433491091da4de38611f97f2fcf3", size = 421222 },
+ { url = "https://files.pythonhosted.org/packages/3b/b0/dd6b941294c2b5b13da5fdc7e749e58d0c55a5114ab37497155e83050e95/orjson-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b83eb2e40e8c4da6d6b340ee6b1d6125f5195eb1b0ebb7eac23c6d9d4f92d224", size = 155562 },
+ { url = "https://files.pythonhosted.org/packages/8e/09/43924331a847476ae2f9a16bd6d3c9dab301265006212ba0d3d7fd58763a/orjson-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f42da604ee65a6b87eef858c913ce3e5777872b19321d11e6fc6d21de89b64f", size = 147432 },
+ { url = "https://files.pythonhosted.org/packages/5d/e9/d9865961081816909f6b49d880749dbbd88425afd7c5bbce0549e2290d77/orjson-3.11.6-cp311-cp311-win32.whl", hash = "sha256:5ae45df804f2d344cffb36c43fdf03c82fb6cd247f5faa41e21891b40dfbf733", size = 139623 },
+ { url = "https://files.pythonhosted.org/packages/b4/f9/6836edb92f76eec1082919101eb1145d2f9c33c8f2c5e6fa399b82a2aaa8/orjson-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:f4295948d65ace0a2d8f2c4ccc429668b7eb8af547578ec882e16bf79b0050b2", size = 136647 },
+ { url = "https://files.pythonhosted.org/packages/b3/0c/4954082eea948c9ae52ee0bcbaa2f99da3216a71bcc314ab129bde22e565/orjson-3.11.6-cp311-cp311-win_arm64.whl", hash = "sha256:314e9c45e0b81b547e3a1cfa3df3e07a815821b3dac9fe8cb75014071d0c16a4", size = 135327 },
+ { url = "https://files.pythonhosted.org/packages/14/ba/759f2879f41910b7e5e0cdbd9cf82a4f017c527fb0e972e9869ca7fe4c8e/orjson-3.11.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6f03f30cd8953f75f2a439070c743c7336d10ee940da918d71c6f3556af3ddcf", size = 249988 },
+ { url = "https://files.pythonhosted.org/packages/f0/70/54cecb929e6c8b10104fcf580b0cc7dc551aa193e83787dd6f3daba28bb5/orjson-3.11.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:af44baae65ef386ad971469a8557a0673bb042b0b9fd4397becd9c2dfaa02588", size = 134445 },
+ { url = "https://files.pythonhosted.org/packages/f2/6f/ec0309154457b9ba1ad05f11faa4441f76037152f75e1ac577db3ce7ca96/orjson-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c310a48542094e4f7dbb6ac076880994986dda8ca9186a58c3cb70a3514d3231", size = 137708 },
+ { url = "https://files.pythonhosted.org/packages/20/52/3c71b80840f8bab9cb26417302707b7716b7d25f863f3a541bcfa232fe6e/orjson-3.11.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8dfa7a5d387f15ecad94cb6b2d2d5f4aeea64efd8d526bfc03c9812d01e1cc0", size = 134798 },
+ { url = "https://files.pythonhosted.org/packages/30/51/b490a43b22ff736282360bd02e6bded455cf31dfc3224e01cd39f919bbd2/orjson-3.11.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8daee3e999411b50f8b50dbb0a3071dd1845f3f9a1a0a6fa6de86d1689d84d", size = 140839 },
+ { url = "https://files.pythonhosted.org/packages/95/bc/4bcfe4280c1bc63c5291bb96f98298845b6355da2226d3400e17e7b51e53/orjson-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89d104c974eafd7436d7a5fdbc57f7a1e776789959a2f4f1b2eab5c62a339f4", size = 144080 },
+ { url = "https://files.pythonhosted.org/packages/01/74/22970f9ead9ab1f1b5f8c227a6c3aa8d71cd2c5acd005868a1d44f2362fa/orjson-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e2e2456788ca5ea75616c40da06fc885a7dc0389780e8a41bf7c5389ba257b", size = 142435 },
+ { url = "https://files.pythonhosted.org/packages/29/34/d564aff85847ab92c82ee43a7a203683566c2fca0723a5f50aebbe759603/orjson-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a42efebc45afabb1448001e90458c4020d5c64fbac8a8dc4045b777db76cb5a", size = 145631 },
+ { url = "https://files.pythonhosted.org/packages/e7/ef/016957a3890752c4aa2368326ea69fa53cdc1fdae0a94a542b6410dbdf52/orjson-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71b7cbef8471324966c3738c90ba38775563ef01b512feb5ad4805682188d1b9", size = 147058 },
+ { url = "https://files.pythonhosted.org/packages/56/cc/9a899c3972085645b3225569f91a30e221f441e5dc8126e6d060b971c252/orjson-3.11.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f8515e5910f454fe9a8e13c2bb9dc4bae4c1836313e967e72eb8a4ad874f0248", size = 421161 },
+ { url = "https://files.pythonhosted.org/packages/21/a8/767d3fbd6d9b8fdee76974db40619399355fd49bf91a6dd2c4b6909ccf05/orjson-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:300360edf27c8c9bf7047345a94fddf3a8b8922df0ff69d71d854a170cb375cf", size = 155757 },
+ { url = "https://files.pythonhosted.org/packages/ad/0b/205cd69ac87e2272e13ef3f5f03a3d4657e317e38c1b08aaa2ef97060bbc/orjson-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:caaed4dad39e271adfadc106fab634d173b2bb23d9cf7e67bd645f879175ebfc", size = 147446 },
+ { url = "https://files.pythonhosted.org/packages/de/c5/dd9f22aa9f27c54c7d05cc32f4580c9ac9b6f13811eeb81d6c4c3f50d6b1/orjson-3.11.6-cp312-cp312-win32.whl", hash = "sha256:955368c11808c89793e847830e1b1007503a5923ddadc108547d3b77df761044", size = 139717 },
+ { url = "https://files.pythonhosted.org/packages/23/a1/e62fc50d904486970315a1654b8cfb5832eb46abb18cd5405118e7e1fc79/orjson-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:2c68de30131481150073d90a5d227a4a421982f42c025ecdfb66157f9579e06f", size = 136711 },
+ { url = "https://files.pythonhosted.org/packages/04/3d/b4fefad8bdf91e0fe212eb04975aeb36ea92997269d68857efcc7eb1dda3/orjson-3.11.6-cp312-cp312-win_arm64.whl", hash = "sha256:65dfa096f4e3a5e02834b681f539a87fbe85adc82001383c0db907557f666bfc", size = 135212 },
+ { url = "https://files.pythonhosted.org/packages/ae/45/d9c71c8c321277bc1ceebf599bc55ba826ae538b7c61f287e9a7e71bd589/orjson-3.11.6-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e4ae1670caabb598a88d385798692ce2a1b2f078971b3329cfb85253c6097f5b", size = 249828 },
+ { url = "https://files.pythonhosted.org/packages/ac/7e/4afcf4cfa9c2f93846d70eee9c53c3c0123286edcbeb530b7e9bd2aea1b2/orjson-3.11.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2c6b81f47b13dac2caa5d20fbc953c75eb802543abf48403a4703ed3bff225f0", size = 134339 },
+ { url = "https://files.pythonhosted.org/packages/40/10/6d2b8a064c8d2411d3d0ea6ab43125fae70152aef6bea77bb50fa54d4097/orjson-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:647d6d034e463764e86670644bdcaf8e68b076e6e74783383b01085ae9ab334f", size = 137662 },
+ { url = "https://files.pythonhosted.org/packages/5a/50/5804ea7d586baf83ee88969eefda97a24f9a5bdba0727f73e16305175b26/orjson-3.11.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8523b9cc4ef174ae52414f7699e95ee657c16aa18b3c3c285d48d7966cce9081", size = 134626 },
+ { url = "https://files.pythonhosted.org/packages/9e/2e/f0492ed43e376722bb4afd648e06cc1e627fc7ec8ff55f6ee739277813ea/orjson-3.11.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313dfd7184cde50c733fc0d5c8c0e2f09017b573afd11dc36bd7476b30b4cb17", size = 140873 },
+ { url = "https://files.pythonhosted.org/packages/10/15/6f874857463421794a303a39ac5494786ad46a4ab46d92bda6705d78c5aa/orjson-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905ee036064ff1e1fd1fb800055ac477cdcb547a78c22c1bc2bbf8d5d1a6fb42", size = 144044 },
+ { url = "https://files.pythonhosted.org/packages/d2/c7/b7223a3a70f1d0cc2d86953825de45f33877ee1b124a91ca1f79aa6e643f/orjson-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce374cb98411356ba906914441fc993f271a7a666d838d8de0e0900dd4a4bc12", size = 142396 },
+ { url = "https://files.pythonhosted.org/packages/87/e3/aa1b6d3ad3cd80f10394134f73ae92a1d11fdbe974c34aa199cc18bb5fcf/orjson-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cded072b9f65fcfd188aead45efa5bd528ba552add619b3ad2a81f67400ec450", size = 145600 },
+ { url = "https://files.pythonhosted.org/packages/f6/cf/e4aac5a46cbd39d7e769ef8650efa851dfce22df1ba97ae2b33efe893b12/orjson-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ab85bdbc138e1f73a234db6bb2e4cc1f0fcec8f4bd2bd2430e957a01aadf746", size = 146967 },
+ { url = "https://files.pythonhosted.org/packages/0b/04/975b86a4bcf6cfeda47aad15956d52fbeda280811206e9967380fa9355c8/orjson-3.11.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:351b96b614e3c37a27b8ab048239ebc1e0be76cc17481a430d70a77fb95d3844", size = 421003 },
+ { url = "https://files.pythonhosted.org/packages/28/d1/0369d0baf40eea5ff2300cebfe209883b2473ab4aa4c4974c8bd5ee42bb2/orjson-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f9959c85576beae5cdcaaf39510b15105f1ee8b70d5dacd90152617f57be8c83", size = 155695 },
+ { url = "https://files.pythonhosted.org/packages/ab/1f/d10c6d6ae26ff1d7c3eea6fd048280ef2e796d4fb260c5424fd021f68ecf/orjson-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75682d62b1b16b61a30716d7a2ec1f4c36195de4a1c61f6665aedd947b93a5d5", size = 147392 },
+ { url = "https://files.pythonhosted.org/packages/8d/43/7479921c174441a0aa5277c313732e20713c0969ac303be9f03d88d3db5d/orjson-3.11.6-cp313-cp313-win32.whl", hash = "sha256:40dc277999c2ef227dcc13072be879b4cfd325502daeb5c35ed768f706f2bf30", size = 139718 },
+ { url = "https://files.pythonhosted.org/packages/88/bc/9ffe7dfbf8454bc4e75bb8bf3a405ed9e0598df1d3535bb4adcd46be07d0/orjson-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0f6e9f8ff7905660bc3c8a54cd4a675aa98f7f175cf00a59815e2ff42c0d916", size = 136635 },
+ { url = "https://files.pythonhosted.org/packages/6f/7e/51fa90b451470447ea5023b20d83331ec741ae28d1e6d8ed547c24e7de14/orjson-3.11.6-cp313-cp313-win_arm64.whl", hash = "sha256:1608999478664de848e5900ce41f25c4ecdfc4beacbc632b6fd55e1a586e5d38", size = 135175 },
+ { url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823 },
+ { url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328 },
+ { url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651 },
+ { url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596 },
+ { url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923 },
+ { url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068 },
+ { url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493 },
+ { url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616 },
+ { url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951 },
+ { url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024 },
+ { url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774 },
+ { url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393 },
+ { url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760 },
+ { url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633 },
+ { url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168 },
]
[[package]]
name = "ormsgpack"
version = "1.12.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" },
- { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" },
- { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" },
- { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" },
- { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" },
- { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" },
- { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" },
- { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" },
- { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" },
- { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" },
- { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" },
- { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" },
- { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" },
- { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" },
- { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" },
- { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" },
- { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" },
- { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" },
- { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" },
- { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" },
- { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" },
- { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" },
- { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" },
- { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" },
- { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" },
- { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" },
- { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" },
- { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" },
- { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" },
- { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" },
- { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" },
- { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" },
- { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" },
- { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" },
- { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" },
- { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" },
- { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" },
- { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" },
- { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" },
- { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266 },
+ { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035 },
+ { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539 },
+ { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401 },
+ { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082 },
+ { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346 },
+ { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181 },
+ { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182 },
+ { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464 },
+ { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618 },
+ { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186 },
+ { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738 },
+ { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569 },
+ { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166 },
+ { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498 },
+ { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518 },
+ { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462 },
+ { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559 },
+ { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661 },
+ { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194 },
+ { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778 },
+ { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592 },
+ { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164 },
+ { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516 },
+ { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539 },
+ { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459 },
+ { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577 },
+ { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717 },
+ { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183 },
+ { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814 },
+ { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634 },
+ { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139 },
+ { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578 },
+ { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539 },
+ { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493 },
+ { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579 },
+ { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721 },
+ { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170 },
+ { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816 },
+ { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232 },
]
[[package]]
name = "overrides"
version = "7.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832 },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
]
[[package]]
@@ -3685,64 +3674,64 @@ dependencies = [
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/46/1e/b184654a856e75e975a6ee95d6577b51c271cd92cb2b020c9378f53e0032/pandas-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d64ce01eb9cdca96a15266aa679ae50212ec52757c79204dbc7701a222401850", size = 10313247, upload-time = "2026-01-21T15:50:15.775Z" },
- { url = "https://files.pythonhosted.org/packages/dd/5e/e04a547ad0f0183bf151fd7c7a477468e3b85ff2ad231c566389e6cc9587/pandas-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:613e13426069793aa1ec53bdcc3b86e8d32071daea138bbcf4fa959c9cdaa2e2", size = 9913131, upload-time = "2026-01-21T15:50:18.611Z" },
- { url = "https://files.pythonhosted.org/packages/a2/93/bb77bfa9fc2aba9f7204db807d5d3fb69832ed2854c60ba91b4c65ba9219/pandas-3.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0192fee1f1a8e743b464a6607858ee4b071deb0b118eb143d71c2a1d170996d5", size = 10741925, upload-time = "2026-01-21T15:50:21.058Z" },
- { url = "https://files.pythonhosted.org/packages/62/fb/89319812eb1d714bfc04b7f177895caeba8ab4a37ef6712db75ed786e2e0/pandas-3.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b853319dec8d5e0c8b875374c078ef17f2269986a78168d9bd57e49bf650ae", size = 11245979, upload-time = "2026-01-21T15:50:23.413Z" },
- { url = "https://files.pythonhosted.org/packages/a9/63/684120486f541fc88da3862ed31165b3b3e12b6a1c7b93be4597bc84e26c/pandas-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:707a9a877a876c326ae2cb640fbdc4ef63b0a7b9e2ef55c6df9942dcee8e2af9", size = 11756337, upload-time = "2026-01-21T15:50:25.932Z" },
- { url = "https://files.pythonhosted.org/packages/39/92/7eb0ad232312b59aec61550c3c81ad0743898d10af5df7f80bc5e5065416/pandas-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:afd0aa3d0b5cda6e0b8ffc10dbcca3b09ef3cbcd3fe2b27364f85fdc04e1989d", size = 12325517, upload-time = "2026-01-21T15:50:27.952Z" },
- { url = "https://files.pythonhosted.org/packages/51/27/bf9436dd0a4fc3130acec0828951c7ef96a0631969613a9a35744baf27f6/pandas-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:113b4cca2614ff7e5b9fee9b6f066618fe73c5a83e99d721ffc41217b2bf57dd", size = 9881576, upload-time = "2026-01-21T15:50:30.149Z" },
- { url = "https://files.pythonhosted.org/packages/e7/2b/c618b871fce0159fd107516336e82891b404e3f340821853c2fc28c7830f/pandas-3.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c14837eba8e99a8da1527c0280bba29b0eb842f64aa94982c5e21227966e164b", size = 9140807, upload-time = "2026-01-21T15:50:32.308Z" },
- { url = "https://files.pythonhosted.org/packages/0b/38/db33686f4b5fa64d7af40d96361f6a4615b8c6c8f1b3d334eee46ae6160e/pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd", size = 10334013, upload-time = "2026-01-21T15:50:34.771Z" },
- { url = "https://files.pythonhosted.org/packages/a5/7b/9254310594e9774906bacdd4e732415e1f86ab7dbb4b377ef9ede58cd8ec/pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740", size = 9874154, upload-time = "2026-01-21T15:50:36.67Z" },
- { url = "https://files.pythonhosted.org/packages/63/d4/726c5a67a13bc66643e66d2e9ff115cead482a44fc56991d0c4014f15aaf/pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801", size = 10384433, upload-time = "2026-01-21T15:50:39.132Z" },
- { url = "https://files.pythonhosted.org/packages/bf/2e/9211f09bedb04f9832122942de8b051804b31a39cfbad199a819bb88d9f3/pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a", size = 10864519, upload-time = "2026-01-21T15:50:41.043Z" },
- { url = "https://files.pythonhosted.org/packages/00/8d/50858522cdc46ac88b9afdc3015e298959a70a08cd21e008a44e9520180c/pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb", size = 11394124, upload-time = "2026-01-21T15:50:43.377Z" },
- { url = "https://files.pythonhosted.org/packages/86/3f/83b2577db02503cd93d8e95b0f794ad9d4be0ba7cb6c8bcdcac964a34a42/pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f", size = 11920444, upload-time = "2026-01-21T15:50:45.932Z" },
- { url = "https://files.pythonhosted.org/packages/64/2d/4f8a2f192ed12c90a0aab47f5557ece0e56b0370c49de9454a09de7381b2/pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1", size = 9730970, upload-time = "2026-01-21T15:50:47.962Z" },
- { url = "https://files.pythonhosted.org/packages/d4/64/ff571be435cf1e643ca98d0945d76732c0b4e9c37191a89c8550b105eed1/pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0", size = 9041950, upload-time = "2026-01-21T15:50:50.422Z" },
- { url = "https://files.pythonhosted.org/packages/6f/fa/7f0ac4ca8877c57537aaff2a842f8760e630d8e824b730eb2e859ffe96ca/pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6", size = 10307129, upload-time = "2026-01-21T15:50:52.877Z" },
- { url = "https://files.pythonhosted.org/packages/6f/11/28a221815dcea4c0c9414dfc845e34a84a6a7dabc6da3194498ed5ba4361/pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f", size = 9850201, upload-time = "2026-01-21T15:50:54.807Z" },
- { url = "https://files.pythonhosted.org/packages/ba/da/53bbc8c5363b7e5bd10f9ae59ab250fc7a382ea6ba08e4d06d8694370354/pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70", size = 10354031, upload-time = "2026-01-21T15:50:57.463Z" },
- { url = "https://files.pythonhosted.org/packages/f7/a3/51e02ebc2a14974170d51e2410dfdab58870ea9bcd37cda15bd553d24dc4/pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e", size = 10861165, upload-time = "2026-01-21T15:50:59.32Z" },
- { url = "https://files.pythonhosted.org/packages/a5/fe/05a51e3cac11d161472b8297bd41723ea98013384dd6d76d115ce3482f9b/pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3", size = 11359359, upload-time = "2026-01-21T15:51:02.014Z" },
- { url = "https://files.pythonhosted.org/packages/ee/56/ba620583225f9b85a4d3e69c01df3e3870659cc525f67929b60e9f21dcd1/pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e", size = 11912907, upload-time = "2026-01-21T15:51:05.175Z" },
- { url = "https://files.pythonhosted.org/packages/c9/8c/c6638d9f67e45e07656b3826405c5cc5f57f6fd07c8b2572ade328c86e22/pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e", size = 9732138, upload-time = "2026-01-21T15:51:07.569Z" },
- { url = "https://files.pythonhosted.org/packages/7b/bf/bd1335c3bf1770b6d8fed2799993b11c4971af93bb1b729b9ebbc02ca2ec/pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be", size = 9033568, upload-time = "2026-01-21T15:51:09.484Z" },
- { url = "https://files.pythonhosted.org/packages/8e/c6/f5e2171914d5e29b9171d495344097d54e3ffe41d2d85d8115baba4dc483/pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98", size = 10741936, upload-time = "2026-01-21T15:51:11.693Z" },
- { url = "https://files.pythonhosted.org/packages/51/88/9a0164f99510a1acb9f548691f022c756c2314aad0d8330a24616c14c462/pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327", size = 10393884, upload-time = "2026-01-21T15:51:14.197Z" },
- { url = "https://files.pythonhosted.org/packages/e0/53/b34d78084d88d8ae2b848591229da8826d1e65aacf00b3abe34023467648/pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb", size = 10310740, upload-time = "2026-01-21T15:51:16.093Z" },
- { url = "https://files.pythonhosted.org/packages/5b/d3/bee792e7c3d6930b74468d990604325701412e55d7aaf47460a22311d1a5/pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812", size = 10700014, upload-time = "2026-01-21T15:51:18.818Z" },
- { url = "https://files.pythonhosted.org/packages/55/db/2570bc40fb13aaed1cbc3fbd725c3a60ee162477982123c3adc8971e7ac1/pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08", size = 11323737, upload-time = "2026-01-21T15:51:20.784Z" },
- { url = "https://files.pythonhosted.org/packages/bc/2e/297ac7f21c8181b62a4cccebad0a70caf679adf3ae5e83cb676194c8acc3/pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c", size = 11771558, upload-time = "2026-01-21T15:51:22.977Z" },
- { url = "https://files.pythonhosted.org/packages/0a/46/e1c6876d71c14332be70239acce9ad435975a80541086e5ffba2f249bcf6/pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa", size = 10473771, upload-time = "2026-01-21T15:51:25.285Z" },
- { url = "https://files.pythonhosted.org/packages/c0/db/0270ad9d13c344b7a36fa77f5f8344a46501abf413803e885d22864d10bf/pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b", size = 10312075, upload-time = "2026-01-21T15:51:28.5Z" },
- { url = "https://files.pythonhosted.org/packages/09/9f/c176f5e9717f7c91becfe0f55a52ae445d3f7326b4a2cf355978c51b7913/pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe", size = 9900213, upload-time = "2026-01-21T15:51:30.955Z" },
- { url = "https://files.pythonhosted.org/packages/d9/e7/63ad4cc10b257b143e0a5ebb04304ad806b4e1a61c5da25f55896d2ca0f4/pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70", size = 10428768, upload-time = "2026-01-21T15:51:33.018Z" },
- { url = "https://files.pythonhosted.org/packages/9e/0e/4e4c2d8210f20149fd2248ef3fff26623604922bd564d915f935a06dd63d/pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d", size = 10882954, upload-time = "2026-01-21T15:51:35.287Z" },
- { url = "https://files.pythonhosted.org/packages/c6/60/c9de8ac906ba1f4d2250f8a951abe5135b404227a55858a75ad26f84db47/pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986", size = 11430293, upload-time = "2026-01-21T15:51:37.57Z" },
- { url = "https://files.pythonhosted.org/packages/a1/69/806e6637c70920e5787a6d6896fd707f8134c2c55cd761e7249a97b7dc5a/pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49", size = 11952452, upload-time = "2026-01-21T15:51:39.618Z" },
- { url = "https://files.pythonhosted.org/packages/cb/de/918621e46af55164c400ab0ef389c9d969ab85a43d59ad1207d4ddbe30a5/pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7", size = 9851081, upload-time = "2026-01-21T15:51:41.758Z" },
- { url = "https://files.pythonhosted.org/packages/91/a1/3562a18dd0bd8c73344bfa26ff90c53c72f827df119d6d6b1dacc84d13e3/pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8", size = 9174610, upload-time = "2026-01-21T15:51:44.312Z" },
- { url = "https://files.pythonhosted.org/packages/ce/26/430d91257eaf366f1737d7a1c158677caaf6267f338ec74e3a1ec444111c/pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73", size = 10761999, upload-time = "2026-01-21T15:51:46.899Z" },
- { url = "https://files.pythonhosted.org/packages/ec/1a/954eb47736c2b7f7fe6a9d56b0cb6987773c00faa3c6451a43db4beb3254/pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2", size = 10410279, upload-time = "2026-01-21T15:51:48.89Z" },
- { url = "https://files.pythonhosted.org/packages/20/fc/b96f3a5a28b250cd1b366eb0108df2501c0f38314a00847242abab71bb3a/pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a", size = 10330198, upload-time = "2026-01-21T15:51:51.015Z" },
- { url = "https://files.pythonhosted.org/packages/90/b3/d0e2952f103b4fbef1ef22d0c2e314e74fc9064b51cee30890b5e3286ee6/pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084", size = 10728513, upload-time = "2026-01-21T15:51:53.387Z" },
- { url = "https://files.pythonhosted.org/packages/76/81/832894f286df828993dc5fd61c63b231b0fb73377e99f6c6c369174cf97e/pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721", size = 11345550, upload-time = "2026-01-21T15:51:55.329Z" },
- { url = "https://files.pythonhosted.org/packages/34/a0/ed160a00fb4f37d806406bc0a79a8b62fe67f29d00950f8d16203ff3409b/pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac", size = 11799386, upload-time = "2026-01-21T15:51:57.457Z" },
- { url = "https://files.pythonhosted.org/packages/36/c8/2ac00d7255252c5e3cf61b35ca92ca25704b0188f7454ca4aec08a33cece/pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb", size = 10873041, upload-time = "2026-01-21T15:52:00.034Z" },
- { url = "https://files.pythonhosted.org/packages/e6/3f/a80ac00acbc6b35166b42850e98a4f466e2c0d9c64054161ba9620f95680/pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", size = 9441003, upload-time = "2026-01-21T15:52:02.281Z" },
+ { url = "https://files.pythonhosted.org/packages/46/1e/b184654a856e75e975a6ee95d6577b51c271cd92cb2b020c9378f53e0032/pandas-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d64ce01eb9cdca96a15266aa679ae50212ec52757c79204dbc7701a222401850", size = 10313247 },
+ { url = "https://files.pythonhosted.org/packages/dd/5e/e04a547ad0f0183bf151fd7c7a477468e3b85ff2ad231c566389e6cc9587/pandas-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:613e13426069793aa1ec53bdcc3b86e8d32071daea138bbcf4fa959c9cdaa2e2", size = 9913131 },
+ { url = "https://files.pythonhosted.org/packages/a2/93/bb77bfa9fc2aba9f7204db807d5d3fb69832ed2854c60ba91b4c65ba9219/pandas-3.0.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0192fee1f1a8e743b464a6607858ee4b071deb0b118eb143d71c2a1d170996d5", size = 10741925 },
+ { url = "https://files.pythonhosted.org/packages/62/fb/89319812eb1d714bfc04b7f177895caeba8ab4a37ef6712db75ed786e2e0/pandas-3.0.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b853319dec8d5e0c8b875374c078ef17f2269986a78168d9bd57e49bf650ae", size = 11245979 },
+ { url = "https://files.pythonhosted.org/packages/a9/63/684120486f541fc88da3862ed31165b3b3e12b6a1c7b93be4597bc84e26c/pandas-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:707a9a877a876c326ae2cb640fbdc4ef63b0a7b9e2ef55c6df9942dcee8e2af9", size = 11756337 },
+ { url = "https://files.pythonhosted.org/packages/39/92/7eb0ad232312b59aec61550c3c81ad0743898d10af5df7f80bc5e5065416/pandas-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:afd0aa3d0b5cda6e0b8ffc10dbcca3b09ef3cbcd3fe2b27364f85fdc04e1989d", size = 12325517 },
+ { url = "https://files.pythonhosted.org/packages/51/27/bf9436dd0a4fc3130acec0828951c7ef96a0631969613a9a35744baf27f6/pandas-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:113b4cca2614ff7e5b9fee9b6f066618fe73c5a83e99d721ffc41217b2bf57dd", size = 9881576 },
+ { url = "https://files.pythonhosted.org/packages/e7/2b/c618b871fce0159fd107516336e82891b404e3f340821853c2fc28c7830f/pandas-3.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c14837eba8e99a8da1527c0280bba29b0eb842f64aa94982c5e21227966e164b", size = 9140807 },
+ { url = "https://files.pythonhosted.org/packages/0b/38/db33686f4b5fa64d7af40d96361f6a4615b8c6c8f1b3d334eee46ae6160e/pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd", size = 10334013 },
+ { url = "https://files.pythonhosted.org/packages/a5/7b/9254310594e9774906bacdd4e732415e1f86ab7dbb4b377ef9ede58cd8ec/pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740", size = 9874154 },
+ { url = "https://files.pythonhosted.org/packages/63/d4/726c5a67a13bc66643e66d2e9ff115cead482a44fc56991d0c4014f15aaf/pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801", size = 10384433 },
+ { url = "https://files.pythonhosted.org/packages/bf/2e/9211f09bedb04f9832122942de8b051804b31a39cfbad199a819bb88d9f3/pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a", size = 10864519 },
+ { url = "https://files.pythonhosted.org/packages/00/8d/50858522cdc46ac88b9afdc3015e298959a70a08cd21e008a44e9520180c/pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb", size = 11394124 },
+ { url = "https://files.pythonhosted.org/packages/86/3f/83b2577db02503cd93d8e95b0f794ad9d4be0ba7cb6c8bcdcac964a34a42/pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f", size = 11920444 },
+ { url = "https://files.pythonhosted.org/packages/64/2d/4f8a2f192ed12c90a0aab47f5557ece0e56b0370c49de9454a09de7381b2/pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1", size = 9730970 },
+ { url = "https://files.pythonhosted.org/packages/d4/64/ff571be435cf1e643ca98d0945d76732c0b4e9c37191a89c8550b105eed1/pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0", size = 9041950 },
+ { url = "https://files.pythonhosted.org/packages/6f/fa/7f0ac4ca8877c57537aaff2a842f8760e630d8e824b730eb2e859ffe96ca/pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6", size = 10307129 },
+ { url = "https://files.pythonhosted.org/packages/6f/11/28a221815dcea4c0c9414dfc845e34a84a6a7dabc6da3194498ed5ba4361/pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f", size = 9850201 },
+ { url = "https://files.pythonhosted.org/packages/ba/da/53bbc8c5363b7e5bd10f9ae59ab250fc7a382ea6ba08e4d06d8694370354/pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70", size = 10354031 },
+ { url = "https://files.pythonhosted.org/packages/f7/a3/51e02ebc2a14974170d51e2410dfdab58870ea9bcd37cda15bd553d24dc4/pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e", size = 10861165 },
+ { url = "https://files.pythonhosted.org/packages/a5/fe/05a51e3cac11d161472b8297bd41723ea98013384dd6d76d115ce3482f9b/pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3", size = 11359359 },
+ { url = "https://files.pythonhosted.org/packages/ee/56/ba620583225f9b85a4d3e69c01df3e3870659cc525f67929b60e9f21dcd1/pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e", size = 11912907 },
+ { url = "https://files.pythonhosted.org/packages/c9/8c/c6638d9f67e45e07656b3826405c5cc5f57f6fd07c8b2572ade328c86e22/pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e", size = 9732138 },
+ { url = "https://files.pythonhosted.org/packages/7b/bf/bd1335c3bf1770b6d8fed2799993b11c4971af93bb1b729b9ebbc02ca2ec/pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be", size = 9033568 },
+ { url = "https://files.pythonhosted.org/packages/8e/c6/f5e2171914d5e29b9171d495344097d54e3ffe41d2d85d8115baba4dc483/pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98", size = 10741936 },
+ { url = "https://files.pythonhosted.org/packages/51/88/9a0164f99510a1acb9f548691f022c756c2314aad0d8330a24616c14c462/pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327", size = 10393884 },
+ { url = "https://files.pythonhosted.org/packages/e0/53/b34d78084d88d8ae2b848591229da8826d1e65aacf00b3abe34023467648/pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb", size = 10310740 },
+ { url = "https://files.pythonhosted.org/packages/5b/d3/bee792e7c3d6930b74468d990604325701412e55d7aaf47460a22311d1a5/pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812", size = 10700014 },
+ { url = "https://files.pythonhosted.org/packages/55/db/2570bc40fb13aaed1cbc3fbd725c3a60ee162477982123c3adc8971e7ac1/pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08", size = 11323737 },
+ { url = "https://files.pythonhosted.org/packages/bc/2e/297ac7f21c8181b62a4cccebad0a70caf679adf3ae5e83cb676194c8acc3/pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c", size = 11771558 },
+ { url = "https://files.pythonhosted.org/packages/0a/46/e1c6876d71c14332be70239acce9ad435975a80541086e5ffba2f249bcf6/pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa", size = 10473771 },
+ { url = "https://files.pythonhosted.org/packages/c0/db/0270ad9d13c344b7a36fa77f5f8344a46501abf413803e885d22864d10bf/pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b", size = 10312075 },
+ { url = "https://files.pythonhosted.org/packages/09/9f/c176f5e9717f7c91becfe0f55a52ae445d3f7326b4a2cf355978c51b7913/pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe", size = 9900213 },
+ { url = "https://files.pythonhosted.org/packages/d9/e7/63ad4cc10b257b143e0a5ebb04304ad806b4e1a61c5da25f55896d2ca0f4/pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70", size = 10428768 },
+ { url = "https://files.pythonhosted.org/packages/9e/0e/4e4c2d8210f20149fd2248ef3fff26623604922bd564d915f935a06dd63d/pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d", size = 10882954 },
+ { url = "https://files.pythonhosted.org/packages/c6/60/c9de8ac906ba1f4d2250f8a951abe5135b404227a55858a75ad26f84db47/pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986", size = 11430293 },
+ { url = "https://files.pythonhosted.org/packages/a1/69/806e6637c70920e5787a6d6896fd707f8134c2c55cd761e7249a97b7dc5a/pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49", size = 11952452 },
+ { url = "https://files.pythonhosted.org/packages/cb/de/918621e46af55164c400ab0ef389c9d969ab85a43d59ad1207d4ddbe30a5/pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7", size = 9851081 },
+ { url = "https://files.pythonhosted.org/packages/91/a1/3562a18dd0bd8c73344bfa26ff90c53c72f827df119d6d6b1dacc84d13e3/pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8", size = 9174610 },
+ { url = "https://files.pythonhosted.org/packages/ce/26/430d91257eaf366f1737d7a1c158677caaf6267f338ec74e3a1ec444111c/pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73", size = 10761999 },
+ { url = "https://files.pythonhosted.org/packages/ec/1a/954eb47736c2b7f7fe6a9d56b0cb6987773c00faa3c6451a43db4beb3254/pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2", size = 10410279 },
+ { url = "https://files.pythonhosted.org/packages/20/fc/b96f3a5a28b250cd1b366eb0108df2501c0f38314a00847242abab71bb3a/pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a", size = 10330198 },
+ { url = "https://files.pythonhosted.org/packages/90/b3/d0e2952f103b4fbef1ef22d0c2e314e74fc9064b51cee30890b5e3286ee6/pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084", size = 10728513 },
+ { url = "https://files.pythonhosted.org/packages/76/81/832894f286df828993dc5fd61c63b231b0fb73377e99f6c6c369174cf97e/pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721", size = 11345550 },
+ { url = "https://files.pythonhosted.org/packages/34/a0/ed160a00fb4f37d806406bc0a79a8b62fe67f29d00950f8d16203ff3409b/pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac", size = 11799386 },
+ { url = "https://files.pythonhosted.org/packages/36/c8/2ac00d7255252c5e3cf61b35ca92ca25704b0188f7454ca4aec08a33cece/pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb", size = 10873041 },
+ { url = "https://files.pythonhosted.org/packages/e6/3f/a80ac00acbc6b35166b42850e98a4f466e2c0d9c64054161ba9620f95680/pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", size = 9441003 },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 },
]
[[package]]
@@ -3752,123 +3741,123 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441, upload-time = "2025-12-05T01:07:16.536Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/26/6cee8a1ce8c43625ec561aff19df07f9776b7525d9002c86bceb3e0ac970/pgvector-0.4.2-py3-none-any.whl", hash = "sha256:549d45f7a18593783d5eec609ea1684a724ba8405c4cb182a0b2b08aeff04e08", size = 27441 },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
- { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
- { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
- { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
- { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
- { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
- { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
- { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
- { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
- { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
- { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
- { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
- { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
- { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
- { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
- { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
- { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
- { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
- { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
- { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
- { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
- { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
- { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
- { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
- { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
- { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
- { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
- { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
- { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
- { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
- { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
- { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
- { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
- { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
- { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
- { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
- { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
- { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
- { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
- { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
- { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
- { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
- { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
- { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
- { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
- { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
- { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
- { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
- { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
- { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
- { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
- { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
- { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
- { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
- { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
- { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
- { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
- { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
- { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
- { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
- { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
- { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
- { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
- { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
- { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
- { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
- { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
- { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
- { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
- { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
- { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
- { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
- { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
- { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
- { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
- { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
- { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
- { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
- { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347 },
+ { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873 },
+ { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168 },
+ { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188 },
+ { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401 },
+ { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655 },
+ { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105 },
+ { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402 },
+ { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149 },
+ { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626 },
+ { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531 },
+ { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279 },
+ { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490 },
+ { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462 },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744 },
+ { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371 },
+ { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215 },
+ { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783 },
+ { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112 },
+ { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489 },
+ { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129 },
+ { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612 },
+ { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837 },
+ { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528 },
+ { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401 },
+ { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094 },
+ { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402 },
+ { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005 },
+ { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669 },
+ { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194 },
+ { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423 },
+ { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667 },
+ { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580 },
+ { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896 },
+ { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266 },
+ { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508 },
+ { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927 },
+ { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624 },
+ { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252 },
+ { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550 },
+ { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114 },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667 },
+ { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966 },
+ { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241 },
+ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592 },
+ { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542 },
+ { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765 },
+ { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848 },
+ { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515 },
+ { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159 },
+ { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185 },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386 },
+ { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384 },
+ { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599 },
+ { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021 },
+ { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360 },
+ { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628 },
+ { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321 },
+ { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723 },
+ { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400 },
+ { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835 },
+ { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225 },
+ { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541 },
+ { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251 },
+ { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807 },
+ { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935 },
+ { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720 },
+ { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498 },
+ { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413 },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084 },
+ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152 },
+ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579 },
+ { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969 },
+ { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674 },
+ { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479 },
+ { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230 },
+ { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404 },
+ { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215 },
+ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946 },
]
[[package]]
name = "pip"
version = "26.1.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144 },
]
[[package]]
name = "platformdirs"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 },
]
[[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" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
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" },
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
@@ -3878,9 +3867,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pywin32", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 },
]
[[package]]
@@ -3894,9 +3883,9 @@ dependencies = [
{ name = "requests" },
{ name = "six" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364 },
]
[[package]]
@@ -3910,347 +3899,337 @@ dependencies = [
{ name = "pyyaml" },
{ name = "virtualenv" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 },
]
[[package]]
name = "priority"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946 },
]
[[package]]
name = "propcache"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" },
- { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" },
- { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" },
- { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" },
- { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" },
- { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" },
- { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" },
- { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" },
- { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" },
- { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" },
- { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" },
- { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" },
- { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" },
- { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" },
- { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },
- { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },
- { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },
- { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },
- { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },
- { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },
- { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },
- { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },
- { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },
- { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },
- { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },
- { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },
- { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },
- { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },
- { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },
- { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
- { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
- { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
- { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
- { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
- { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
- { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
- { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
- { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
- { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
- { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
- { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
- { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
- { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
- { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
- { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
- { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
- { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
- { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
- { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
- { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
- { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
- { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
- { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
- { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
- { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
- { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
- { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
- { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
- { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
- { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
- { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
- { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
- { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
- { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
- { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
- { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
- { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
- { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
- { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
- { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
- { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
- { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
- { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
- { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
- { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
- { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
- { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
- { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
- { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
- { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
- { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
- { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
- { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
- { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
- { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
- { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
- { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
- { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
- { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
- { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208 },
+ { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777 },
+ { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647 },
+ { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929 },
+ { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778 },
+ { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144 },
+ { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030 },
+ { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252 },
+ { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064 },
+ { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429 },
+ { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727 },
+ { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097 },
+ { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084 },
+ { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637 },
+ { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064 },
+ { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061 },
+ { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037 },
+ { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324 },
+ { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505 },
+ { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242 },
+ { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474 },
+ { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575 },
+ { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736 },
+ { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019 },
+ { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376 },
+ { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988 },
+ { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615 },
+ { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066 },
+ { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655 },
+ { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789 },
+ { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750 },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780 },
+ { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308 },
+ { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182 },
+ { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215 },
+ { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112 },
+ { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442 },
+ { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398 },
+ { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920 },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748 },
+ { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877 },
+ { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437 },
+ { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586 },
+ { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790 },
+ { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158 },
+ { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451 },
+ { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374 },
+ { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396 },
+ { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950 },
+ { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856 },
+ { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420 },
+ { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254 },
+ { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205 },
+ { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873 },
+ { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739 },
+ { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514 },
+ { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781 },
+ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396 },
+ { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897 },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789 },
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152 },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869 },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596 },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981 },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490 },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371 },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424 },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566 },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130 },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625 },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209 },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797 },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140 },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257 },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097 },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455 },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372 },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411 },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712 },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557 },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015 },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880 },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938 },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641 },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510 },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161 },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393 },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546 },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259 },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428 },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 },
]
[[package]]
name = "protobuf"
version = "6.33.5"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
- { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
- { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
- { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
- { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
- { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
- { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769 },
+ { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118 },
+ { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766 },
+ { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638 },
+ { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411 },
+ { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465 },
+ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687 },
]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
- { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
- { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
- { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
- { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
- { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
- { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
- { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
- { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
- { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
- { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
- { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
- { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
- { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
- { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
- { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
- { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
- { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
- { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
- { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595 },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082 },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476 },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062 },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893 },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589 },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664 },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087 },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383 },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210 },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228 },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284 },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090 },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859 },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560 },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997 },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972 },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266 },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737 },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617 },
]
[[package]]
name = "pybase64"
version = "1.4.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272, upload-time = "2025-12-06T13:27:04.013Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/b8/4ed5c7ad5ec15b08d35cc79ace6145d5c1ae426e46435f4987379439dfea/pybase64-1.4.3.tar.gz", hash = "sha256:c2ed274c9e0ba9c8f9c4083cfe265e66dd679126cd9c2027965d807352f3f053", size = 137272 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237, upload-time = "2025-12-06T13:22:52.159Z" },
- { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673, upload-time = "2025-12-06T13:22:53.241Z" },
- { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331, upload-time = "2025-12-06T13:22:54.197Z" },
- { url = "https://files.pythonhosted.org/packages/64/15/8d60b9ec5e658185fc2ee3333e01a6e30d717cf677b24f47cbb3a859d13c/pybase64-1.4.3-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95a57cccf106352a72ed8bc8198f6820b16cc7d55aa3867a16dea7011ae7c218", size = 71370, upload-time = "2025-12-06T13:22:55.517Z" },
- { url = "https://files.pythonhosted.org/packages/ac/29/a3e5c1667cc8c38d025a4636855de0fc117fc62e2afeb033a3c6f12c6a22/pybase64-1.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cd1c47dfceb9c7bd3de210fb4e65904053ed2d7c9dce6d107f041ff6fbd7e21", size = 59834, upload-time = "2025-12-06T13:22:56.682Z" },
- { url = "https://files.pythonhosted.org/packages/a9/00/8ffcf9810bd23f3984698be161cf7edba656fd639b818039a7be1d6405d4/pybase64-1.4.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9fe9922698f3e2f72874b26890d53a051c431d942701bb3a37aae94da0b12107", size = 56652, upload-time = "2025-12-06T13:22:57.724Z" },
- { url = "https://files.pythonhosted.org/packages/81/62/379e347797cdea4ab686375945bc77ad8d039c688c0d4d0cfb09d247beb9/pybase64-1.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:af5f4bd29c86b59bb4375e0491d16ec8a67548fa99c54763aaedaf0b4b5a6632", size = 59382, upload-time = "2025-12-06T13:22:58.758Z" },
- { url = "https://files.pythonhosted.org/packages/c6/f2/9338ffe2f487086f26a2c8ca175acb3baa86fce0a756ff5670a0822bb877/pybase64-1.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c302f6ca7465262908131411226e02100f488f531bb5e64cb901aa3f439bccd9", size = 59990, upload-time = "2025-12-06T13:23:01.007Z" },
- { url = "https://files.pythonhosted.org/packages/f9/a4/85a6142b65b4df8625b337727aa81dc199642de3d09677804141df6ee312/pybase64-1.4.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2f3f439fa4d7fde164ebbbb41968db7d66b064450ab6017c6c95cef0afa2b349", size = 54923, upload-time = "2025-12-06T13:23:02.369Z" },
- { url = "https://files.pythonhosted.org/packages/ac/00/e40215d25624012bf5b7416ca37f168cb75f6dd15acdb91ea1f2ea4dc4e7/pybase64-1.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a23c6866551043f8b681a5e1e0d59469148b2920a3b4fc42b1275f25ea4217a", size = 58664, upload-time = "2025-12-06T13:23:03.378Z" },
- { url = "https://files.pythonhosted.org/packages/b0/73/d7e19a63e795c13837f2356268d95dc79d1180e756f57ced742a1e52fdeb/pybase64-1.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:56e6526f8565642abc5f84338cc131ce298a8ccab696b19bdf76fa6d7dc592ef", size = 52338, upload-time = "2025-12-06T13:23:04.458Z" },
- { url = "https://files.pythonhosted.org/packages/f2/32/3c746d7a310b69bdd9df77ffc85c41b80bce00a774717596f869b0d4a20e/pybase64-1.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6a792a8b9d866ffa413c9687d9b611553203753987a3a582d68cbc51cf23da45", size = 68993, upload-time = "2025-12-06T13:23:05.526Z" },
- { url = "https://files.pythonhosted.org/packages/5d/b3/63cec68f9d6f6e4c0b438d14e5f1ef536a5fe63ce14b70733ac5e31d7ab8/pybase64-1.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62ad29a5026bb22cfcd1ca484ec34b0a5ced56ddba38ceecd9359b2818c9c4f9", size = 58055, upload-time = "2025-12-06T13:23:06.931Z" },
- { url = "https://files.pythonhosted.org/packages/d5/cb/7acf7c3c06f9692093c07f109668725dc37fb9a3df0fa912b50add645195/pybase64-1.4.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11b9d1d2d32ec358c02214363b8fc3651f6be7dd84d880ecd597a6206a80e121", size = 54430, upload-time = "2025-12-06T13:23:07.936Z" },
- { url = "https://files.pythonhosted.org/packages/33/39/4eb33ff35d173bfff4002e184ce8907f5d0a42d958d61cd9058ef3570179/pybase64-1.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0aebaa7f238caa0a0d373616016e2040c6c879ebce3ba7ab3c59029920f13640", size = 56272, upload-time = "2025-12-06T13:23:09.253Z" },
- { url = "https://files.pythonhosted.org/packages/19/97/a76d65c375a254e65b730c6f56bf528feca91305da32eceab8bcc08591e6/pybase64-1.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e504682b20c63c2b0c000e5f98a80ea867f8d97642e042a5a39818e44ba4d599", size = 70904, upload-time = "2025-12-06T13:23:10.336Z" },
- { url = "https://files.pythonhosted.org/packages/5e/2c/8338b6d3da3c265002839e92af0a80d6db88385c313c73f103dfb800c857/pybase64-1.4.3-cp311-cp311-win32.whl", hash = "sha256:e9a8b81984e3c6fb1db9e1614341b0a2d98c0033d693d90c726677db1ffa3a4c", size = 33639, upload-time = "2025-12-06T13:23:11.9Z" },
- { url = "https://files.pythonhosted.org/packages/39/dc/32efdf2f5927e5449cc341c266a1bbc5fecd5319a8807d9c5405f76e6d02/pybase64-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:a90a8fa16a901fabf20de824d7acce07586e6127dc2333f1de05f73b1f848319", size = 35797, upload-time = "2025-12-06T13:23:13.174Z" },
- { url = "https://files.pythonhosted.org/packages/da/59/eda4f9cb0cbce5a45f0cd06131e710674f8123a4d570772c5b9694f88559/pybase64-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:61d87de5bc94d143622e94390ec3e11b9c1d4644fe9be3a81068ab0f91056f59", size = 31160, upload-time = "2025-12-06T13:23:15.696Z" },
- { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167, upload-time = "2025-12-06T13:23:16.821Z" },
- { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673, upload-time = "2025-12-06T13:23:17.835Z" },
- { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210, upload-time = "2025-12-06T13:23:18.813Z" },
- { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599, upload-time = "2025-12-06T13:23:20.195Z" },
- { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922, upload-time = "2025-12-06T13:23:21.487Z" },
- { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712, upload-time = "2025-12-06T13:23:22.77Z" },
- { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300, upload-time = "2025-12-06T13:23:24.543Z" },
- { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278, upload-time = "2025-12-06T13:23:25.608Z" },
- { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817, upload-time = "2025-12-06T13:23:26.633Z" },
- { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611, upload-time = "2025-12-06T13:23:27.684Z" },
- { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404, upload-time = "2025-12-06T13:23:28.714Z" },
- { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817, upload-time = "2025-12-06T13:23:30.079Z" },
- { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854, upload-time = "2025-12-06T13:23:31.17Z" },
- { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333, upload-time = "2025-12-06T13:23:32.422Z" },
- { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492, upload-time = "2025-12-06T13:23:33.515Z" },
- { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974, upload-time = "2025-12-06T13:23:36.27Z" },
- { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686, upload-time = "2025-12-06T13:23:37.848Z" },
- { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833, upload-time = "2025-12-06T13:23:38.877Z" },
- { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185, upload-time = "2025-12-06T13:23:39.908Z" },
- { url = "https://files.pythonhosted.org/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1", size = 33901, upload-time = "2025-12-06T13:23:40.951Z" },
- { url = "https://files.pythonhosted.org/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2", size = 40807, upload-time = "2025-12-06T13:23:42.006Z" },
- { url = "https://files.pythonhosted.org/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d", size = 30932, upload-time = "2025-12-06T13:23:43.348Z" },
- { url = "https://files.pythonhosted.org/packages/f7/79/b1b550ac6bff51a4880bf6e089008b2e1ca16f2c98db5e039a08ac3ad157/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2a8204f1fdfec5aa4184249b51296c0de95445869920c88123978304aad42df1", size = 31394, upload-time = "2025-12-06T13:23:44.317Z" },
- { url = "https://files.pythonhosted.org/packages/82/70/b5d7c5932bf64ee1ec5da859fbac981930b6a55d432a603986c7f509c838/pybase64-1.4.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:874fc2a3777de6baf6aa921a7aa73b3be98295794bea31bd80568a963be30767", size = 38078, upload-time = "2025-12-06T13:23:45.348Z" },
- { url = "https://files.pythonhosted.org/packages/56/fe/e66fe373bce717c6858427670736d54297938dad61c5907517ab4106bd90/pybase64-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2dc64a94a9d936b8e3449c66afabbaa521d3cc1a563d6bbaaa6ffa4535222e4b", size = 38158, upload-time = "2025-12-06T13:23:46.872Z" },
- { url = "https://files.pythonhosted.org/packages/80/a9/b806ed1dcc7aed2ea3dd4952286319e6f3a8b48615c8118f453948e01999/pybase64-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e48f86de1c145116ccf369a6e11720ce696c2ec02d285f440dfb57ceaa0a6cb4", size = 31672, upload-time = "2025-12-06T13:23:47.88Z" },
- { url = "https://files.pythonhosted.org/packages/1c/c9/24b3b905cf75e23a9a4deaf203b35ffcb9f473ac0e6d8257f91a05dfce62/pybase64-1.4.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1d45c8fe8fe82b65c36b227bb4a2cf623d9ada16bed602ce2d3e18c35285b72a", size = 68244, upload-time = "2025-12-06T13:23:49.026Z" },
- { url = "https://files.pythonhosted.org/packages/f8/cd/d15b0c3e25e5859fab0416dc5b96d34d6bd2603c1c96a07bb2202b68ab92/pybase64-1.4.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad70c26ba091d8f5167e9d4e1e86a0483a5414805cdb598a813db635bd3be8b8", size = 71620, upload-time = "2025-12-06T13:23:50.081Z" },
- { url = "https://files.pythonhosted.org/packages/0d/31/4ca953cc3dcde2b3711d6bfd70a6f4ad2ca95a483c9698076ba605f1520f/pybase64-1.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e98310b7c43145221e7194ac9fa7fffc84763c87bfc5e2f59f9f92363475bdc1", size = 59930, upload-time = "2025-12-06T13:23:51.68Z" },
- { url = "https://files.pythonhosted.org/packages/60/55/e7f7bdcd0fd66e61dda08db158ffda5c89a306bbdaaf5a062fbe4e48f4a1/pybase64-1.4.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:398685a76034e91485a28aeebcb49e64cd663212fd697b2497ac6dfc1df5e671", size = 56425, upload-time = "2025-12-06T13:23:52.732Z" },
- { url = "https://files.pythonhosted.org/packages/cb/65/b592c7f921e51ca1aca3af5b0d201a98666d0a36b930ebb67e7c2ed27395/pybase64-1.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7e46400a6461187ccb52ed75b0045d937529e801a53a9cd770b350509f9e4d50", size = 59327, upload-time = "2025-12-06T13:23:53.856Z" },
- { url = "https://files.pythonhosted.org/packages/23/95/1613d2fb82dbb1548595ad4179f04e9a8451bfa18635efce18b631eabe3f/pybase64-1.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1b62b9f2f291d94f5e0b76ab499790b7dcc78a009d4ceea0b0428770267484b6", size = 60294, upload-time = "2025-12-06T13:23:54.937Z" },
- { url = "https://files.pythonhosted.org/packages/9d/73/40431f37f7d1b3eab4673e7946ff1e8f5d6bd425ec257e834dae8a6fc7b0/pybase64-1.4.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:f30ceb5fa4327809dede614be586efcbc55404406d71e1f902a6fdcf322b93b2", size = 54858, upload-time = "2025-12-06T13:23:56.031Z" },
- { url = "https://files.pythonhosted.org/packages/a7/84/f6368bcaf9f743732e002a9858646fd7a54f428490d427dd6847c5cfe89e/pybase64-1.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0d5f18ed53dfa1d4cf8b39ee542fdda8e66d365940e11f1710989b3cf4a2ed66", size = 58629, upload-time = "2025-12-06T13:23:57.12Z" },
- { url = "https://files.pythonhosted.org/packages/43/75/359532f9adb49c6b546cafc65c46ed75e2ccc220d514ba81c686fbd83965/pybase64-1.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:119d31aa4b58b85a8ebd12b63c07681a138c08dfc2fe5383459d42238665d3eb", size = 52448, upload-time = "2025-12-06T13:23:58.298Z" },
- { url = "https://files.pythonhosted.org/packages/92/6c/ade2ba244c3f33ed920a7ed572ad772eb0b5f14480b72d629d0c9e739a40/pybase64-1.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3cf0218b0e2f7988cf7d738a73b6a1d14f3be6ce249d7c0f606e768366df2cce", size = 68841, upload-time = "2025-12-06T13:23:59.886Z" },
- { url = "https://files.pythonhosted.org/packages/a0/51/b345139cd236be382f2d4d4453c21ee6299e14d2f759b668e23080f8663f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:12f4ee5e988bc5c0c1106b0d8fc37fb0508f12dab76bac1b098cb500d148da9d", size = 57910, upload-time = "2025-12-06T13:24:00.994Z" },
- { url = "https://files.pythonhosted.org/packages/1a/b8/9f84bdc4f1c4f0052489396403c04be2f9266a66b70c776001eaf0d78c1f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:937826bc7b6b95b594a45180e81dd4d99bd4dd4814a443170e399163f7ff3fb6", size = 54335, upload-time = "2025-12-06T13:24:02.046Z" },
- { url = "https://files.pythonhosted.org/packages/d0/c7/be63b617d284de46578a366da77ede39c8f8e815ed0d82c7c2acca560fab/pybase64-1.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:88995d1460971ef80b13e3e007afbe4b27c62db0508bc7250a2ab0a0b4b91362", size = 56486, upload-time = "2025-12-06T13:24:03.141Z" },
- { url = "https://files.pythonhosted.org/packages/5e/96/f252c8f9abd6ded3ef1ccd3cdbb8393a33798007f761b23df8de1a2480e6/pybase64-1.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:72326fe163385ed3e1e806dd579d47fde5d8a59e51297a60fc4e6cbc1b4fc4ed", size = 70978, upload-time = "2025-12-06T13:24:04.221Z" },
- { url = "https://files.pythonhosted.org/packages/af/51/0f5714af7aeef96e30f968e4371d75ad60558aaed3579d7c6c8f1c43c18a/pybase64-1.4.3-cp313-cp313-win32.whl", hash = "sha256:b1623730c7892cf5ed0d6355e375416be6ef8d53ab9b284f50890443175c0ac3", size = 33684, upload-time = "2025-12-06T13:24:05.29Z" },
- { url = "https://files.pythonhosted.org/packages/b6/ad/0cea830a654eb08563fb8214150ef57546ece1cc421c09035f0e6b0b5ea9/pybase64-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:8369887590f1646a5182ca2fb29252509da7ae31d4923dbb55d3e09da8cc4749", size = 35832, upload-time = "2025-12-06T13:24:06.35Z" },
- { url = "https://files.pythonhosted.org/packages/b4/0d/eec2a8214989c751bc7b4cad1860eb2c6abf466e76b77508c0f488c96a37/pybase64-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:860b86bca71e5f0237e2ab8b2d9c4c56681f3513b1bf3e2117290c1963488390", size = 31175, upload-time = "2025-12-06T13:24:07.419Z" },
- { url = "https://files.pythonhosted.org/packages/db/c9/e23463c1a2913686803ef76b1a5ae7e6fac868249a66e48253d17ad7232c/pybase64-1.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eb51db4a9c93215135dccd1895dca078e8785c357fabd983c9f9a769f08989a9", size = 38497, upload-time = "2025-12-06T13:24:08.873Z" },
- { url = "https://files.pythonhosted.org/packages/71/83/343f446b4b7a7579bf6937d2d013d82f1a63057cf05558e391ab6039d7db/pybase64-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a03ef3f529d85fd46b89971dfb00c634d53598d20ad8908fb7482955c710329d", size = 32076, upload-time = "2025-12-06T13:24:09.975Z" },
- { url = "https://files.pythonhosted.org/packages/46/fc/cb64964c3b29b432f54d1bce5e7691d693e33bbf780555151969ffd95178/pybase64-1.4.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2e745f2ce760c6cf04d8a72198ef892015ddb89f6ceba489e383518ecbdb13ab", size = 72317, upload-time = "2025-12-06T13:24:11.129Z" },
- { url = "https://files.pythonhosted.org/packages/0a/b7/fab2240da6f4e1ad46f71fa56ec577613cf5df9dce2d5b4cfaa4edd0e365/pybase64-1.4.3-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fac217cd9de8581a854b0ac734c50fd1fa4b8d912396c1fc2fce7c230efe3a7", size = 75534, upload-time = "2025-12-06T13:24:12.433Z" },
- { url = "https://files.pythonhosted.org/packages/91/3b/3e2f2b6e68e3d83ddb9fa799f3548fb7449765daec9bbd005a9fbe296d7f/pybase64-1.4.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da1ee8fa04b283873de2d6e8fa5653e827f55b86bdf1a929c5367aaeb8d26f8a", size = 65399, upload-time = "2025-12-06T13:24:13.928Z" },
- { url = "https://files.pythonhosted.org/packages/6b/08/476ac5914c3b32e0274a2524fc74f01cbf4f4af4513d054e41574eb018f6/pybase64-1.4.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:b0bf8e884ee822ca7b1448eeb97fa131628fe0ff42f60cae9962789bd562727f", size = 60487, upload-time = "2025-12-06T13:24:15.177Z" },
- { url = "https://files.pythonhosted.org/packages/f1/b8/618a92915330cc9cba7880299b546a1d9dab1a21fd6c0292ee44a4fe608c/pybase64-1.4.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1bf749300382a6fd1f4f255b183146ef58f8e9cb2f44a077b3a9200dfb473a77", size = 63959, upload-time = "2025-12-06T13:24:16.854Z" },
- { url = "https://files.pythonhosted.org/packages/a5/52/af9d8d051652c3051862c442ec3861259c5cdb3fc69774bc701470bd2a59/pybase64-1.4.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:153a0e42329b92337664cfc356f2065248e6c9a1bd651bbcd6dcaf15145d3f06", size = 64874, upload-time = "2025-12-06T13:24:18.328Z" },
- { url = "https://files.pythonhosted.org/packages/e4/51/5381a7adf1f381bd184d33203692d3c57cf8ae9f250f380c3fecbdbe554b/pybase64-1.4.3-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:86ee56ac7f2184ca10217ed1c655c1a060273e233e692e9086da29d1ae1768db", size = 58572, upload-time = "2025-12-06T13:24:19.417Z" },
- { url = "https://files.pythonhosted.org/packages/e0/f0/578ee4ffce5818017de4fdf544e066c225bc435e73eb4793cde28a689d0b/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0e71a4db76726bf830b47477e7d830a75c01b2e9b01842e787a0836b0ba741e3", size = 63636, upload-time = "2025-12-06T13:24:20.497Z" },
- { url = "https://files.pythonhosted.org/packages/b9/ad/8ae94814bf20159ea06310b742433e53d5820aa564c9fdf65bf2d79f8799/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2ba7799ec88540acd9861b10551d24656ca3c2888ecf4dba2ee0a71544a8923f", size = 56193, upload-time = "2025-12-06T13:24:21.559Z" },
- { url = "https://files.pythonhosted.org/packages/d1/31/6438cfcc3d3f0fa84d229fa125c243d5094e72628e525dfefadf3bcc6761/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2860299e4c74315f5951f0cf3e72ba0f201c3356c8a68f95a3ab4e620baf44e9", size = 72655, upload-time = "2025-12-06T13:24:22.673Z" },
- { url = "https://files.pythonhosted.org/packages/a3/0d/2bbc9e9c3fc12ba8a6e261482f03a544aca524f92eae0b4908c0a10ba481/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:bb06015db9151f0c66c10aae8e3603adab6b6cd7d1f7335a858161d92fc29618", size = 62471, upload-time = "2025-12-06T13:24:23.8Z" },
- { url = "https://files.pythonhosted.org/packages/2c/0b/34d491e7f49c1dbdb322ea8da6adecda7c7cd70b6644557c6e4ca5c6f7c7/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:242512a070817272865d37c8909059f43003b81da31f616bb0c391ceadffe067", size = 58119, upload-time = "2025-12-06T13:24:24.994Z" },
- { url = "https://files.pythonhosted.org/packages/ce/17/c21d0cde2a6c766923ae388fc1f78291e1564b0d38c814b5ea8a0e5e081c/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5d8277554a12d3e3eed6180ebda62786bf9fc8d7bb1ee00244258f4a87ca8d20", size = 60791, upload-time = "2025-12-06T13:24:26.046Z" },
- { url = "https://files.pythonhosted.org/packages/92/b2/eaa67038916a48de12b16f4c384bcc1b84b7ec731b23613cb05f27673294/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f40b7ddd698fc1e13a4b64fbe405e4e0e1279e8197e37050e24154655f5f7c4e", size = 74701, upload-time = "2025-12-06T13:24:27.466Z" },
- { url = "https://files.pythonhosted.org/packages/42/10/abb7757c330bb869ebb95dab0c57edf5961ffbd6c095c8209cbbf75d117d/pybase64-1.4.3-cp313-cp313t-win32.whl", hash = "sha256:46d75c9387f354c5172582a9eaae153b53a53afeb9c19fcf764ea7038be3bd8b", size = 33965, upload-time = "2025-12-06T13:24:28.548Z" },
- { url = "https://files.pythonhosted.org/packages/63/a0/2d4e5a59188e9e6aed0903d580541aaea72dcbbab7bf50fb8b83b490b6c3/pybase64-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:d7344625591d281bec54e85cbfdab9e970f6219cac1570f2aa140b8c942ccb81", size = 36207, upload-time = "2025-12-06T13:24:29.646Z" },
- { url = "https://files.pythonhosted.org/packages/1f/05/95b902e8f567b4d4b41df768ccc438af618f8d111e54deaf57d2df46bd76/pybase64-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:28a3c60c55138e0028313f2eccd321fec3c4a0be75e57a8d3eb883730b1b0880", size = 31505, upload-time = "2025-12-06T13:24:30.687Z" },
- { url = "https://files.pythonhosted.org/packages/e4/80/4bd3dff423e5a91f667ca41982dc0b79495b90ec0c0f5d59aca513e50f8c/pybase64-1.4.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:015bb586a1ea1467f69d57427abe587469392215f59db14f1f5c39b52fdafaf5", size = 33835, upload-time = "2025-12-06T13:24:31.767Z" },
- { url = "https://files.pythonhosted.org/packages/45/60/a94d94cc1e3057f602e0b483c9ebdaef40911d84a232647a2fe593ab77bb/pybase64-1.4.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d101e3a516f837c3dcc0e5a0b7db09582ebf99ed670865223123fb2e5839c6c0", size = 40673, upload-time = "2025-12-06T13:24:32.82Z" },
- { url = "https://files.pythonhosted.org/packages/e3/71/cf62b261d431857e8e054537a5c3c24caafa331de30daede7b2c6c558501/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8f183ac925a48046abe047360fe3a1b28327afb35309892132fe1915d62fb282", size = 30939, upload-time = "2025-12-06T13:24:34.001Z" },
- { url = "https://files.pythonhosted.org/packages/24/3e/d12f92a3c1f7c6ab5d53c155bff9f1084ba997a37a39a4f781ccba9455f3/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30bf3558e24dcce4da5248dcf6d73792adfcf4f504246967e9db155be4c439ad", size = 31401, upload-time = "2025-12-06T13:24:35.11Z" },
- { url = "https://files.pythonhosted.org/packages/9b/3d/9c27440031fea0d05146f8b70a460feb95d8b4e3d9ca8f45c972efb4c3d3/pybase64-1.4.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a674b419de318d2ce54387dd62646731efa32b4b590907800f0bd40675c1771d", size = 38075, upload-time = "2025-12-06T13:24:36.53Z" },
- { url = "https://files.pythonhosted.org/packages/4b/d4/6c0e0cf0efd53c254173fbcd84a3d8fcbf5e0f66622473da425becec32a5/pybase64-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:720104fd7303d07bac302be0ff8f7f9f126f2f45c1edb4f48fdb0ff267e69fe1", size = 38257, upload-time = "2025-12-06T13:24:38.049Z" },
- { url = "https://files.pythonhosted.org/packages/50/eb/27cb0b610d5cd70f5ad0d66c14ad21c04b8db930f7139818e8fbdc14df4d/pybase64-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83f1067f73fa5afbc3efc0565cecc6ed53260eccddef2ebe43a8ce2b99ea0e0a", size = 31685, upload-time = "2025-12-06T13:24:40.327Z" },
- { url = "https://files.pythonhosted.org/packages/db/26/b136a4b65e5c94ff06217f7726478df3f31ab1c777c2c02cf698e748183f/pybase64-1.4.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b51204d349a4b208287a8aa5b5422be3baa88abf6cc8ff97ccbda34919bbc857", size = 68460, upload-time = "2025-12-06T13:24:41.735Z" },
- { url = "https://files.pythonhosted.org/packages/68/6d/84ce50e7ee1ae79984d689e05a9937b2460d4efa1e5b202b46762fb9036c/pybase64-1.4.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30f2fd53efecbdde4bdca73a872a68dcb0d1bf8a4560c70a3e7746df973e1ef3", size = 71688, upload-time = "2025-12-06T13:24:42.908Z" },
- { url = "https://files.pythonhosted.org/packages/e3/57/6743e420416c3ff1b004041c85eb0ebd9c50e9cf05624664bfa1dc8b5625/pybase64-1.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0932b0c5cfa617091fd74f17d24549ce5de3628791998c94ba57be808078eeaf", size = 60040, upload-time = "2025-12-06T13:24:44.37Z" },
- { url = "https://files.pythonhosted.org/packages/3b/68/733324e28068a89119af2921ce548e1c607cc5c17d354690fc51c302e326/pybase64-1.4.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:acb61f5ab72bec808eb0d4ce8b87ec9f38d7d750cb89b1371c35eb8052a29f11", size = 56478, upload-time = "2025-12-06T13:24:45.815Z" },
- { url = "https://files.pythonhosted.org/packages/b5/9e/f3f4aa8cfe3357a3cdb0535b78eb032b671519d3ecc08c58c4c6b72b5a91/pybase64-1.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2bc2d5bc15168f5c04c53bdfe5a1e543b2155f456ed1e16d7edce9ce73842021", size = 59463, upload-time = "2025-12-06T13:24:46.938Z" },
- { url = "https://files.pythonhosted.org/packages/aa/d1/53286038e1f0df1cf58abcf4a4a91b0f74ab44539c2547b6c31001ddd054/pybase64-1.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8a7bc3cd23880bdca59758bcdd6f4ef0674f2393782763910a7466fab35ccb98", size = 60360, upload-time = "2025-12-06T13:24:48.039Z" },
- { url = "https://files.pythonhosted.org/packages/00/9a/5cc6ce95db2383d27ff4d790b8f8b46704d360d701ab77c4f655bcfaa6a7/pybase64-1.4.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ad15acf618880d99792d71e3905b0e2508e6e331b76a1b34212fa0f11e01ad28", size = 54999, upload-time = "2025-12-06T13:24:49.547Z" },
- { url = "https://files.pythonhosted.org/packages/64/e7/c3c1d09c3d7ae79e3aa1358c6d912d6b85f29281e47aa94fc0122a415a2f/pybase64-1.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448158d417139cb4851200e5fee62677ae51f56a865d50cda9e0d61bda91b116", size = 58736, upload-time = "2025-12-06T13:24:50.641Z" },
- { url = "https://files.pythonhosted.org/packages/db/d5/0baa08e3d8119b15b588c39f0d39fd10472f0372e3c54ca44649cbefa256/pybase64-1.4.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9058c49b5a2f3e691b9db21d37eb349e62540f9f5fc4beabf8cbe3c732bead86", size = 52298, upload-time = "2025-12-06T13:24:51.791Z" },
- { url = "https://files.pythonhosted.org/packages/00/87/fc6f11474a1de7e27cd2acbb8d0d7508bda3efa73dfe91c63f968728b2a3/pybase64-1.4.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ce561724f6522907a66303aca27dce252d363fcd85884972d348f4403ba3011a", size = 69049, upload-time = "2025-12-06T13:24:53.253Z" },
- { url = "https://files.pythonhosted.org/packages/69/9d/7fb5566f669ac18b40aa5fc1c438e24df52b843c1bdc5da47d46d4c1c630/pybase64-1.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:63316560a94ac449fe86cb8b9e0a13714c659417e92e26a5cbf085cd0a0c838d", size = 57952, upload-time = "2025-12-06T13:24:54.342Z" },
- { url = "https://files.pythonhosted.org/packages/de/cc/ceb949232dbbd3ec4ee0190d1df4361296beceee9840390a63df8bc31784/pybase64-1.4.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ecd796f2ac0be7b73e7e4e232b8c16422014de3295d43e71d2b19fd4a4f5368", size = 54484, upload-time = "2025-12-06T13:24:55.774Z" },
- { url = "https://files.pythonhosted.org/packages/a7/69/659f3c8e6a5d7b753b9c42a4bd9c42892a0f10044e9c7351a4148d413a33/pybase64-1.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d01e102a12fb2e1ed3dc11611c2818448626637857ec3994a9cf4809dfd23477", size = 56542, upload-time = "2025-12-06T13:24:57Z" },
- { url = "https://files.pythonhosted.org/packages/85/2c/29c9e6c9c82b72025f9676f9e82eb1fd2339ad038cbcbf8b9e2ac02798fc/pybase64-1.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ebff797a93c2345f22183f454fd8607a34d75eca5a3a4a969c1c75b304cee39d", size = 71045, upload-time = "2025-12-06T13:24:58.179Z" },
- { url = "https://files.pythonhosted.org/packages/b9/84/5a3dce8d7a0040a5c0c14f0fe1311cd8db872913fa04438071b26b0dac04/pybase64-1.4.3-cp314-cp314-win32.whl", hash = "sha256:28b2a1bb0828c0595dc1ea3336305cd97ff85b01c00d81cfce4f92a95fb88f56", size = 34200, upload-time = "2025-12-06T13:24:59.956Z" },
- { url = "https://files.pythonhosted.org/packages/57/bc/ce7427c12384adee115b347b287f8f3cf65860b824d74fe2c43e37e81c1f/pybase64-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:33338d3888700ff68c3dedfcd49f99bfc3b887570206130926791e26b316b029", size = 36323, upload-time = "2025-12-06T13:25:01.708Z" },
- { url = "https://files.pythonhosted.org/packages/9a/1b/2b8ffbe9a96eef7e3f6a5a7be75995eebfb6faaedc85b6da6b233e50c778/pybase64-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:62725669feb5acb186458da2f9353e88ae28ef66bb9c4c8d1568b12a790dfa94", size = 31584, upload-time = "2025-12-06T13:25:02.801Z" },
- { url = "https://files.pythonhosted.org/packages/ac/d8/6824c2e6fb45b8fa4e7d92e3c6805432d5edc7b855e3e8e1eedaaf6efb7c/pybase64-1.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:153fe29be038948d9372c3e77ae7d1cab44e4ba7d9aaf6f064dbeea36e45b092", size = 38601, upload-time = "2025-12-06T13:25:04.222Z" },
- { url = "https://files.pythonhosted.org/packages/ea/e5/10d2b3a4ad3a4850be2704a2f70cd9c0cf55725c8885679872d3bc846c67/pybase64-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7fe3decaa7c4a9e162327ec7bd81ce183d2b16f23c6d53b606649c6e0203e9e", size = 32078, upload-time = "2025-12-06T13:25:05.362Z" },
- { url = "https://files.pythonhosted.org/packages/43/04/8b15c34d3c2282f1c1b0850f1113a249401b618a382646a895170bc9b5e7/pybase64-1.4.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a5ae04ea114c86eb1da1f6e18d75f19e3b5ae39cb1d8d3cd87c29751a6a22780", size = 72474, upload-time = "2025-12-06T13:25:06.434Z" },
- { url = "https://files.pythonhosted.org/packages/42/00/f34b4d11278f8fdc68bc38f694a91492aa318f7c6f1bd7396197ac0f8b12/pybase64-1.4.3-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1755b3dce3a2a5c7d17ff6d4115e8bee4a1d5aeae74469db02e47c8f477147da", size = 75706, upload-time = "2025-12-06T13:25:07.636Z" },
- { url = "https://files.pythonhosted.org/packages/bb/5d/71747d4ad7fe16df4c4c852bdbdeb1f2cf35677b48d7c34d3011a7a6ad3a/pybase64-1.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb852f900e27ffc4ec1896817535a0fa19610ef8875a096b59f21d0aa42ff172", size = 65589, upload-time = "2025-12-06T13:25:08.809Z" },
- { url = "https://files.pythonhosted.org/packages/49/b1/d1e82bd58805bb5a3a662864800bab83a83a36ba56e7e3b1706c708002a5/pybase64-1.4.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9cf21ea8c70c61eddab3421fbfce061fac4f2fb21f7031383005a1efdb13d0b9", size = 60670, upload-time = "2025-12-06T13:25:10.04Z" },
- { url = "https://files.pythonhosted.org/packages/15/67/16c609b7a13d1d9fc87eca12ba2dce5e67f949eeaab61a41bddff843cbb0/pybase64-1.4.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:afff11b331fdc27692fc75e85ae083340a35105cea1a3c4552139e2f0e0d174f", size = 64194, upload-time = "2025-12-06T13:25:11.48Z" },
- { url = "https://files.pythonhosted.org/packages/3c/11/37bc724e42960f0106c2d33dc957dcec8f760c91a908cc6c0df7718bc1a8/pybase64-1.4.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9a5143df542c1ce5c1f423874b948c4d689b3f05ec571f8792286197a39ba02", size = 64984, upload-time = "2025-12-06T13:25:12.645Z" },
- { url = "https://files.pythonhosted.org/packages/6e/66/b2b962a6a480dd5dae3029becf03ea1a650d326e39bf1c44ea3db78bb010/pybase64-1.4.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d62e9861019ad63624b4a7914dff155af1cc5d6d79df3be14edcaedb5fdad6f9", size = 58750, upload-time = "2025-12-06T13:25:13.848Z" },
- { url = "https://files.pythonhosted.org/packages/2b/15/9b6d711035e29b18b2e1c03d47f41396d803d06ef15b6c97f45b75f73f04/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84cfd4d92668ef5766cc42a9c9474b88960ac2b860767e6e7be255c6fddbd34a", size = 63816, upload-time = "2025-12-06T13:25:15.356Z" },
- { url = "https://files.pythonhosted.org/packages/b4/21/e2901381ed0df62e2308380f30d9c4d87d6b74e33a84faed3478d33a7197/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:60fc025437f9a7c2cc45e0c19ed68ed08ba672be2c5575fd9d98bdd8f01dd61f", size = 56348, upload-time = "2025-12-06T13:25:16.559Z" },
- { url = "https://files.pythonhosted.org/packages/c4/16/3d788388a178a0407aa814b976fe61bfa4af6760d9aac566e59da6e4a8b4/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edc8446196f04b71d3af76c0bd1fe0a45066ac5bffecca88adb9626ee28c266f", size = 72842, upload-time = "2025-12-06T13:25:18.055Z" },
- { url = "https://files.pythonhosted.org/packages/a6/63/c15b1f8bd47ea48a5a2d52a4ec61f037062932ea6434ab916107b58e861e/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e99f6fa6509c037794da57f906ade271f52276c956d00f748e5b118462021d48", size = 62651, upload-time = "2025-12-06T13:25:19.191Z" },
- { url = "https://files.pythonhosted.org/packages/bd/b8/f544a2e37c778d59208966d4ef19742a0be37c12fc8149ff34483c176616/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d94020ef09f624d841aa9a3a6029df8cf65d60d7a6d5c8687579fa68bd679b65", size = 58295, upload-time = "2025-12-06T13:25:20.822Z" },
- { url = "https://files.pythonhosted.org/packages/03/99/1fae8a3b7ac181e36f6e7864a62d42d5b1f4fa7edf408c6711e28fba6b4d/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f64ce70d89942a23602dee910dec9b48e5edf94351e1b378186b74fcc00d7f66", size = 60960, upload-time = "2025-12-06T13:25:22.099Z" },
- { url = "https://files.pythonhosted.org/packages/9d/9e/cd4c727742345ad8384569a4466f1a1428f4e5cc94d9c2ab2f53d30be3fe/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ea99f56e45c469818b9781903be86ba4153769f007ba0655fa3b46dc332803d", size = 74863, upload-time = "2025-12-06T13:25:23.442Z" },
- { url = "https://files.pythonhosted.org/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab", size = 34513, upload-time = "2025-12-06T13:25:24.592Z" },
- { url = "https://files.pythonhosted.org/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76", size = 36702, upload-time = "2025-12-06T13:25:25.72Z" },
- { url = "https://files.pythonhosted.org/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9", size = 31904, upload-time = "2025-12-06T13:25:26.826Z" },
- { url = "https://files.pythonhosted.org/packages/b2/7c/545fd4935a0e1ddd7147f557bf8157c73eecec9cffd523382fa7af2557de/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_10_9_x86_64.whl", hash = "sha256:d27c1dfdb0c59a5e758e7a98bd78eaca5983c22f4a811a36f4f980d245df4611", size = 38393, upload-time = "2025-12-06T13:26:19.535Z" },
- { url = "https://files.pythonhosted.org/packages/c3/ca/ae7a96be9ddc96030d4e9dffc43635d4e136b12058b387fd47eb8301b60f/pybase64-1.4.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0f1a0c51d6f159511e3431b73c25db31095ee36c394e26a4349e067c62f434e5", size = 32109, upload-time = "2025-12-06T13:26:20.72Z" },
- { url = "https://files.pythonhosted.org/packages/bf/44/d4b7adc7bf4fd5b52d8d099121760c450a52c390223806b873f0b6a2d551/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a492518f3078a4e3faaef310697d21df9c6bc71908cebc8c2f6fbfa16d7d6b1f", size = 43227, upload-time = "2025-12-06T13:26:21.845Z" },
- { url = "https://files.pythonhosted.org/packages/08/86/2ba2d8734ef7939debeb52cf9952e457ba7aa226cae5c0e6dd631f9b851f/pybase64-1.4.3-graalpy311-graalpy242_311_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae1a0f47784fd16df90d8acc32011c8d5fcdd9ab392c9ec49543e5f6a9c43a4", size = 35804, upload-time = "2025-12-06T13:26:23.149Z" },
- { url = "https://files.pythonhosted.org/packages/4f/5b/19c725dc3aaa6281f2ce3ea4c1628d154a40dd99657d1381995f8096768b/pybase64-1.4.3-graalpy311-graalpy242_311_native-win_amd64.whl", hash = "sha256:03cea70676ffbd39a1ab7930a2d24c625b416cacc9d401599b1d29415a43ab6a", size = 35880, upload-time = "2025-12-06T13:26:24.663Z" },
- { url = "https://files.pythonhosted.org/packages/17/45/92322aec1b6979e789b5710f73c59f2172bc37c8ce835305434796824b7b/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:2baaa092f3475f3a9c87ac5198023918ea8b6c125f4c930752ab2cbe3cd1d520", size = 38746, upload-time = "2025-12-06T13:26:25.869Z" },
- { url = "https://files.pythonhosted.org/packages/11/94/f1a07402870388fdfc2ecec0c718111189732f7d0f2d7fe1386e19e8fad0/pybase64-1.4.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:cde13c0764b1af07a631729f26df019070dad759981d6975527b7e8ecb465b6c", size = 32573, upload-time = "2025-12-06T13:26:27.792Z" },
- { url = "https://files.pythonhosted.org/packages/fa/8f/43c3bb11ca9bacf81cb0b7a71500bb65b2eda6d5fe07433c09b543de97f3/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5c29a582b0ea3936d02bd6fe9bf674ab6059e6e45ab71c78404ab2c913224414", size = 43461, upload-time = "2025-12-06T13:26:28.906Z" },
- { url = "https://files.pythonhosted.org/packages/2d/4c/2a5258329200be57497d3972b5308558c6de42e3749c6cc2aa1cbe34b25a/pybase64-1.4.3-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6b664758c804fa919b4f1257aa8cf68e95db76fc331de5f70bfc3a34655afe1", size = 36058, upload-time = "2025-12-06T13:26:30.092Z" },
- { url = "https://files.pythonhosted.org/packages/ea/6d/41faa414cde66ec023b0ca8402a8f11cb61731c3dc27c082909cbbd1f929/pybase64-1.4.3-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:f7537fa22ae56a0bf51e4b0ffc075926ad91c618e1416330939f7ef366b58e3b", size = 36231, upload-time = "2025-12-06T13:26:31.656Z" },
- { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939, upload-time = "2025-12-06T13:26:41.014Z" },
- { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466, upload-time = "2025-12-06T13:26:42.539Z" },
- { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681, upload-time = "2025-12-06T13:26:43.782Z" },
- { url = "https://files.pythonhosted.org/packages/12/d7/6610f34a8972415fab3bb4704c174a1cc477bffbc3c36e526428d0f3957d/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af6d0d3a691911cc4c9a625f3ddcd3af720738c21be3d5c72de05629139d393", size = 41294, upload-time = "2025-12-06T13:26:44.936Z" },
- { url = "https://files.pythonhosted.org/packages/64/25/ed24400948a6c974ab1374a233cb7e8af0a5373cea0dd8a944627d17c34a/pybase64-1.4.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfc8c49a28322d82242088378f8542ce97459866ba73150b062a7073e82629d", size = 35447, upload-time = "2025-12-06T13:26:46.098Z" },
- { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134, upload-time = "2025-12-06T13:26:47.35Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/63/21e981e9d3f1f123e0b0ee2130112b1956cad9752309f574862c7ae77c08/pybase64-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70b0d4a4d54e216ce42c2655315378b8903933ecfa32fced453989a92b4317b2", size = 38237 },
+ { url = "https://files.pythonhosted.org/packages/92/fb/3f448e139516404d2a3963915cc10dc9dde7d3a67de4edba2f827adfef17/pybase64-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8127f110cdee7a70e576c5c9c1d4e17e92e76c191869085efbc50419f4ae3c72", size = 31673 },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/bb06a5b9885e7d853ac1e801c4d8abfdb4c8506deee33e53d55aa6690e67/pybase64-1.4.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f9ef0388878bc15a084bd9bf73ec1b2b4ee513d11009b1506375e10a7aae5032", size = 68331 },
+ { url = "https://files.pythonhosted.org/packages/64/15/8d60b9ec5e658185fc2ee3333e01a6e30d717cf677b24f47cbb3a859d13c/pybase64-1.4.3-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95a57cccf106352a72ed8bc8198f6820b16cc7d55aa3867a16dea7011ae7c218", size = 71370 },
+ { url = "https://files.pythonhosted.org/packages/ac/29/a3e5c1667cc8c38d025a4636855de0fc117fc62e2afeb033a3c6f12c6a22/pybase64-1.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cd1c47dfceb9c7bd3de210fb4e65904053ed2d7c9dce6d107f041ff6fbd7e21", size = 59834 },
+ { url = "https://files.pythonhosted.org/packages/a9/00/8ffcf9810bd23f3984698be161cf7edba656fd639b818039a7be1d6405d4/pybase64-1.4.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9fe9922698f3e2f72874b26890d53a051c431d942701bb3a37aae94da0b12107", size = 56652 },
+ { url = "https://files.pythonhosted.org/packages/81/62/379e347797cdea4ab686375945bc77ad8d039c688c0d4d0cfb09d247beb9/pybase64-1.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:af5f4bd29c86b59bb4375e0491d16ec8a67548fa99c54763aaedaf0b4b5a6632", size = 59382 },
+ { url = "https://files.pythonhosted.org/packages/c6/f2/9338ffe2f487086f26a2c8ca175acb3baa86fce0a756ff5670a0822bb877/pybase64-1.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c302f6ca7465262908131411226e02100f488f531bb5e64cb901aa3f439bccd9", size = 59990 },
+ { url = "https://files.pythonhosted.org/packages/f9/a4/85a6142b65b4df8625b337727aa81dc199642de3d09677804141df6ee312/pybase64-1.4.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2f3f439fa4d7fde164ebbbb41968db7d66b064450ab6017c6c95cef0afa2b349", size = 54923 },
+ { url = "https://files.pythonhosted.org/packages/ac/00/e40215d25624012bf5b7416ca37f168cb75f6dd15acdb91ea1f2ea4dc4e7/pybase64-1.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a23c6866551043f8b681a5e1e0d59469148b2920a3b4fc42b1275f25ea4217a", size = 58664 },
+ { url = "https://files.pythonhosted.org/packages/b0/73/d7e19a63e795c13837f2356268d95dc79d1180e756f57ced742a1e52fdeb/pybase64-1.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:56e6526f8565642abc5f84338cc131ce298a8ccab696b19bdf76fa6d7dc592ef", size = 52338 },
+ { url = "https://files.pythonhosted.org/packages/f2/32/3c746d7a310b69bdd9df77ffc85c41b80bce00a774717596f869b0d4a20e/pybase64-1.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6a792a8b9d866ffa413c9687d9b611553203753987a3a582d68cbc51cf23da45", size = 68993 },
+ { url = "https://files.pythonhosted.org/packages/5d/b3/63cec68f9d6f6e4c0b438d14e5f1ef536a5fe63ce14b70733ac5e31d7ab8/pybase64-1.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62ad29a5026bb22cfcd1ca484ec34b0a5ced56ddba38ceecd9359b2818c9c4f9", size = 58055 },
+ { url = "https://files.pythonhosted.org/packages/d5/cb/7acf7c3c06f9692093c07f109668725dc37fb9a3df0fa912b50add645195/pybase64-1.4.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11b9d1d2d32ec358c02214363b8fc3651f6be7dd84d880ecd597a6206a80e121", size = 54430 },
+ { url = "https://files.pythonhosted.org/packages/33/39/4eb33ff35d173bfff4002e184ce8907f5d0a42d958d61cd9058ef3570179/pybase64-1.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0aebaa7f238caa0a0d373616016e2040c6c879ebce3ba7ab3c59029920f13640", size = 56272 },
+ { url = "https://files.pythonhosted.org/packages/19/97/a76d65c375a254e65b730c6f56bf528feca91305da32eceab8bcc08591e6/pybase64-1.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e504682b20c63c2b0c000e5f98a80ea867f8d97642e042a5a39818e44ba4d599", size = 70904 },
+ { url = "https://files.pythonhosted.org/packages/5e/2c/8338b6d3da3c265002839e92af0a80d6db88385c313c73f103dfb800c857/pybase64-1.4.3-cp311-cp311-win32.whl", hash = "sha256:e9a8b81984e3c6fb1db9e1614341b0a2d98c0033d693d90c726677db1ffa3a4c", size = 33639 },
+ { url = "https://files.pythonhosted.org/packages/39/dc/32efdf2f5927e5449cc341c266a1bbc5fecd5319a8807d9c5405f76e6d02/pybase64-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:a90a8fa16a901fabf20de824d7acce07586e6127dc2333f1de05f73b1f848319", size = 35797 },
+ { url = "https://files.pythonhosted.org/packages/da/59/eda4f9cb0cbce5a45f0cd06131e710674f8123a4d570772c5b9694f88559/pybase64-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:61d87de5bc94d143622e94390ec3e11b9c1d4644fe9be3a81068ab0f91056f59", size = 31160 },
+ { url = "https://files.pythonhosted.org/packages/86/a7/efcaa564f091a2af7f18a83c1c4875b1437db56ba39540451dc85d56f653/pybase64-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:18d85e5ab8b986bb32d8446aca6258ed80d1bafe3603c437690b352c648f5967", size = 38167 },
+ { url = "https://files.pythonhosted.org/packages/db/c7/c7ad35adff2d272bf2930132db2b3eea8c44bb1b1f64eb9b2b8e57cde7b4/pybase64-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f5791a3491d116d0deaf4d83268f48792998519698f8751efb191eac84320e9", size = 31673 },
+ { url = "https://files.pythonhosted.org/packages/43/1b/9a8cab0042b464e9a876d5c65fe5127445a2436da36fda64899b119b1a1b/pybase64-1.4.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f0b3f200c3e06316f6bebabd458b4e4bcd4c2ca26af7c0c766614d91968dee27", size = 68210 },
+ { url = "https://files.pythonhosted.org/packages/62/f7/965b79ff391ad208b50e412b5d3205ccce372a2d27b7218ae86d5295b105/pybase64-1.4.3-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb632edfd132b3eaf90c39c89aa314beec4e946e210099b57d40311f704e11d4", size = 71599 },
+ { url = "https://files.pythonhosted.org/packages/03/4b/a3b5175130b3810bbb8ccfa1edaadbd3afddb9992d877c8a1e2f274b476e/pybase64-1.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:356ef1d74648ce997f5a777cf8f1aefecc1c0b4fe6201e0ef3ec8a08170e1b54", size = 59922 },
+ { url = "https://files.pythonhosted.org/packages/da/5d/c38d1572027fc601b62d7a407721688b04b4d065d60ca489912d6893e6cf/pybase64-1.4.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:c48361f90db32bacaa5518419d4eb9066ba558013aaf0c7781620279ecddaeb9", size = 56712 },
+ { url = "https://files.pythonhosted.org/packages/e7/d4/4e04472fef485caa8f561d904d4d69210a8f8fc1608ea15ebd9012b92655/pybase64-1.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:702bcaa16ae02139d881aeaef5b1c8ffb4a3fae062fe601d1e3835e10310a517", size = 59300 },
+ { url = "https://files.pythonhosted.org/packages/86/e7/16e29721b86734b881d09b7e23dfd7c8408ad01a4f4c7525f3b1088e25ec/pybase64-1.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:53d0ffe1847b16b647c6413d34d1de08942b7724273dd57e67dcbdb10c574045", size = 60278 },
+ { url = "https://files.pythonhosted.org/packages/b1/02/18515f211d7c046be32070709a8efeeef8a0203de4fd7521e6b56404731b/pybase64-1.4.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9a1792e8b830a92736dae58f0c386062eb038dfe8004fb03ba33b6083d89cd43", size = 54817 },
+ { url = "https://files.pythonhosted.org/packages/e7/be/14e29d8e1a481dbff151324c96dd7b5d2688194bb65dc8a00ca0e1ad1e86/pybase64-1.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d468b1b1ac5ad84875a46eaa458663c3721e8be5f155ade356406848d3701f6", size = 58611 },
+ { url = "https://files.pythonhosted.org/packages/b4/8a/a2588dfe24e1bbd742a554553778ab0d65fdf3d1c9a06d10b77047d142aa/pybase64-1.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e97b7bdbd62e71898cd542a6a9e320d9da754ff3ebd02cb802d69087ee94d468", size = 52404 },
+ { url = "https://files.pythonhosted.org/packages/27/fc/afcda7445bebe0cbc38cafdd7813234cdd4fc5573ff067f1abf317bb0cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b33aeaa780caaa08ffda87fc584d5eab61e3d3bbb5d86ead02161dc0c20d04bc", size = 68817 },
+ { url = "https://files.pythonhosted.org/packages/d3/3a/87c3201e555ed71f73e961a787241a2438c2bbb2ca8809c29ddf938a3157/pybase64-1.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c0efcf78f11cf866bed49caa7b97552bc4855a892f9cc2372abcd3ed0056f0d", size = 57854 },
+ { url = "https://files.pythonhosted.org/packages/fd/7d/931c2539b31a7b375e7d595b88401eeb5bd6c5ce1059c9123f9b608aaa14/pybase64-1.4.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66e3791f2ed725a46593f8bd2761ff37d01e2cdad065b1dceb89066f476e50c6", size = 54333 },
+ { url = "https://files.pythonhosted.org/packages/de/5e/537601e02cc01f27e9d75f440f1a6095b8df44fc28b1eef2cd739aea8cec/pybase64-1.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:72bb0b6bddadab26e1b069bb78e83092711a111a80a0d6b9edcb08199ad7299b", size = 56492 },
+ { url = "https://files.pythonhosted.org/packages/96/97/2a2e57acf8f5c9258d22aba52e71f8050e167b29ed2ee1113677c1b600c1/pybase64-1.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3365dbcbcdb0a294f0f50af0c0a16b27a232eddeeb0bceeefd844ef30d2a23", size = 70974 },
+ { url = "https://files.pythonhosted.org/packages/75/2e/a9e28941c6dab6f06e6d3f6783d3373044be9b0f9a9d3492c3d8d2260ac0/pybase64-1.4.3-cp312-cp312-win32.whl", hash = "sha256:7bca1ed3a5df53305c629ca94276966272eda33c0d71f862d2d3d043f1e1b91a", size = 33686 },
+ { url = "https://files.pythonhosted.org/packages/83/e3/507ab649d8c3512c258819c51d25c45d6e29d9ca33992593059e7b646a33/pybase64-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:9f2da8f56d9b891b18b4daf463a0640eae45a80af548ce435be86aa6eff3603b", size = 35833 },
+ { url = "https://files.pythonhosted.org/packages/bc/8a/6eba66cd549a2fc74bb4425fd61b839ba0ab3022d3c401b8a8dc2cc00c7a/pybase64-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:0631d8a2d035de03aa9bded029b9513e1fee8ed80b7ddef6b8e9389ffc445da0", size = 31185 },
+ { url = "https://files.pythonhosted.org/packages/3a/50/b7170cb2c631944388fe2519507fe3835a4054a6a12a43f43781dae82be1/pybase64-1.4.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:ea4b785b0607d11950b66ce7c328f452614aefc9c6d3c9c28bae795dc7f072e1", size = 33901 },
+ { url = "https://files.pythonhosted.org/packages/48/8b/69f50578e49c25e0a26e3ee72c39884ff56363344b79fc3967f5af420ed6/pybase64-1.4.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:6a10b6330188c3026a8b9c10e6b9b3f2e445779cf16a4c453d51a072241c65a2", size = 40807 },
+ { url = "https://files.pythonhosted.org/packages/5c/8d/20b68f11adfc4c22230e034b65c71392e3e338b413bf713c8945bd2ccfb3/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:27fdff227a0c0e182e0ba37a99109645188978b920dfb20d8b9c17eeee370d0d", size = 30932 },
+ { url = "https://files.pythonhosted.org/packages/f7/79/b1b550ac6bff51a4880bf6e089008b2e1ca16f2c98db5e039a08ac3ad157/pybase64-1.4.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2a8204f1fdfec5aa4184249b51296c0de95445869920c88123978304aad42df1", size = 31394 },
+ { url = "https://files.pythonhosted.org/packages/82/70/b5d7c5932bf64ee1ec5da859fbac981930b6a55d432a603986c7f509c838/pybase64-1.4.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:874fc2a3777de6baf6aa921a7aa73b3be98295794bea31bd80568a963be30767", size = 38078 },
+ { url = "https://files.pythonhosted.org/packages/56/fe/e66fe373bce717c6858427670736d54297938dad61c5907517ab4106bd90/pybase64-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2dc64a94a9d936b8e3449c66afabbaa521d3cc1a563d6bbaaa6ffa4535222e4b", size = 38158 },
+ { url = "https://files.pythonhosted.org/packages/80/a9/b806ed1dcc7aed2ea3dd4952286319e6f3a8b48615c8118f453948e01999/pybase64-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e48f86de1c145116ccf369a6e11720ce696c2ec02d285f440dfb57ceaa0a6cb4", size = 31672 },
+ { url = "https://files.pythonhosted.org/packages/1c/c9/24b3b905cf75e23a9a4deaf203b35ffcb9f473ac0e6d8257f91a05dfce62/pybase64-1.4.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1d45c8fe8fe82b65c36b227bb4a2cf623d9ada16bed602ce2d3e18c35285b72a", size = 68244 },
+ { url = "https://files.pythonhosted.org/packages/f8/cd/d15b0c3e25e5859fab0416dc5b96d34d6bd2603c1c96a07bb2202b68ab92/pybase64-1.4.3-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad70c26ba091d8f5167e9d4e1e86a0483a5414805cdb598a813db635bd3be8b8", size = 71620 },
+ { url = "https://files.pythonhosted.org/packages/0d/31/4ca953cc3dcde2b3711d6bfd70a6f4ad2ca95a483c9698076ba605f1520f/pybase64-1.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e98310b7c43145221e7194ac9fa7fffc84763c87bfc5e2f59f9f92363475bdc1", size = 59930 },
+ { url = "https://files.pythonhosted.org/packages/60/55/e7f7bdcd0fd66e61dda08db158ffda5c89a306bbdaaf5a062fbe4e48f4a1/pybase64-1.4.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:398685a76034e91485a28aeebcb49e64cd663212fd697b2497ac6dfc1df5e671", size = 56425 },
+ { url = "https://files.pythonhosted.org/packages/cb/65/b592c7f921e51ca1aca3af5b0d201a98666d0a36b930ebb67e7c2ed27395/pybase64-1.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7e46400a6461187ccb52ed75b0045d937529e801a53a9cd770b350509f9e4d50", size = 59327 },
+ { url = "https://files.pythonhosted.org/packages/23/95/1613d2fb82dbb1548595ad4179f04e9a8451bfa18635efce18b631eabe3f/pybase64-1.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1b62b9f2f291d94f5e0b76ab499790b7dcc78a009d4ceea0b0428770267484b6", size = 60294 },
+ { url = "https://files.pythonhosted.org/packages/9d/73/40431f37f7d1b3eab4673e7946ff1e8f5d6bd425ec257e834dae8a6fc7b0/pybase64-1.4.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:f30ceb5fa4327809dede614be586efcbc55404406d71e1f902a6fdcf322b93b2", size = 54858 },
+ { url = "https://files.pythonhosted.org/packages/a7/84/f6368bcaf9f743732e002a9858646fd7a54f428490d427dd6847c5cfe89e/pybase64-1.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0d5f18ed53dfa1d4cf8b39ee542fdda8e66d365940e11f1710989b3cf4a2ed66", size = 58629 },
+ { url = "https://files.pythonhosted.org/packages/43/75/359532f9adb49c6b546cafc65c46ed75e2ccc220d514ba81c686fbd83965/pybase64-1.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:119d31aa4b58b85a8ebd12b63c07681a138c08dfc2fe5383459d42238665d3eb", size = 52448 },
+ { url = "https://files.pythonhosted.org/packages/92/6c/ade2ba244c3f33ed920a7ed572ad772eb0b5f14480b72d629d0c9e739a40/pybase64-1.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3cf0218b0e2f7988cf7d738a73b6a1d14f3be6ce249d7c0f606e768366df2cce", size = 68841 },
+ { url = "https://files.pythonhosted.org/packages/a0/51/b345139cd236be382f2d4d4453c21ee6299e14d2f759b668e23080f8663f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:12f4ee5e988bc5c0c1106b0d8fc37fb0508f12dab76bac1b098cb500d148da9d", size = 57910 },
+ { url = "https://files.pythonhosted.org/packages/1a/b8/9f84bdc4f1c4f0052489396403c04be2f9266a66b70c776001eaf0d78c1f/pybase64-1.4.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:937826bc7b6b95b594a45180e81dd4d99bd4dd4814a443170e399163f7ff3fb6", size = 54335 },
+ { url = "https://files.pythonhosted.org/packages/d0/c7/be63b617d284de46578a366da77ede39c8f8e815ed0d82c7c2acca560fab/pybase64-1.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:88995d1460971ef80b13e3e007afbe4b27c62db0508bc7250a2ab0a0b4b91362", size = 56486 },
+ { url = "https://files.pythonhosted.org/packages/5e/96/f252c8f9abd6ded3ef1ccd3cdbb8393a33798007f761b23df8de1a2480e6/pybase64-1.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:72326fe163385ed3e1e806dd579d47fde5d8a59e51297a60fc4e6cbc1b4fc4ed", size = 70978 },
+ { url = "https://files.pythonhosted.org/packages/af/51/0f5714af7aeef96e30f968e4371d75ad60558aaed3579d7c6c8f1c43c18a/pybase64-1.4.3-cp313-cp313-win32.whl", hash = "sha256:b1623730c7892cf5ed0d6355e375416be6ef8d53ab9b284f50890443175c0ac3", size = 33684 },
+ { url = "https://files.pythonhosted.org/packages/b6/ad/0cea830a654eb08563fb8214150ef57546ece1cc421c09035f0e6b0b5ea9/pybase64-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:8369887590f1646a5182ca2fb29252509da7ae31d4923dbb55d3e09da8cc4749", size = 35832 },
+ { url = "https://files.pythonhosted.org/packages/b4/0d/eec2a8214989c751bc7b4cad1860eb2c6abf466e76b77508c0f488c96a37/pybase64-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:860b86bca71e5f0237e2ab8b2d9c4c56681f3513b1bf3e2117290c1963488390", size = 31175 },
+ { url = "https://files.pythonhosted.org/packages/db/c9/e23463c1a2913686803ef76b1a5ae7e6fac868249a66e48253d17ad7232c/pybase64-1.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eb51db4a9c93215135dccd1895dca078e8785c357fabd983c9f9a769f08989a9", size = 38497 },
+ { url = "https://files.pythonhosted.org/packages/71/83/343f446b4b7a7579bf6937d2d013d82f1a63057cf05558e391ab6039d7db/pybase64-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a03ef3f529d85fd46b89971dfb00c634d53598d20ad8908fb7482955c710329d", size = 32076 },
+ { url = "https://files.pythonhosted.org/packages/46/fc/cb64964c3b29b432f54d1bce5e7691d693e33bbf780555151969ffd95178/pybase64-1.4.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2e745f2ce760c6cf04d8a72198ef892015ddb89f6ceba489e383518ecbdb13ab", size = 72317 },
+ { url = "https://files.pythonhosted.org/packages/0a/b7/fab2240da6f4e1ad46f71fa56ec577613cf5df9dce2d5b4cfaa4edd0e365/pybase64-1.4.3-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fac217cd9de8581a854b0ac734c50fd1fa4b8d912396c1fc2fce7c230efe3a7", size = 75534 },
+ { url = "https://files.pythonhosted.org/packages/91/3b/3e2f2b6e68e3d83ddb9fa799f3548fb7449765daec9bbd005a9fbe296d7f/pybase64-1.4.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da1ee8fa04b283873de2d6e8fa5653e827f55b86bdf1a929c5367aaeb8d26f8a", size = 65399 },
+ { url = "https://files.pythonhosted.org/packages/6b/08/476ac5914c3b32e0274a2524fc74f01cbf4f4af4513d054e41574eb018f6/pybase64-1.4.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:b0bf8e884ee822ca7b1448eeb97fa131628fe0ff42f60cae9962789bd562727f", size = 60487 },
+ { url = "https://files.pythonhosted.org/packages/f1/b8/618a92915330cc9cba7880299b546a1d9dab1a21fd6c0292ee44a4fe608c/pybase64-1.4.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1bf749300382a6fd1f4f255b183146ef58f8e9cb2f44a077b3a9200dfb473a77", size = 63959 },
+ { url = "https://files.pythonhosted.org/packages/a5/52/af9d8d051652c3051862c442ec3861259c5cdb3fc69774bc701470bd2a59/pybase64-1.4.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:153a0e42329b92337664cfc356f2065248e6c9a1bd651bbcd6dcaf15145d3f06", size = 64874 },
+ { url = "https://files.pythonhosted.org/packages/e4/51/5381a7adf1f381bd184d33203692d3c57cf8ae9f250f380c3fecbdbe554b/pybase64-1.4.3-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:86ee56ac7f2184ca10217ed1c655c1a060273e233e692e9086da29d1ae1768db", size = 58572 },
+ { url = "https://files.pythonhosted.org/packages/e0/f0/578ee4ffce5818017de4fdf544e066c225bc435e73eb4793cde28a689d0b/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0e71a4db76726bf830b47477e7d830a75c01b2e9b01842e787a0836b0ba741e3", size = 63636 },
+ { url = "https://files.pythonhosted.org/packages/b9/ad/8ae94814bf20159ea06310b742433e53d5820aa564c9fdf65bf2d79f8799/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2ba7799ec88540acd9861b10551d24656ca3c2888ecf4dba2ee0a71544a8923f", size = 56193 },
+ { url = "https://files.pythonhosted.org/packages/d1/31/6438cfcc3d3f0fa84d229fa125c243d5094e72628e525dfefadf3bcc6761/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2860299e4c74315f5951f0cf3e72ba0f201c3356c8a68f95a3ab4e620baf44e9", size = 72655 },
+ { url = "https://files.pythonhosted.org/packages/a3/0d/2bbc9e9c3fc12ba8a6e261482f03a544aca524f92eae0b4908c0a10ba481/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:bb06015db9151f0c66c10aae8e3603adab6b6cd7d1f7335a858161d92fc29618", size = 62471 },
+ { url = "https://files.pythonhosted.org/packages/2c/0b/34d491e7f49c1dbdb322ea8da6adecda7c7cd70b6644557c6e4ca5c6f7c7/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:242512a070817272865d37c8909059f43003b81da31f616bb0c391ceadffe067", size = 58119 },
+ { url = "https://files.pythonhosted.org/packages/ce/17/c21d0cde2a6c766923ae388fc1f78291e1564b0d38c814b5ea8a0e5e081c/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5d8277554a12d3e3eed6180ebda62786bf9fc8d7bb1ee00244258f4a87ca8d20", size = 60791 },
+ { url = "https://files.pythonhosted.org/packages/92/b2/eaa67038916a48de12b16f4c384bcc1b84b7ec731b23613cb05f27673294/pybase64-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f40b7ddd698fc1e13a4b64fbe405e4e0e1279e8197e37050e24154655f5f7c4e", size = 74701 },
+ { url = "https://files.pythonhosted.org/packages/42/10/abb7757c330bb869ebb95dab0c57edf5961ffbd6c095c8209cbbf75d117d/pybase64-1.4.3-cp313-cp313t-win32.whl", hash = "sha256:46d75c9387f354c5172582a9eaae153b53a53afeb9c19fcf764ea7038be3bd8b", size = 33965 },
+ { url = "https://files.pythonhosted.org/packages/63/a0/2d4e5a59188e9e6aed0903d580541aaea72dcbbab7bf50fb8b83b490b6c3/pybase64-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:d7344625591d281bec54e85cbfdab9e970f6219cac1570f2aa140b8c942ccb81", size = 36207 },
+ { url = "https://files.pythonhosted.org/packages/1f/05/95b902e8f567b4d4b41df768ccc438af618f8d111e54deaf57d2df46bd76/pybase64-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:28a3c60c55138e0028313f2eccd321fec3c4a0be75e57a8d3eb883730b1b0880", size = 31505 },
+ { url = "https://files.pythonhosted.org/packages/e4/80/4bd3dff423e5a91f667ca41982dc0b79495b90ec0c0f5d59aca513e50f8c/pybase64-1.4.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:015bb586a1ea1467f69d57427abe587469392215f59db14f1f5c39b52fdafaf5", size = 33835 },
+ { url = "https://files.pythonhosted.org/packages/45/60/a94d94cc1e3057f602e0b483c9ebdaef40911d84a232647a2fe593ab77bb/pybase64-1.4.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d101e3a516f837c3dcc0e5a0b7db09582ebf99ed670865223123fb2e5839c6c0", size = 40673 },
+ { url = "https://files.pythonhosted.org/packages/e3/71/cf62b261d431857e8e054537a5c3c24caafa331de30daede7b2c6c558501/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8f183ac925a48046abe047360fe3a1b28327afb35309892132fe1915d62fb282", size = 30939 },
+ { url = "https://files.pythonhosted.org/packages/24/3e/d12f92a3c1f7c6ab5d53c155bff9f1084ba997a37a39a4f781ccba9455f3/pybase64-1.4.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30bf3558e24dcce4da5248dcf6d73792adfcf4f504246967e9db155be4c439ad", size = 31401 },
+ { url = "https://files.pythonhosted.org/packages/9b/3d/9c27440031fea0d05146f8b70a460feb95d8b4e3d9ca8f45c972efb4c3d3/pybase64-1.4.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a674b419de318d2ce54387dd62646731efa32b4b590907800f0bd40675c1771d", size = 38075 },
+ { url = "https://files.pythonhosted.org/packages/4b/d4/6c0e0cf0efd53c254173fbcd84a3d8fcbf5e0f66622473da425becec32a5/pybase64-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:720104fd7303d07bac302be0ff8f7f9f126f2f45c1edb4f48fdb0ff267e69fe1", size = 38257 },
+ { url = "https://files.pythonhosted.org/packages/50/eb/27cb0b610d5cd70f5ad0d66c14ad21c04b8db930f7139818e8fbdc14df4d/pybase64-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83f1067f73fa5afbc3efc0565cecc6ed53260eccddef2ebe43a8ce2b99ea0e0a", size = 31685 },
+ { url = "https://files.pythonhosted.org/packages/db/26/b136a4b65e5c94ff06217f7726478df3f31ab1c777c2c02cf698e748183f/pybase64-1.4.3-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b51204d349a4b208287a8aa5b5422be3baa88abf6cc8ff97ccbda34919bbc857", size = 68460 },
+ { url = "https://files.pythonhosted.org/packages/68/6d/84ce50e7ee1ae79984d689e05a9937b2460d4efa1e5b202b46762fb9036c/pybase64-1.4.3-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30f2fd53efecbdde4bdca73a872a68dcb0d1bf8a4560c70a3e7746df973e1ef3", size = 71688 },
+ { url = "https://files.pythonhosted.org/packages/e3/57/6743e420416c3ff1b004041c85eb0ebd9c50e9cf05624664bfa1dc8b5625/pybase64-1.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0932b0c5cfa617091fd74f17d24549ce5de3628791998c94ba57be808078eeaf", size = 60040 },
+ { url = "https://files.pythonhosted.org/packages/3b/68/733324e28068a89119af2921ce548e1c607cc5c17d354690fc51c302e326/pybase64-1.4.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:acb61f5ab72bec808eb0d4ce8b87ec9f38d7d750cb89b1371c35eb8052a29f11", size = 56478 },
+ { url = "https://files.pythonhosted.org/packages/b5/9e/f3f4aa8cfe3357a3cdb0535b78eb032b671519d3ecc08c58c4c6b72b5a91/pybase64-1.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:2bc2d5bc15168f5c04c53bdfe5a1e543b2155f456ed1e16d7edce9ce73842021", size = 59463 },
+ { url = "https://files.pythonhosted.org/packages/aa/d1/53286038e1f0df1cf58abcf4a4a91b0f74ab44539c2547b6c31001ddd054/pybase64-1.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8a7bc3cd23880bdca59758bcdd6f4ef0674f2393782763910a7466fab35ccb98", size = 60360 },
+ { url = "https://files.pythonhosted.org/packages/00/9a/5cc6ce95db2383d27ff4d790b8f8b46704d360d701ab77c4f655bcfaa6a7/pybase64-1.4.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ad15acf618880d99792d71e3905b0e2508e6e331b76a1b34212fa0f11e01ad28", size = 54999 },
+ { url = "https://files.pythonhosted.org/packages/64/e7/c3c1d09c3d7ae79e3aa1358c6d912d6b85f29281e47aa94fc0122a415a2f/pybase64-1.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448158d417139cb4851200e5fee62677ae51f56a865d50cda9e0d61bda91b116", size = 58736 },
+ { url = "https://files.pythonhosted.org/packages/db/d5/0baa08e3d8119b15b588c39f0d39fd10472f0372e3c54ca44649cbefa256/pybase64-1.4.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9058c49b5a2f3e691b9db21d37eb349e62540f9f5fc4beabf8cbe3c732bead86", size = 52298 },
+ { url = "https://files.pythonhosted.org/packages/00/87/fc6f11474a1de7e27cd2acbb8d0d7508bda3efa73dfe91c63f968728b2a3/pybase64-1.4.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ce561724f6522907a66303aca27dce252d363fcd85884972d348f4403ba3011a", size = 69049 },
+ { url = "https://files.pythonhosted.org/packages/69/9d/7fb5566f669ac18b40aa5fc1c438e24df52b843c1bdc5da47d46d4c1c630/pybase64-1.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:63316560a94ac449fe86cb8b9e0a13714c659417e92e26a5cbf085cd0a0c838d", size = 57952 },
+ { url = "https://files.pythonhosted.org/packages/de/cc/ceb949232dbbd3ec4ee0190d1df4361296beceee9840390a63df8bc31784/pybase64-1.4.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ecd796f2ac0be7b73e7e4e232b8c16422014de3295d43e71d2b19fd4a4f5368", size = 54484 },
+ { url = "https://files.pythonhosted.org/packages/a7/69/659f3c8e6a5d7b753b9c42a4bd9c42892a0f10044e9c7351a4148d413a33/pybase64-1.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d01e102a12fb2e1ed3dc11611c2818448626637857ec3994a9cf4809dfd23477", size = 56542 },
+ { url = "https://files.pythonhosted.org/packages/85/2c/29c9e6c9c82b72025f9676f9e82eb1fd2339ad038cbcbf8b9e2ac02798fc/pybase64-1.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ebff797a93c2345f22183f454fd8607a34d75eca5a3a4a969c1c75b304cee39d", size = 71045 },
+ { url = "https://files.pythonhosted.org/packages/b9/84/5a3dce8d7a0040a5c0c14f0fe1311cd8db872913fa04438071b26b0dac04/pybase64-1.4.3-cp314-cp314-win32.whl", hash = "sha256:28b2a1bb0828c0595dc1ea3336305cd97ff85b01c00d81cfce4f92a95fb88f56", size = 34200 },
+ { url = "https://files.pythonhosted.org/packages/57/bc/ce7427c12384adee115b347b287f8f3cf65860b824d74fe2c43e37e81c1f/pybase64-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:33338d3888700ff68c3dedfcd49f99bfc3b887570206130926791e26b316b029", size = 36323 },
+ { url = "https://files.pythonhosted.org/packages/9a/1b/2b8ffbe9a96eef7e3f6a5a7be75995eebfb6faaedc85b6da6b233e50c778/pybase64-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:62725669feb5acb186458da2f9353e88ae28ef66bb9c4c8d1568b12a790dfa94", size = 31584 },
+ { url = "https://files.pythonhosted.org/packages/ac/d8/6824c2e6fb45b8fa4e7d92e3c6805432d5edc7b855e3e8e1eedaaf6efb7c/pybase64-1.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:153fe29be038948d9372c3e77ae7d1cab44e4ba7d9aaf6f064dbeea36e45b092", size = 38601 },
+ { url = "https://files.pythonhosted.org/packages/ea/e5/10d2b3a4ad3a4850be2704a2f70cd9c0cf55725c8885679872d3bc846c67/pybase64-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7fe3decaa7c4a9e162327ec7bd81ce183d2b16f23c6d53b606649c6e0203e9e", size = 32078 },
+ { url = "https://files.pythonhosted.org/packages/43/04/8b15c34d3c2282f1c1b0850f1113a249401b618a382646a895170bc9b5e7/pybase64-1.4.3-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a5ae04ea114c86eb1da1f6e18d75f19e3b5ae39cb1d8d3cd87c29751a6a22780", size = 72474 },
+ { url = "https://files.pythonhosted.org/packages/42/00/f34b4d11278f8fdc68bc38f694a91492aa318f7c6f1bd7396197ac0f8b12/pybase64-1.4.3-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1755b3dce3a2a5c7d17ff6d4115e8bee4a1d5aeae74469db02e47c8f477147da", size = 75706 },
+ { url = "https://files.pythonhosted.org/packages/bb/5d/71747d4ad7fe16df4c4c852bdbdeb1f2cf35677b48d7c34d3011a7a6ad3a/pybase64-1.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb852f900e27ffc4ec1896817535a0fa19610ef8875a096b59f21d0aa42ff172", size = 65589 },
+ { url = "https://files.pythonhosted.org/packages/49/b1/d1e82bd58805bb5a3a662864800bab83a83a36ba56e7e3b1706c708002a5/pybase64-1.4.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:9cf21ea8c70c61eddab3421fbfce061fac4f2fb21f7031383005a1efdb13d0b9", size = 60670 },
+ { url = "https://files.pythonhosted.org/packages/15/67/16c609b7a13d1d9fc87eca12ba2dce5e67f949eeaab61a41bddff843cbb0/pybase64-1.4.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:afff11b331fdc27692fc75e85ae083340a35105cea1a3c4552139e2f0e0d174f", size = 64194 },
+ { url = "https://files.pythonhosted.org/packages/3c/11/37bc724e42960f0106c2d33dc957dcec8f760c91a908cc6c0df7718bc1a8/pybase64-1.4.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9a5143df542c1ce5c1f423874b948c4d689b3f05ec571f8792286197a39ba02", size = 64984 },
+ { url = "https://files.pythonhosted.org/packages/6e/66/b2b962a6a480dd5dae3029becf03ea1a650d326e39bf1c44ea3db78bb010/pybase64-1.4.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d62e9861019ad63624b4a7914dff155af1cc5d6d79df3be14edcaedb5fdad6f9", size = 58750 },
+ { url = "https://files.pythonhosted.org/packages/2b/15/9b6d711035e29b18b2e1c03d47f41396d803d06ef15b6c97f45b75f73f04/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:84cfd4d92668ef5766cc42a9c9474b88960ac2b860767e6e7be255c6fddbd34a", size = 63816 },
+ { url = "https://files.pythonhosted.org/packages/b4/21/e2901381ed0df62e2308380f30d9c4d87d6b74e33a84faed3478d33a7197/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:60fc025437f9a7c2cc45e0c19ed68ed08ba672be2c5575fd9d98bdd8f01dd61f", size = 56348 },
+ { url = "https://files.pythonhosted.org/packages/c4/16/3d788388a178a0407aa814b976fe61bfa4af6760d9aac566e59da6e4a8b4/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edc8446196f04b71d3af76c0bd1fe0a45066ac5bffecca88adb9626ee28c266f", size = 72842 },
+ { url = "https://files.pythonhosted.org/packages/a6/63/c15b1f8bd47ea48a5a2d52a4ec61f037062932ea6434ab916107b58e861e/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e99f6fa6509c037794da57f906ade271f52276c956d00f748e5b118462021d48", size = 62651 },
+ { url = "https://files.pythonhosted.org/packages/bd/b8/f544a2e37c778d59208966d4ef19742a0be37c12fc8149ff34483c176616/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d94020ef09f624d841aa9a3a6029df8cf65d60d7a6d5c8687579fa68bd679b65", size = 58295 },
+ { url = "https://files.pythonhosted.org/packages/03/99/1fae8a3b7ac181e36f6e7864a62d42d5b1f4fa7edf408c6711e28fba6b4d/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f64ce70d89942a23602dee910dec9b48e5edf94351e1b378186b74fcc00d7f66", size = 60960 },
+ { url = "https://files.pythonhosted.org/packages/9d/9e/cd4c727742345ad8384569a4466f1a1428f4e5cc94d9c2ab2f53d30be3fe/pybase64-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ea99f56e45c469818b9781903be86ba4153769f007ba0655fa3b46dc332803d", size = 74863 },
+ { url = "https://files.pythonhosted.org/packages/28/86/a236ecfc5b494e1e922da149689f690abc84248c7c1358f5605b8c9fdd60/pybase64-1.4.3-cp314-cp314t-win32.whl", hash = "sha256:343b1901103cc72362fd1f842524e3bb24978e31aea7ff11e033af7f373f66ab", size = 34513 },
+ { url = "https://files.pythonhosted.org/packages/56/ce/ca8675f8d1352e245eb012bfc75429ee9cf1f21c3256b98d9a329d44bf0f/pybase64-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57aff6f7f9dea6705afac9d706432049642de5b01080d3718acc23af87c5af76", size = 36702 },
+ { url = "https://files.pythonhosted.org/packages/3b/30/4a675864877397179b09b720ee5fcb1cf772cf7bebc831989aff0a5f79c1/pybase64-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e906aa08d4331e799400829e0f5e4177e76a3281e8a4bc82ba114c6b30e405c9", size = 31904 },
+ { url = "https://files.pythonhosted.org/packages/b2/76/160dded493c00d3376d4ad0f38a2119c5345de4a6693419ad39c3565959b/pybase64-1.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:277de6e03cc9090fb359365c686a2a3036d23aee6cd20d45d22b8c89d1247f17", size = 37939 },
+ { url = "https://files.pythonhosted.org/packages/b7/b8/a0f10be8d648d6f8f26e560d6e6955efa7df0ff1e009155717454d76f601/pybase64-1.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ab1dd8b1ed2d1d750260ed58ab40defaa5ba83f76a30e18b9ebd5646f6247ae5", size = 31466 },
+ { url = "https://files.pythonhosted.org/packages/d3/22/832a2f9e76cdf39b52e01e40d8feeb6a04cf105494f2c3e3126d0149717f/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:bd4d2293de9fd212e294c136cec85892460b17d24e8c18a6ba18750928037750", size = 40681 },
+ { url = "https://files.pythonhosted.org/packages/12/d7/6610f34a8972415fab3bb4704c174a1cc477bffbc3c36e526428d0f3957d/pybase64-1.4.3-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af6d0d3a691911cc4c9a625f3ddcd3af720738c21be3d5c72de05629139d393", size = 41294 },
+ { url = "https://files.pythonhosted.org/packages/64/25/ed24400948a6c974ab1374a233cb7e8af0a5373cea0dd8a944627d17c34a/pybase64-1.4.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfc8c49a28322d82242088378f8542ce97459866ba73150b062a7073e82629d", size = 35447 },
+ { url = "https://files.pythonhosted.org/packages/ee/2b/e18ee7c5ee508a82897f021c1981533eca2940b5f072fc6ed0906c03a7a7/pybase64-1.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:debf737e09b8bf832ba86f5ecc3d3dbd0e3021d6cd86ba4abe962d6a5a77adb3", size = 36134 },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
- { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
- { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
- { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
- { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
- { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
- { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
- { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
- { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
- { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
- { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
- { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
- { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
- { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
- { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
- { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
- { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
- { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
- { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
- { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
- { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
- { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
+ { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152 },
+ { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348 },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033 },
+ { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142 },
+ { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384 },
+ { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237 },
+ { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898 },
+ { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197 },
+ { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600 },
+ { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740 },
+ { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685 },
+ { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627 },
+ { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362 },
+ { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625 },
+ { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954 },
+ { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534 },
+ { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853 },
+ { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465 },
+ { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414 },
+ { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 },
+ { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 },
+ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 },
]
[[package]]
@@ -4263,9 +4242,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 },
]
[[package]]
@@ -4275,94 +4254,86 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
- { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
- { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
- { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
- { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
- { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
- { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
- { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
- { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
- { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
- { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
- { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
- { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
- { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
- { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
- { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
- { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
- { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
- { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
- { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
- { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
- { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
- { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
- { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
- { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
- { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
- { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
- { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
- { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
- { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
- { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
- { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
- { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
- { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
- { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
- { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
- { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
- { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
- { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
- { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
- { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
- { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
- { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
- { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
- { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
- { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
- { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
- { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
- { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
- { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
- { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
- { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
- { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
- { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
- { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
- { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
- { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
- { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
- { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
- { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
- { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
- { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
- { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
- { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
- { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
- { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
- { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
- { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
- { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
- { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
- { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
- { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
- { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
- { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
- { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
- { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
- { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
- { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
- { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
- { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
- { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
- { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
- { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
- { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 },
+ { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 },
+ { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 },
+ { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 },
+ { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 },
+ { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 },
+ { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 },
+ { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 },
+ { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 },
+ { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 },
+ { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 },
+ { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 },
+ { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 },
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 },
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 },
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 },
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 },
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 },
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 },
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 },
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 },
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 },
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 },
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 },
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 },
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 },
+ { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 },
+ { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 },
+ { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 },
+ { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 },
+ { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 },
+ { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 },
+ { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 },
+ { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 },
+ { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 },
+ { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 },
+ { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 },
+ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 },
+ { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 },
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 },
+ { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
+ { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },
+ { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 },
+ { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 },
+ { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 },
+ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 },
]
[[package]]
@@ -4374,27 +4345,27 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
+ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715 },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
]
[[package]]
name = "pyjwt"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 },
]
[package.optional-dependencies]
@@ -4407,18 +4378,18 @@ name = "pylibseekdb"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176, upload-time = "2026-05-25T08:59:18.118Z" },
- { url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003, upload-time = "2026-05-25T06:11:51.929Z" },
- { url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660, upload-time = "2026-05-25T06:12:02.817Z" },
- { url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028, upload-time = "2026-05-25T08:59:41.571Z" },
- { url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851, upload-time = "2026-05-25T06:12:11.973Z" },
- { url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328, upload-time = "2026-05-25T06:12:22.051Z" },
- { url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219, upload-time = "2026-05-25T09:00:08.798Z" },
- { url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366, upload-time = "2026-05-25T06:12:31.689Z" },
- { url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143, upload-time = "2026-05-25T06:12:43.005Z" },
- { url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982, upload-time = "2026-05-25T09:00:26.672Z" },
- { url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377, upload-time = "2026-05-25T06:12:53.468Z" },
- { url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373, upload-time = "2026-05-25T06:13:03.535Z" },
+ { url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176 },
+ { url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003 },
+ { url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660 },
+ { url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028 },
+ { url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851 },
+ { url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328 },
+ { url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219 },
+ { url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366 },
+ { url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143 },
+ { url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982 },
+ { url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377 },
+ { url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373 },
]
[[package]]
@@ -4434,18 +4405,18 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "setuptools" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/71/68/9b8bac2267af60035d65fb5a4247c5ac8da175d66ec794d84d9cd3486524/pymilvus-2.6.8.tar.gz", hash = "sha256:15232f5f66805bf2f50b30bbad59637b62f5258d9343f7615353ce1221fab6b5", size = 1421303, upload-time = "2026-01-29T07:32:16.519Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/71/68/9b8bac2267af60035d65fb5a4247c5ac8da175d66ec794d84d9cd3486524/pymilvus-2.6.8.tar.gz", hash = "sha256:15232f5f66805bf2f50b30bbad59637b62f5258d9343f7615353ce1221fab6b5", size = 1421303 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b8/27/3af2199afaabd48791584fa5da5929f08d1a3c8c37a2ef12c15fc9309111/pymilvus-2.6.8-py3-none-any.whl", hash = "sha256:c4c413ffdef2599064301fd831de6f9839a753abe27c68c6148707629711d069", size = 300995, upload-time = "2026-01-29T07:32:14.199Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/27/3af2199afaabd48791584fa5da5929f08d1a3c8c37a2ef12c15fc9309111/pymilvus-2.6.8-py3-none-any.whl", hash = "sha256:c4c413ffdef2599064301fd831de6f9839a753abe27c68c6148707629711d069", size = 300995 },
]
[[package]]
name = "pymysql"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258, upload-time = "2025-08-24T12:55:55.146Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/ae/1fe3fcd9f959efa0ebe200b8de88b5a5ce3e767e38c7ac32fb179f16a388/pymysql-1.1.2.tar.gz", hash = "sha256:4961d3e165614ae65014e361811a724e2044ad3ea3739de9903ae7c21f539f03", size = 48258 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300 },
]
[[package]]
@@ -4455,77 +4426,77 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" },
- { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" },
- { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" },
- { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" },
- { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" },
- { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" },
- { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" },
- { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" },
- { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" },
- { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" },
- { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" },
- { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" },
- { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" },
- { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" },
- { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" },
- { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" },
- { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" },
- { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" },
- { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" },
- { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" },
- { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" },
- { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" },
- { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" },
- { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064 },
+ { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370 },
+ { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304 },
+ { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871 },
+ { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356 },
+ { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814 },
+ { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742 },
+ { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714 },
+ { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257 },
+ { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319 },
+ { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044 },
+ { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740 },
+ { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458 },
+ { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020 },
+ { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174 },
+ { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085 },
+ { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614 },
+ { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251 },
+ { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859 },
+ { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926 },
+ { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101 },
+ { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421 },
+ { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754 },
+ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801 },
]
[[package]]
name = "pypdf2"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419, upload-time = "2022-12-31T10:36:13.13Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572, upload-time = "2022-12-31T10:36:10.327Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572 },
]
[[package]]
name = "pypika"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fb/fb/b7d5f29108b07c10c69fc3bb72e12f869d55a360a449749fba5a1f903525/pypika-0.50.0.tar.gz", hash = "sha256:2ff66a153adc8d8877879ff2abd5a3b050a5d2adfdf8659d3402076e385e35b3", size = 81033, upload-time = "2026-01-14T12:34:21.895Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fb/fb/b7d5f29108b07c10c69fc3bb72e12f869d55a360a449749fba5a1f903525/pypika-0.50.0.tar.gz", hash = "sha256:2ff66a153adc8d8877879ff2abd5a3b050a5d2adfdf8659d3402076e385e35b3", size = 81033 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/18/5b/419c5bb460cb27b52fcd3bc96830255c3265bc1859f55aafa3ff08fae8bd/pypika-0.50.0-py2.py3-none-any.whl", hash = "sha256:ed11b7e259bc38abbcfde00cfb31f8d00aa42ffa51e437b8f5ac2db12b0fe0f4", size = 60577, upload-time = "2026-01-14T12:34:20.078Z" },
+ { url = "https://files.pythonhosted.org/packages/18/5b/419c5bb460cb27b52fcd3bc96830255c3265bc1859f55aafa3ff08fae8bd/pypika-0.50.0-py2.py3-none-any.whl", hash = "sha256:ed11b7e259bc38abbcfde00cfb31f8d00aa42ffa51e437b8f5ac2db12b0fe0f4", size = 60577 },
]
[[package]]
name = "pypng"
version = "0.20220715.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/93/cd/112f092ec27cca83e0516de0a3368dbd9128c187fb6b52aaaa7cde39c96d/pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1", size = 128992, upload-time = "2022-07-15T14:11:05.301Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/93/cd/112f092ec27cca83e0516de0a3368dbd9128c187fb6b52aaaa7cde39c96d/pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1", size = 128992 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3e/b9/3766cc361d93edb2ce81e2e1f87dd98f314d7d513877a342d31b30741680/pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", size = 58057, upload-time = "2022-07-15T14:11:03.713Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/b9/3766cc361d93edb2ce81e2e1f87dd98f314d7d513877a342d31b30741680/pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c", size = 58057 },
]
[[package]]
name = "pyproject-hooks"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 },
]
[[package]]
name = "pyreadline3"
version = "3.5.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 },
]
[[package]]
@@ -4544,7 +4515,7 @@ dependencies = [
{ name = "tqdm", marker = "python_full_version < '3.14'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985, upload-time = "2026-02-12T14:19:05.402Z" },
+ { url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985 },
]
[[package]]
@@ -4558,9 +4529,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 },
]
[[package]]
@@ -4571,9 +4542,9 @@ dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 },
]
[[package]]
@@ -4585,9 +4556,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 },
]
[[package]]
@@ -4597,9 +4568,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
@@ -4610,36 +4581,36 @@ dependencies = [
{ name = "lxml" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987 },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042 },
]
[[package]]
name = "python-socks"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6c/07/cfdd6a846ac859e513b4e68bb6c669a90a74d89d8d405516fba7fc9c6f0c/python_socks-2.8.0.tar.gz", hash = "sha256:340f82778b20a290bdd538ee47492978d603dff7826aaf2ce362d21ad9ee6f1b", size = 273130, upload-time = "2025-12-09T12:17:05.433Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/07/cfdd6a846ac859e513b4e68bb6c669a90a74d89d8d405516fba7fc9c6f0c/python_socks-2.8.0.tar.gz", hash = "sha256:340f82778b20a290bdd538ee47492978d603dff7826aaf2ce362d21ad9ee6f1b", size = 273130 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/13/10/e2b575faa32d1d32e5e6041fc64794fa9f09526852a06b25353b66f52cae/python_socks-2.8.0-py3-none-any.whl", hash = "sha256:57c24b416569ccea493a101d38b0c82ed54be603aa50b6afbe64c46e4a4e4315", size = 55075, upload-time = "2025-12-09T12:17:03.269Z" },
+ { url = "https://files.pythonhosted.org/packages/13/10/e2b575faa32d1d32e5e6041fc64794fa9f09526852a06b25353b66f52cae/python_socks-2.8.0-py3-none-any.whl", hash = "sha256:57c24b416569ccea493a101d38b0c82ed54be603aa50b6afbe64c46e4a4e4315", size = 55075 },
]
[[package]]
@@ -4650,9 +4621,9 @@ dependencies = [
{ name = "httpcore", marker = "python_full_version >= '3.14'" },
{ name = "httpx" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cd/9b/8df90c85404166a6631e857027866263adb27440d8af1dbeffbdc4f0166c/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742", size = 1503761, upload-time = "2026-01-24T13:57:00.269Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/9b/8df90c85404166a6631e857027866263adb27440d8af1dbeffbdc4f0166c/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742", size = 1503761 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/13/97/7298f0e1afe3a1ae52ff4c5af5087ed4de319ea73eb3b5c8c4dd4e76e708/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0", size = 737267, upload-time = "2026-01-24T13:56:58.06Z" },
+ { url = "https://files.pythonhosted.org/packages/13/97/7298f0e1afe3a1ae52ff4c5af5087ed4de319ea73eb3b5c8c4dd4e76e708/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0", size = 737267 },
]
[[package]]
@@ -4660,73 +4631,73 @@ name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" },
- { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" },
- { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" },
- { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
- { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
- { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
- { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
- { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
- { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
- { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
- { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
- { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031 },
+ { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308 },
+ { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930 },
+ { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 },
+ { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 },
+ { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 },
+ { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700 },
+ { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700 },
+ { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318 },
+ { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714 },
+ { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800 },
+ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 },
]
[[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" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
- { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
- { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
- { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
- { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
- { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
- { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
- { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
- { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
- { 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" },
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[package]]
@@ -4742,9 +4713,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ca/7d/3cd10e26ae97b35cf856ca1dc67576e42414ae39502c51165bb36bb1dff8/qdrant_client-1.16.2.tar.gz", hash = "sha256:ca4ef5f9be7b5eadeec89a085d96d5c723585a391eb8b2be8192919ab63185f0", size = 331112, upload-time = "2025-12-12T10:58:30.866Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/7d/3cd10e26ae97b35cf856ca1dc67576e42414ae39502c51165bb36bb1dff8/qdrant_client-1.16.2.tar.gz", hash = "sha256:ca4ef5f9be7b5eadeec89a085d96d5c723585a391eb8b2be8192919ab63185f0", size = 331112 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186, upload-time = "2025-12-12T10:58:29.282Z" },
+ { url = "https://files.pythonhosted.org/packages/08/13/8ce16f808297e16968269de44a14f4fef19b64d9766be1d6ba5ba78b579d/qdrant_client-1.16.2-py3-none-any.whl", hash = "sha256:442c7ef32ae0f005e88b5d3c0783c63d4912b97ae756eb5e052523be682f17d3", size = 377186 },
]
[[package]]
@@ -4756,9 +4727,9 @@ dependencies = [
{ name = "apscheduler" },
{ name = "pyyaml" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fc/80/d1a9cb6e33c94b19eedf8e6276e8cc4a1d51f70deab565fbba6fa9f77441/qq_botpy_rc-1.2.1.6.tar.gz", hash = "sha256:c4215417a58e4acab05ec3f3bb3648b3ead6b8c2c6aa6daf648249ba9dff5fde", size = 44506, upload-time = "2024-11-16T02:11:07.909Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/80/d1a9cb6e33c94b19eedf8e6276e8cc4a1d51f70deab565fbba6fa9f77441/qq_botpy_rc-1.2.1.6.tar.gz", hash = "sha256:c4215417a58e4acab05ec3f3bb3648b3ead6b8c2c6aa6daf648249ba9dff5fde", size = 44506 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/09/71/94578dd83f535684f12a949184906e4e06394bffbe33c70696985ef5ae05/qq_botpy_rc-1.2.1.6-py3-none-any.whl", hash = "sha256:f3e18248af23856e2e9ef267691945ab1580087de1886e20ee0e8605a0d558b7", size = 51459, upload-time = "2024-11-16T02:11:06.235Z" },
+ { url = "https://files.pythonhosted.org/packages/09/71/94578dd83f535684f12a949184906e4e06394bffbe33c70696985ef5ae05/qq_botpy_rc-1.2.1.6-py3-none-any.whl", hash = "sha256:f3e18248af23856e2e9ef267691945ab1580087de1886e20ee0e8605a0d558b7", size = 51459 },
]
[[package]]
@@ -4770,9 +4741,9 @@ dependencies = [
{ name = "pypng" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/30/35/ad6d4c5a547fe9a5baf85a9edbafff93fc6394b014fab30595877305fa59/qrcode-7.4.2.tar.gz", hash = "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845", size = 535974, upload-time = "2023-02-05T22:11:46.548Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/35/ad6d4c5a547fe9a5baf85a9edbafff93fc6394b014fab30595877305fa59/qrcode-7.4.2.tar.gz", hash = "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845", size = 535974 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/24/79/aaf0c1c7214f2632badb2771d770b1500d3d7cbdf2590ae62e721ec50584/qrcode-7.4.2-py3-none-any.whl", hash = "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a", size = 46197, upload-time = "2023-02-05T22:11:43.4Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/aaf0c1c7214f2632badb2771d770b1500d3d7cbdf2590ae62e721ec50584/qrcode-7.4.2-py3-none-any.whl", hash = "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a", size = 46197 },
]
[[package]]
@@ -4790,9 +4761,9 @@ dependencies = [
{ name = "markupsafe" },
{ name = "werkzeug" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960 },
]
[[package]]
@@ -4802,9 +4773,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "quart" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/14/b1/2a65be601f3c92c913f3321ee186d10c2da4325447b4b0fca83e0c493c60/quart_cors-0.8.0.tar.gz", hash = "sha256:ac32c4931da6fba944e9e2d3f856f2db4fd82e3fb905a09646086780c221a118", size = 12466, upload-time = "2024-12-27T20:34:32.245Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/14/b1/2a65be601f3c92c913f3321ee186d10c2da4325447b4b0fca83e0c493c60/quart_cors-0.8.0.tar.gz", hash = "sha256:ac32c4931da6fba944e9e2d3f856f2db4fd82e3fb905a09646086780c221a118", size = 12466 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ea/31/da390a5a10674481dea2909178973de81fa3a246c0eedcc0e1e4114f52f8/quart_cors-0.8.0-py3-none-any.whl", hash = "sha256:62dc811768e2e1704d2b99d5880e3eb26fc776832305a19ea53db66f63837767", size = 8698, upload-time = "2024-12-27T20:34:29.511Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/31/da390a5a10674481dea2909178973de81fa3a246c0eedcc0e1e4114f52f8/quart_cors-0.8.0-py3-none-any.whl", hash = "sha256:62dc811768e2e1704d2b99d5880e3eb26fc776832305a19ea53db66f63837767", size = 8698 },
]
[[package]]
@@ -4816,113 +4787,113 @@ dependencies = [
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 },
]
[[package]]
name = "regex"
version = "2026.1.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" },
- { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" },
- { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" },
- { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" },
- { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" },
- { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" },
- { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" },
- { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" },
- { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" },
- { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" },
- { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" },
- { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" },
- { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" },
- { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" },
- { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" },
- { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" },
- { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" },
- { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" },
- { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" },
- { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" },
- { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" },
- { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" },
- { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" },
- { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" },
- { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" },
- { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" },
- { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" },
- { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" },
- { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" },
- { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" },
- { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" },
- { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" },
- { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" },
- { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" },
- { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" },
- { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" },
- { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" },
- { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" },
- { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" },
- { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" },
- { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" },
- { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" },
- { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" },
- { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" },
- { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" },
- { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" },
- { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" },
- { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" },
- { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" },
- { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" },
- { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" },
- { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" },
- { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" },
- { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" },
- { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" },
- { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" },
- { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" },
- { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" },
- { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" },
- { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" },
- { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" },
- { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" },
- { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" },
- { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" },
- { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
- { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
- { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
- { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
- { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
- { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
- { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
- { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
- { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
- { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
- { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
- { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
- { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
- { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
- { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
- { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
- { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
- { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
- { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
- { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
- { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
- { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
- { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
- { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
- { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
- { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
- { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
- { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
- { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
- { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
- { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
- { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168 },
+ { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636 },
+ { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496 },
+ { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503 },
+ { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535 },
+ { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225 },
+ { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526 },
+ { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446 },
+ { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051 },
+ { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485 },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195 },
+ { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986 },
+ { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992 },
+ { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893 },
+ { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840 },
+ { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374 },
+ { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398 },
+ { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339 },
+ { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003 },
+ { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656 },
+ { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252 },
+ { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268 },
+ { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589 },
+ { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700 },
+ { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928 },
+ { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607 },
+ { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729 },
+ { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697 },
+ { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849 },
+ { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279 },
+ { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166 },
+ { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415 },
+ { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164 },
+ { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218 },
+ { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895 },
+ { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680 },
+ { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210 },
+ { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358 },
+ { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583 },
+ { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782 },
+ { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978 },
+ { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550 },
+ { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747 },
+ { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615 },
+ { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951 },
+ { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275 },
+ { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145 },
+ { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411 },
+ { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068 },
+ { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756 },
+ { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114 },
+ { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524 },
+ { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455 },
+ { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007 },
+ { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794 },
+ { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159 },
+ { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558 },
+ { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427 },
+ { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939 },
+ { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753 },
+ { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559 },
+ { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879 },
+ { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317 },
+ { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551 },
+ { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170 },
+ { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146 },
+ { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986 },
+ { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098 },
+ { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980 },
+ { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607 },
+ { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358 },
+ { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833 },
+ { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045 },
+ { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374 },
+ { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940 },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112 },
+ { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586 },
+ { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691 },
+ { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422 },
+ { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467 },
+ { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073 },
+ { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757 },
+ { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122 },
+ { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761 },
+ { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538 },
+ { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066 },
+ { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938 },
+ { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314 },
+ { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652 },
+ { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550 },
+ { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981 },
+ { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780 },
+ { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778 },
+ { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667 },
+ { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386 },
+ { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837 },
]
[[package]]
@@ -4935,9 +4906,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 },
]
[[package]]
@@ -4948,9 +4919,9 @@ dependencies = [
{ name = "oauthlib" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
]
[[package]]
@@ -4960,9 +4931,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 },
]
[[package]]
@@ -4974,9 +4945,9 @@ dependencies = [
{ name = "requests" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099 },
]
[[package]]
@@ -4987,143 +4958,143 @@ dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125, upload-time = "2026-01-24T21:40:44.847Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" },
+ { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952 },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" },
- { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" },
- { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" },
- { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" },
- { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" },
- { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" },
- { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" },
- { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" },
- { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" },
- { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" },
- { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" },
- { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" },
- { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" },
- { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" },
- { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" },
- { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
- { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
- { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
- { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
- { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
- { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
- { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
- { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
- { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
- { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
- { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
- { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
- { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
- { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
- { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
- { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
- { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
- { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
- { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
- { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
- { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
- { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
- { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
- { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
- { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
- { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
- { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
- { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
- { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
- { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
- { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
- { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
- { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
- { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
- { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
- { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
- { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
- { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
- { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
- { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
- { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
- { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
- { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
- { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
- { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
- { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
- { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
- { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
- { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
- { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
- { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
- { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
- { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
- { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
- { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
- { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
- { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
- { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
- { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
- { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
- { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
- { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
- { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
- { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
- { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
- { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
- { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
- { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
- { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
- { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
- { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
- { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
- { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
- { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" },
- { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" },
- { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" },
- { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" },
- { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" },
- { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" },
- { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" },
- { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" },
- { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" },
- { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" },
- { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" },
- { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157 },
+ { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676 },
+ { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938 },
+ { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932 },
+ { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830 },
+ { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033 },
+ { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828 },
+ { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683 },
+ { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583 },
+ { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496 },
+ { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669 },
+ { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011 },
+ { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406 },
+ { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024 },
+ { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069 },
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 },
+ { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292 },
+ { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128 },
+ { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542 },
+ { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004 },
+ { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063 },
+ { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099 },
+ { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177 },
+ { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015 },
+ { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736 },
+ { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981 },
+ { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782 },
+ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191 },
]
[[package]]
name = "ruff"
version = "0.14.14"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
- { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
- { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
- { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
- { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
- { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
- { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
- { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
- { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
- { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
- { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
- { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
- { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
- { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
- { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
- { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
- { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
- { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650 },
+ { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245 },
+ { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273 },
+ { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753 },
+ { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052 },
+ { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637 },
+ { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761 },
+ { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701 },
+ { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455 },
+ { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882 },
+ { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549 },
+ { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416 },
+ { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491 },
+ { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525 },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626 },
+ { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442 },
+ { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486 },
+ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448 },
]
[[package]]
@@ -5133,31 +5104,31 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830 },
]
[[package]]
name = "safetensors"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
- { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
- { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
- { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
- { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
- { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
- { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
- { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
- { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
- { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
- { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
- { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
- { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781 },
+ { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058 },
+ { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748 },
+ { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881 },
+ { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463 },
+ { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855 },
+ { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152 },
+ { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856 },
+ { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060 },
+ { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715 },
+ { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377 },
+ { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368 },
+ { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423 },
+ { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380 },
]
[[package]]
@@ -5170,44 +5141,44 @@ dependencies = [
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" },
- { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" },
- { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" },
- { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" },
- { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" },
- { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" },
- { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
- { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
- { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
- { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
- { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" },
- { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" },
- { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
- { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
- { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
- { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
- { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
- { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
- { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
- { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
- { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
- { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
- { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
- { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
- { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
- { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
- { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
- { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
- { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
- { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
- { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
- { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
- { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
- { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
- { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
- { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835 },
+ { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381 },
+ { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632 },
+ { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788 },
+ { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706 },
+ { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451 },
+ { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242 },
+ { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075 },
+ { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492 },
+ { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904 },
+ { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359 },
+ { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898 },
+ { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770 },
+ { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458 },
+ { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341 },
+ { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022 },
+ { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409 },
+ { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760 },
+ { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045 },
+ { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324 },
+ { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651 },
+ { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045 },
+ { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994 },
+ { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518 },
+ { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667 },
+ { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524 },
+ { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133 },
+ { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223 },
+ { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518 },
+ { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546 },
+ { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305 },
+ { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257 },
+ { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673 },
+ { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467 },
+ { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395 },
+ { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647 },
]
[[package]]
@@ -5217,68 +5188,68 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.14'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
- { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
- { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
- { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
- { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
- { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
- { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
- { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
- { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
- { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
- { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
- { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
- { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
- { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
- { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
- { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
- { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
- { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
- { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
- { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
- { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
- { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
- { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
- { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
- { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
- { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
- { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
- { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
- { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
- { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
- { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
- { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
- { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
- { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
- { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
- { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
- { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
- { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
- { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
- { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
- { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" },
- { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" },
- { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" },
- { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" },
- { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" },
- { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" },
- { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" },
- { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" },
- { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" },
- { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" },
- { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" },
- { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" },
- { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" },
- { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" },
- { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" },
- { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" },
- { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" },
- { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" },
- { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" },
- { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
+ { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675 },
+ { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057 },
+ { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032 },
+ { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533 },
+ { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057 },
+ { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300 },
+ { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333 },
+ { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314 },
+ { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512 },
+ { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248 },
+ { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954 },
+ { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662 },
+ { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366 },
+ { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017 },
+ { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842 },
+ { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890 },
+ { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557 },
+ { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856 },
+ { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682 },
+ { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340 },
+ { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199 },
+ { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001 },
+ { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719 },
+ { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595 },
+ { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429 },
+ { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952 },
+ { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063 },
+ { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449 },
+ { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943 },
+ { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621 },
+ { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708 },
+ { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135 },
+ { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977 },
+ { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601 },
+ { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667 },
+ { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159 },
+ { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771 },
+ { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910 },
+ { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980 },
+ { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543 },
+ { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510 },
+ { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131 },
+ { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032 },
+ { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766 },
+ { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007 },
+ { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333 },
+ { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066 },
+ { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763 },
+ { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984 },
+ { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877 },
+ { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750 },
+ { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858 },
+ { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723 },
+ { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098 },
+ { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397 },
+ { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163 },
+ { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291 },
+ { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317 },
+ { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327 },
+ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165 },
]
[[package]]
@@ -5295,63 +5266,63 @@ dependencies = [
{ name = "transformers", marker = "python_full_version >= '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/46/9f/dba4b3e18ebbe1eaa29d9f1764fbc7da0cd91937b83f2b7928d15c5d2d36/sentence_transformers-5.2.3-py3-none-any.whl", hash = "sha256:6437c62d4112b615ddebda362dfc16a4308d604c5b68125ed586e3e95d5b2e30", size = 494225, upload-time = "2026-02-17T14:05:18.596Z" },
+ { url = "https://files.pythonhosted.org/packages/46/9f/dba4b3e18ebbe1eaa29d9f1764fbc7da0cd91937b83f2b7928d15c5d2d36/sentence_transformers-5.2.3-py3-none-any.whl", hash = "sha256:6437c62d4112b615ddebda362dfc16a4308d604c5b68125ed586e3e95d5b2e30", size = 494225 },
]
[[package]]
name = "setuptools"
version = "80.10.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" },
+ { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234 },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "slack-sdk"
version = "3.39.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b6/dd/645f3eb93fce38eadbb649e85684730b1fc3906c2674ca59bddc2ca2bd2e/slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1", size = 247207, upload-time = "2025-11-20T15:27:57.556Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/dd/645f3eb93fce38eadbb649e85684730b1fc3906c2674ca59bddc2ca2bd2e/slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1", size = 247207 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/1f/32bcf088e535c1870b1a1f2e3b916129c66fdfe565a793316317241d41e5/slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8", size = 309850, upload-time = "2025-11-20T15:27:55.729Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/1f/32bcf088e535c1870b1a1f2e3b916129c66fdfe565a793316317241d41e5/slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8", size = 309850 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]]
name = "soupsieve"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016 },
]
[[package]]
@@ -5362,45 +5333,45 @@ dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" },
- { url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" },
- { url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" },
- { url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116, upload-time = "2026-01-21T18:32:35.044Z" },
- { url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327, upload-time = "2026-01-21T18:44:59.254Z" },
- { url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564, upload-time = "2026-01-21T18:33:15.85Z" },
- { url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233, upload-time = "2026-01-21T18:33:17.528Z" },
- { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" },
- { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" },
- { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" },
- { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" },
- { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" },
- { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" },
- { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" },
- { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" },
- { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" },
- { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" },
- { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" },
- { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" },
- { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" },
- { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" },
- { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" },
- { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" },
- { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" },
- { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" },
- { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
- { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
- { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
- { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
- { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
- { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
- { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
- { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
- { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
- { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
- { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
- { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851 },
+ { url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241 },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741 },
+ { url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116 },
+ { url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327 },
+ { url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564 },
+ { url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233 },
+ { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405 },
+ { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702 },
+ { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664 },
+ { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372 },
+ { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425 },
+ { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155 },
+ { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078 },
+ { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268 },
+ { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511 },
+ { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881 },
+ { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559 },
+ { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728 },
+ { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295 },
+ { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076 },
+ { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533 },
+ { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208 },
+ { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292 },
+ { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497 },
+ { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079 },
+ { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216 },
+ { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208 },
+ { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994 },
+ { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990 },
+ { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215 },
+ { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867 },
+ { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202 },
+ { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296 },
+ { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008 },
+ { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137 },
+ { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882 },
]
[package.optional-dependencies]
@@ -5416,9 +5387,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "sqlalchemy" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/56/b8/e7cd6def4a773f25d6e29ffce63ccbfd6cf9488b804ab6fb9b80d334b39d/sqlmodel-0.0.31.tar.gz", hash = "sha256:2d41a8a9ee05e40736e2f9db8ea28cbfe9b5d4e5a18dd139e80605025e0c516c", size = 94952, upload-time = "2025-12-28T12:35:01.436Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/b8/e7cd6def4a773f25d6e29ffce63ccbfd6cf9488b804ab6fb9b80d334b39d/sqlmodel-0.0.31.tar.gz", hash = "sha256:2d41a8a9ee05e40736e2f9db8ea28cbfe9b5d4e5a18dd139e80605025e0c516c", size = 94952 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6c/72/5aa5be921800f6418a949a73c9bb7054890881143e6bc604a93d228a95a3/sqlmodel-0.0.31-py3-none-any.whl", hash = "sha256:6d946d56cac4c2db296ba1541357cee2e795d68174e2043cd138b916794b1513", size = 27093, upload-time = "2025-12-28T12:35:00.108Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/72/5aa5be921800f6418a949a73c9bb7054890881143e6bc604a93d228a95a3/sqlmodel-0.0.31-py3-none-any.whl", hash = "sha256:6d946d56cac4c2db296ba1541357cee2e795d68174e2043cd138b916794b1513", size = 27093 },
]
[[package]]
@@ -5429,9 +5400,9 @@ dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" },
+ { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763 },
]
[[package]]
@@ -5439,7 +5410,7 @@ name = "sseclient-py"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/2e/59920f7d66b7f9932a3d83dd0ec53fab001be1e058bf582606fe414a5198/sseclient_py-1.9.0-py3-none-any.whl", hash = "sha256:340062b1587fc2880892811e2ab5b176d98ef3eee98b3672ff3a3ba1e8ed0f6f", size = 8351, upload-time = "2026-01-02T23:39:30.995Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/2e/59920f7d66b7f9932a3d83dd0ec53fab001be1e058bf582606fe414a5198/sseclient_py-1.9.0-py3-none-any.whl", hash = "sha256:340062b1587fc2880892811e2ab5b176d98ef3eee98b3672ff3a3ba1e8ed0f6f", size = 8351 },
]
[[package]]
@@ -5450,9 +5421,9 @@ dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632 },
]
[[package]]
@@ -5462,9 +5433,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 },
]
[[package]]
@@ -5475,9 +5446,9 @@ dependencies = [
{ name = "httpx" },
{ name = "sseclient-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d8/ea/4817347580d3aa4a919a899f49061119d82d632ed2d6b1000e36d40af34e/tboxsdk-0.0.12.tar.gz", hash = "sha256:ba48f8805839593072ac21d22e3a97ba1a2fcb86b241f703b50c8dd904c5bc80", size = 22865, upload-time = "2025-10-29T12:07:55.169Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/ea/4817347580d3aa4a919a899f49061119d82d632ed2d6b1000e36d40af34e/tboxsdk-0.0.12.tar.gz", hash = "sha256:ba48f8805839593072ac21d22e3a97ba1a2fcb86b241f703b50c8dd904c5bc80", size = 22865 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e8/bc/861e77774d57c3211630bf130cbe6ebd0c533f856b0b11c16d03c4f8a42d/tboxsdk-0.0.12-py3-none-any.whl", hash = "sha256:c53e8a34b42a97cb1661bd33a154524d8ca87a599f445d08d2dd407d764cd3f7", size = 15920, upload-time = "2025-10-29T12:07:54.153Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/bc/861e77774d57c3211630bf130cbe6ebd0c533f856b0b11c16d03c4f8a42d/tboxsdk-0.0.12-py3-none-any.whl", hash = "sha256:c53e8a34b42a97cb1661bd33a154524d8ca87a599f445d08d2dd407d764cd3f7", size = 15920 },
]
[[package]]
@@ -5487,18 +5458,18 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mistletoe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/33/eb/8a3a557eec87c0fcd4c0939232fa5ea407801050370596daa4ca3e51a1db/telegramify_markdown-0.5.4.tar.gz", hash = "sha256:c32bd04e5a1c22519c011ccf7350a01b6d162e6cc9a9d89c83eff964d491007e", size = 40370, upload-time = "2025-12-20T06:43:11.42Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/eb/8a3a557eec87c0fcd4c0939232fa5ea407801050370596daa4ca3e51a1db/telegramify_markdown-0.5.4.tar.gz", hash = "sha256:c32bd04e5a1c22519c011ccf7350a01b6d162e6cc9a9d89c83eff964d491007e", size = 40370 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/f0/4d07bcada3cddb66bccf061661b733e8512c5580e1bd11fba2aea1488d70/telegramify_markdown-0.5.4-py3-none-any.whl", hash = "sha256:7c806e12b6c7045d7723e064a0ff25afcb16c92c0d95385b61a57b8c53a430d3", size = 33536, upload-time = "2025-12-20T06:43:10.153Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/4d07bcada3cddb66bccf061661b733e8512c5580e1bd11fba2aea1488d70/telegramify_markdown-0.5.4-py3-none-any.whl", hash = "sha256:7c806e12b6c7045d7723e064a0ff25afcb16c92c0d95385b61a57b8c53a430d3", size = 33536 },
]
[[package]]
name = "tenacity"
version = "9.1.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248 },
]
[[package]]
@@ -5513,18 +5484,18 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/38/7d169a765993efde5095c70a668bf4f5831bb7ac099e932f2783e9b71abf/textual-7.5.0.tar.gz", hash = "sha256:c730cba1e3d704e8f1ca915b6a3af01451e3bca380114baacf6abf87e9dac8b6", size = 1592319, upload-time = "2026-01-30T13:46:39.881Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/38/7d169a765993efde5095c70a668bf4f5831bb7ac099e932f2783e9b71abf/textual-7.5.0.tar.gz", hash = "sha256:c730cba1e3d704e8f1ca915b6a3af01451e3bca380114baacf6abf87e9dac8b6", size = 1592319 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164 },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
+ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638 },
]
[[package]]
@@ -5535,50 +5506,50 @@ dependencies = [
{ name = "regex" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" },
- { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" },
- { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" },
- { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" },
- { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" },
- { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" },
- { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" },
- { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
- { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
- { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
- { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
- { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
- { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
- { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
- { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
- { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
- { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
- { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
- { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
- { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
- { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
- { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
- { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
- { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
- { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
- { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
- { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
- { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
- { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" },
- { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" },
- { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" },
- { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" },
- { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" },
- { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" },
- { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" },
- { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" },
- { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" },
- { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" },
- { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" },
- { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" },
- { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" },
- { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
+ { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565 },
+ { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284 },
+ { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201 },
+ { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444 },
+ { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080 },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240 },
+ { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422 },
+ { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728 },
+ { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049 },
+ { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008 },
+ { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665 },
+ { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230 },
+ { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688 },
+ { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694 },
+ { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802 },
+ { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995 },
+ { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948 },
+ { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986 },
+ { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222 },
+ { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097 },
+ { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117 },
+ { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309 },
+ { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712 },
+ { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725 },
+ { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875 },
+ { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451 },
+ { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794 },
+ { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777 },
+ { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188 },
+ { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978 },
+ { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271 },
+ { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216 },
+ { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860 },
+ { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567 },
+ { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067 },
+ { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473 },
+ { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855 },
+ { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022 },
+ { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736 },
+ { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908 },
+ { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706 },
+ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667 },
]
[[package]]
@@ -5588,77 +5559,77 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
- { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
- { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
- { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
- { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
- { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
- { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
- { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
- { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
- { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
- { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
- { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
- { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
- { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
- { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
+ { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275 },
+ { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472 },
+ { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736 },
+ { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835 },
+ { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673 },
+ { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818 },
+ { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195 },
+ { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982 },
+ { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245 },
+ { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069 },
+ { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263 },
+ { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429 },
+ { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363 },
+ { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786 },
+ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133 },
]
[[package]]
name = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
- { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
- { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
- { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
- { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
- { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
- { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
- { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
- { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
- { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
- { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
- { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
- { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
- { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
- { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
- { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
- { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
- { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
- { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
- { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
- { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
- { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
- { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
- { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
- { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
- { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
- { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
- { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
- { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
- { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
- { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
- { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
- { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
- { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
- { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
- { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
- { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
- { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
- { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
- { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
- { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
- { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
- { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
- { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
- { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663 },
+ { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469 },
+ { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039 },
+ { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007 },
+ { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875 },
+ { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271 },
+ { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770 },
+ { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626 },
+ { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842 },
+ { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894 },
+ { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053 },
+ { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481 },
+ { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720 },
+ { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014 },
+ { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820 },
+ { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712 },
+ { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296 },
+ { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553 },
+ { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915 },
+ { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038 },
+ { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245 },
+ { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335 },
+ { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962 },
+ { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396 },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530 },
+ { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227 },
+ { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748 },
+ { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725 },
+ { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901 },
+ { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375 },
+ { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639 },
+ { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897 },
+ { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697 },
+ { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567 },
+ { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556 },
+ { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014 },
+ { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339 },
+ { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490 },
+ { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398 },
+ { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515 },
+ { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806 },
+ { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340 },
+ { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106 },
+ { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504 },
+ { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561 },
+ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477 },
]
[[package]]
@@ -5683,26 +5654,26 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
- { url = "https://files.pythonhosted.org/packages/5a/e3/750b3e3548635ceac03ba255daa26dbc7ed66ca3484dc4b4d955ab7f4501/torch-2.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:107df6888624bdea41508f9aeb6149d9333c737a5530ceecb56c904e811369ae", size = 426379894, upload-time = "2026-06-17T21:06:55.077Z" },
- { url = "https://files.pythonhosted.org/packages/dc/ca/ed24783da629ff3e640ba3f70a7639e9045d3d88b93ee6bc47b8a28a1f2c/torch-2.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e29e7e74d05bda7d955c75e99459f878ebd970ef851b4057edbd3b34a5eb4a3", size = 532169264, upload-time = "2026-06-17T21:08:17.65Z" },
- { url = "https://files.pythonhosted.org/packages/46/61/c63f0158446f3a98ea672b004d761b848911eba567ea4a624c7db5aadc04/torch-2.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:a513506cfda3c1c78dabeb6574c1597538c0254b3d39af174dde35d8177f4ce3", size = 122953086, upload-time = "2026-06-17T21:08:27.69Z" },
- { url = "https://files.pythonhosted.org/packages/f0/54/efb7ebca77970012b0cc21687a55d70eb2ba514b2c2b8e18d9fb1222f3be/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", size = 87991951, upload-time = "2026-06-17T21:07:49.309Z" },
- { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721, upload-time = "2026-06-17T21:06:41.842Z" },
- { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322, upload-time = "2026-06-17T21:06:06.673Z" },
- { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095, upload-time = "2026-06-17T21:07:44.9Z" },
- { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358, upload-time = "2026-06-17T21:07:40.299Z" },
- { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134, upload-time = "2026-06-17T21:07:31.481Z" },
- { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019, upload-time = "2026-06-17T21:05:37.925Z" },
- { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777, upload-time = "2026-06-17T21:07:09.49Z" },
- { url = "https://files.pythonhosted.org/packages/63/b7/1b49fe7086ea36839cc80abc43174c43d0ab6f676c0891c871c162f44fe3/torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece", size = 88010025, upload-time = "2026-06-17T21:07:03.934Z" },
- { url = "https://files.pythonhosted.org/packages/d7/06/5b44063a6545036dcc680d2d303b137d9176cfb2cc1e1863e3ef94abeb52/torch-2.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7973ccd3d2cd35c74449213f7bded199bec6c6247e705cbeda7407af79703d91", size = 426392891, upload-time = "2026-06-17T21:05:52.261Z" },
- { url = "https://files.pythonhosted.org/packages/f8/dd/c9ce9a4b0eb3c5bb92d9ea56766e2c22559f0b45171149188494edcce80f/torch-2.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c64ac4aac16be5e296dcd912305605804b203333c690bf98c55bc09494ee92ad", size = 532272494, upload-time = "2026-06-17T21:06:22.72Z" },
- { url = "https://files.pythonhosted.org/packages/21/7c/f3a601fc1b1f663ff269bfe553654e638651939aa6563e8daa7167c33098/torch-2.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f6dc4caf7eb4adb38a2d9f536b51db56310fdd1254e69a2d96767e1367c892b3", size = 122987254, upload-time = "2026-06-17T21:06:33.199Z" },
- { url = "https://files.pythonhosted.org/packages/e6/8c/b8087556cf81ddd808dbeb34afb8396d7ae7a1694ab489f08b1a0004e7d0/torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d", size = 88303173, upload-time = "2026-06-17T21:05:06.603Z" },
- { url = "https://files.pythonhosted.org/packages/4a/07/fe09d1699fbed2afa10ebc692ff2b99d113f2605b6748cea633989e2789a/torch-2.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97eba061fcb042fed191400b15568990073d67eaacaa6ee9b7ca01dd8b790fe9", size = 426404009, upload-time = "2026-06-17T21:04:57.557Z" },
- { url = "https://files.pythonhosted.org/packages/2e/f7/0ce4f6c1962c60ded7270e0a9eb560fb615c92b89d332cf9e3dff36d5ecc/torch-2.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3867b861391701012adb2df93360efb88494dca245a185e3bb7624495cfe3f33", size = 532184292, upload-time = "2026-06-17T21:05:17.526Z" },
- { url = "https://files.pythonhosted.org/packages/70/db/e384c12aba30320ca92aaaf557456cbcb26f04b4df307728bb8f019f5000/torch-2.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dd15595f8fc764cffde8c6361a3beb6ef69a028c851b1b3e70e077f615980d4e", size = 123231142, upload-time = "2026-06-17T21:05:27.061Z" },
+ { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425 },
+ { url = "https://files.pythonhosted.org/packages/5a/e3/750b3e3548635ceac03ba255daa26dbc7ed66ca3484dc4b4d955ab7f4501/torch-2.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:107df6888624bdea41508f9aeb6149d9333c737a5530ceecb56c904e811369ae", size = 426379894 },
+ { url = "https://files.pythonhosted.org/packages/dc/ca/ed24783da629ff3e640ba3f70a7639e9045d3d88b93ee6bc47b8a28a1f2c/torch-2.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e29e7e74d05bda7d955c75e99459f878ebd970ef851b4057edbd3b34a5eb4a3", size = 532169264 },
+ { url = "https://files.pythonhosted.org/packages/46/61/c63f0158446f3a98ea672b004d761b848911eba567ea4a624c7db5aadc04/torch-2.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:a513506cfda3c1c78dabeb6574c1597538c0254b3d39af174dde35d8177f4ce3", size = 122953086 },
+ { url = "https://files.pythonhosted.org/packages/f0/54/efb7ebca77970012b0cc21687a55d70eb2ba514b2c2b8e18d9fb1222f3be/torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e", size = 87991951 },
+ { url = "https://files.pythonhosted.org/packages/1e/00/4210d76ca7424981f04033ebe7e48816ab83287a62538747a58825db770c/torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c", size = 426382721 },
+ { url = "https://files.pythonhosted.org/packages/76/1f/bc9f5a5aa569307076365f25afcebacb22e9c754b1bcfbaaa146627c7fda/torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921", size = 532261322 },
+ { url = "https://files.pythonhosted.org/packages/9e/49/c549461daa008159d006a76a991fbc2f26fa8bac27a4030c858463dcb20f/torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3", size = 122988095 },
+ { url = "https://files.pythonhosted.org/packages/ff/4a/0300261818e1560d72cc160ac826005507e8b7ca0a35788b591436d05b4a/torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650", size = 87992358 },
+ { url = "https://files.pythonhosted.org/packages/30/a7/874a5ca05e8f159211dca7921060f7057acc1adb26431e119fd150623efc/torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c", size = 426386134 },
+ { url = "https://files.pythonhosted.org/packages/e1/75/20bb8fe9c1ad6538cce8cd0391b51927ae5af0b17ed1eab44b8824465dc1/torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d", size = 532268019 },
+ { url = "https://files.pythonhosted.org/packages/d1/fa/824ddb662af55b2eabc0dbb7b57c7c0b1bcd93693754a2b8509ec4d16490/torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386", size = 122987777 },
+ { url = "https://files.pythonhosted.org/packages/63/b7/1b49fe7086ea36839cc80abc43174c43d0ab6f676c0891c871c162f44fe3/torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece", size = 88010025 },
+ { url = "https://files.pythonhosted.org/packages/d7/06/5b44063a6545036dcc680d2d303b137d9176cfb2cc1e1863e3ef94abeb52/torch-2.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7973ccd3d2cd35c74449213f7bded199bec6c6247e705cbeda7407af79703d91", size = 426392891 },
+ { url = "https://files.pythonhosted.org/packages/f8/dd/c9ce9a4b0eb3c5bb92d9ea56766e2c22559f0b45171149188494edcce80f/torch-2.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c64ac4aac16be5e296dcd912305605804b203333c690bf98c55bc09494ee92ad", size = 532272494 },
+ { url = "https://files.pythonhosted.org/packages/21/7c/f3a601fc1b1f663ff269bfe553654e638651939aa6563e8daa7167c33098/torch-2.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f6dc4caf7eb4adb38a2d9f536b51db56310fdd1254e69a2d96767e1367c892b3", size = 122987254 },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/b8087556cf81ddd808dbeb34afb8396d7ae7a1694ab489f08b1a0004e7d0/torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d", size = 88303173 },
+ { url = "https://files.pythonhosted.org/packages/4a/07/fe09d1699fbed2afa10ebc692ff2b99d113f2605b6748cea633989e2789a/torch-2.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97eba061fcb042fed191400b15568990073d67eaacaa6ee9b7ca01dd8b790fe9", size = 426404009 },
+ { url = "https://files.pythonhosted.org/packages/2e/f7/0ce4f6c1962c60ded7270e0a9eb560fb615c92b89d332cf9e3dff36d5ecc/torch-2.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3867b861391701012adb2df93360efb88494dca245a185e3bb7624495cfe3f33", size = 532184292 },
+ { url = "https://files.pythonhosted.org/packages/70/db/e384c12aba30320ca92aaaf557456cbcb26f04b4df307728bb8f019f5000/torch-2.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dd15595f8fc764cffde8c6361a3beb6ef69a028c851b1b3e70e077f615980d4e", size = 123231142 },
]
[[package]]
@@ -5712,9 +5683,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/27/89/4b0001b2dab8df0a5ee2787dcbe771de75ded01f18f1f8d53dedeea2882b/tqdm-4.67.2.tar.gz", hash = "sha256:649aac53964b2cb8dec76a14b405a4c0d13612cb8933aae547dd144eacc99653", size = 169514, upload-time = "2026-01-30T23:12:06.555Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/27/89/4b0001b2dab8df0a5ee2787dcbe771de75ded01f18f1f8d53dedeea2882b/tqdm-4.67.2.tar.gz", hash = "sha256:649aac53964b2cb8dec76a14b405a4c0d13612cb8933aae547dd144eacc99653", size = 169514 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f5/e2/31eac96de2915cf20ccaed0225035db149dfb9165a9ed28d4b252ef3f7f7/tqdm-4.67.2-py3-none-any.whl", hash = "sha256:9a12abcbbff58b6036b2167d9d3853042b9d436fe7330f06ae047867f2f8e0a7", size = 78354, upload-time = "2026-01-30T23:12:04.368Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e2/31eac96de2915cf20ccaed0225035db149dfb9165a9ed28d4b252ef3f7f7/tqdm-4.67.2-py3-none-any.whl", hash = "sha256:9a12abcbbff58b6036b2167d9d3853042b9d436fe7330f06ae047867f2f8e0a7", size = 78354 },
]
[[package]]
@@ -5732,9 +5703,9 @@ dependencies = [
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "typer", marker = "python_full_version >= '3.14'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827 },
]
[[package]]
@@ -5742,16 +5713,16 @@ name = "triton"
version = "3.7.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887, upload-time = "2026-06-17T20:03:42.913Z" },
- { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" },
- { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" },
- { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" },
- { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" },
- { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" },
- { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" },
- { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" },
- { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" },
- { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887 },
+ { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306 },
+ { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359 },
+ { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725 },
+ { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629 },
+ { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241 },
+ { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764 },
+ { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537 },
+ { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760 },
+ { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404 },
]
[[package]]
@@ -5764,9 +5735,9 @@ dependencies = [
{ name = "shellingham" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381 },
]
[[package]]
@@ -5777,36 +5748,36 @@ dependencies = [
{ name = "click" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444 },
]
[[package]]
name = "types-aiofiles"
version = "25.1.0.20251011"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/84/6c/6d23908a8217e36704aa9c79d99a620f2fdd388b66a4b7f72fbc6b6ff6c6/types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff", size = 14535, upload-time = "2025-10-11T02:44:51.237Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/84/6c/6d23908a8217e36704aa9c79d99a620f2fdd388b66a4b7f72fbc6b6ff6c6/types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff", size = 14535 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338, upload-time = "2025-10-11T02:44:50.054Z" },
+ { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338 },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20250915"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]
[[package]]
@@ -5816,18 +5787,18 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 },
]
[[package]]
@@ -5837,91 +5808,91 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026 },
]
[[package]]
name = "uc-micro-py"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
+ { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229 },
]
[[package]]
name = "unpaddedbase64"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4d/f8/114266b21a7a9e3d09b352bb63c9d61d918bb7aa35d08c722793bfbfd28f/unpaddedbase64-2.1.0.tar.gz", hash = "sha256:7273c60c089de39d90f5d6d4a7883a79e319dc9d9b1c8924a7fab96178a5f005", size = 5621, upload-time = "2021-03-09T11:35:47.729Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f8/114266b21a7a9e3d09b352bb63c9d61d918bb7aa35d08c722793bfbfd28f/unpaddedbase64-2.1.0.tar.gz", hash = "sha256:7273c60c089de39d90f5d6d4a7883a79e319dc9d9b1c8924a7fab96178a5f005", size = 5621 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4c/a7/563b2d8fb7edc07320bf69ac6a7eedcd7a1a9d663a6bb90a4d9bd2eda5f7/unpaddedbase64-2.1.0-py3-none-any.whl", hash = "sha256:485eff129c30175d2cd6f0cd8d2310dff51e666f7f36175f738d75dfdbd0b1c6", size = 6083, upload-time = "2021-03-09T11:35:46.7Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/a7/563b2d8fb7edc07320bf69ac6a7eedcd7a1a9d663a6bb90a4d9bd2eda5f7/unpaddedbase64-2.1.0-py3-none-any.whl", hash = "sha256:485eff129c30175d2cd6f0cd8d2310dff51e666f7f36175f738d75dfdbd0b1c6", size = 6083 },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 },
]
[[package]]
name = "uuid-utils"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" },
- { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943, upload-time = "2026-01-20T20:37:18.767Z" },
- { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467, upload-time = "2026-01-20T20:37:11.794Z" },
- { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333, upload-time = "2026-01-20T20:37:12.818Z" },
- { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859, upload-time = "2026-01-20T20:37:01.512Z" },
- { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988, upload-time = "2026-01-20T20:37:22.881Z" },
- { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784, upload-time = "2026-01-20T20:37:10.808Z" },
- { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750, upload-time = "2026-01-20T20:37:06.152Z" },
- { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818, upload-time = "2026-01-20T20:37:21.816Z" },
- { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831, upload-time = "2026-01-20T20:37:19.691Z" },
- { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333, upload-time = "2026-01-20T20:37:03.117Z" },
- { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319, upload-time = "2026-01-20T20:37:04.208Z" },
- { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566, upload-time = "2026-01-20T20:37:16.868Z" },
- { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809, upload-time = "2026-01-20T20:37:05.139Z" },
- { url = "https://files.pythonhosted.org/packages/f1/03/1f1146e32e94d1f260dfabc81e1649102083303fb4ad549775c943425d9a/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:762e8d67992ac4d2454e24a141a1c82142b5bde10409818c62adbe9924ebc86d", size = 587430, upload-time = "2026-01-20T20:37:24.998Z" },
- { url = "https://files.pythonhosted.org/packages/87/ba/d5a7469362594d885fd9219fe9e851efbe65101d3ef1ef25ea321d7ce841/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:40be5bf0b13aa849d9062abc86c198be6a25ff35316ce0b89fc25f3bac6d525e", size = 298106, upload-time = "2026-01-20T20:37:23.896Z" },
- { url = "https://files.pythonhosted.org/packages/8a/11/3dafb2a5502586f59fd49e93f5802cd5face82921b3a0f3abb5f357cb879/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:191a90a6f3940d1b7322b6e6cceff4dd533c943659e0a15f788674407856a515", size = 333423, upload-time = "2026-01-20T20:37:17.828Z" },
- { url = "https://files.pythonhosted.org/packages/7c/f2/c8987663f0cdcf4d717a36d85b5db2a5589df0a4e129aa10f16f4380ef48/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa4525f4ad82f9d9c842f9a3703f1539c1808affbaec07bb1b842f6b8b96aa5", size = 338659, upload-time = "2026-01-20T20:37:14.286Z" },
- { url = "https://files.pythonhosted.org/packages/d1/c8/929d81665d83f0b2ffaecb8e66c3091a50f62c7cb5b65e678bd75a96684e/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdbd82ff20147461caefc375551595ecf77ebb384e46267f128aca45a0f2cdfc", size = 467029, upload-time = "2026-01-20T20:37:08.277Z" },
- { url = "https://files.pythonhosted.org/packages/8e/a0/27d7daa1bfed7163f4ccaf52d7d2f4ad7bb1002a85b45077938b91ee584f/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff57e8a5d540006ce73cf0841a643d445afe78ba12e75ac53a95ca2924a56be", size = 333298, upload-time = "2026-01-20T20:37:07.271Z" },
- { url = "https://files.pythonhosted.org/packages/63/d4/acad86ce012b42ce18a12f31ee2aa3cbeeb98664f865f05f68c882945913/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fd9112ca96978361201e669729784f26c71fecc9c13a7f8a07162c31bd4d1e2", size = 359217, upload-time = "2026-01-20T20:36:59.687Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786 },
+ { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943 },
+ { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467 },
+ { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333 },
+ { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859 },
+ { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988 },
+ { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784 },
+ { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750 },
+ { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818 },
+ { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831 },
+ { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333 },
+ { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319 },
+ { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566 },
+ { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809 },
+ { url = "https://files.pythonhosted.org/packages/f1/03/1f1146e32e94d1f260dfabc81e1649102083303fb4ad549775c943425d9a/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:762e8d67992ac4d2454e24a141a1c82142b5bde10409818c62adbe9924ebc86d", size = 587430 },
+ { url = "https://files.pythonhosted.org/packages/87/ba/d5a7469362594d885fd9219fe9e851efbe65101d3ef1ef25ea321d7ce841/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:40be5bf0b13aa849d9062abc86c198be6a25ff35316ce0b89fc25f3bac6d525e", size = 298106 },
+ { url = "https://files.pythonhosted.org/packages/8a/11/3dafb2a5502586f59fd49e93f5802cd5face82921b3a0f3abb5f357cb879/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:191a90a6f3940d1b7322b6e6cceff4dd533c943659e0a15f788674407856a515", size = 333423 },
+ { url = "https://files.pythonhosted.org/packages/7c/f2/c8987663f0cdcf4d717a36d85b5db2a5589df0a4e129aa10f16f4380ef48/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa4525f4ad82f9d9c842f9a3703f1539c1808affbaec07bb1b842f6b8b96aa5", size = 338659 },
+ { url = "https://files.pythonhosted.org/packages/d1/c8/929d81665d83f0b2ffaecb8e66c3091a50f62c7cb5b65e678bd75a96684e/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdbd82ff20147461caefc375551595ecf77ebb384e46267f128aca45a0f2cdfc", size = 467029 },
+ { url = "https://files.pythonhosted.org/packages/8e/a0/27d7daa1bfed7163f4ccaf52d7d2f4ad7bb1002a85b45077938b91ee584f/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff57e8a5d540006ce73cf0841a643d445afe78ba12e75ac53a95ca2924a56be", size = 333298 },
+ { url = "https://files.pythonhosted.org/packages/63/d4/acad86ce012b42ce18a12f31ee2aa3cbeeb98664f865f05f68c882945913/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fd9112ca96978361201e669729784f26c71fecc9c13a7f8a07162c31bd4d1e2", size = 359217 },
]
[[package]]
name = "uv"
version = "0.11.19"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/67/f0/6254502aebfdc0a9df6069269a126dd58252ac29d2d6cdf4777cea3e90b5/uv-0.11.19.tar.gz", hash = "sha256:f56f5bf853626a30423052d7ee00bf5cc940a08347d6ee7ede96862d084054a5", size = 4213580, upload-time = "2026-06-03T22:37:15.976Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/67/f0/6254502aebfdc0a9df6069269a126dd58252ac29d2d6cdf4777cea3e90b5/uv-0.11.19.tar.gz", hash = "sha256:f56f5bf853626a30423052d7ee00bf5cc940a08347d6ee7ede96862d084054a5", size = 4213580 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1a/73/be32c2f6ba30fa9d8b3baceb478107cc23722d4aaab87145a332e4985185/uv-0.11.19-py3-none-linux_armv6l.whl", hash = "sha256:c729f56ffef9b945053412c839695e8a0b13758aa15b7763e95a7dd539a6f522", size = 23620003, upload-time = "2026-06-03T22:37:53.017Z" },
- { url = "https://files.pythonhosted.org/packages/fd/ed/3aefe4a4ca4ac9204c6745670dbe12f4add69194d40f5abd1c7bd45ba9af/uv-0.11.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a98495b9dd67287d8c1a0786f98cb037a50f0ee6c3d648572edaa7137aabc277", size = 23183211, upload-time = "2026-06-03T22:37:20.699Z" },
- { url = "https://files.pythonhosted.org/packages/5b/eb/5d1469f9e709d56066f292978711fbf1f805b7fb46f901d3c1f260fd9908/uv-0.11.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fdd881cd6d80782afcf8c1d446dd15a42985167fd812b763d38ba1e4a8d944d", size = 21754003, upload-time = "2026-06-03T22:37:05.027Z" },
- { url = "https://files.pythonhosted.org/packages/7b/93/109b5ee6678f54492f94fdef74149643eaa1f2f4716906a2a10816b31247/uv-0.11.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7222f45b5541551057bfc2e3021f113800704f665c119fdf3ea700c6c4859b21", size = 23518832, upload-time = "2026-06-03T22:37:28.794Z" },
- { url = "https://files.pythonhosted.org/packages/08/0c/8c59bbcf78e94ca9994256920efa99d1c4dc9d0b966eb62ebba075585a16/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:2e0e0b8ad59ec56f1440d6e4313b64a1d8119275dcec73d19eef33c43f99428c", size = 23163128, upload-time = "2026-06-03T22:37:23.226Z" },
- { url = "https://files.pythonhosted.org/packages/89/d6/69caf9e6f11c84b5fb92df190b46fbecb7dc6645ae891c6ed66d7aaaa310/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4aa17ffd719daf37b7a6265efd3ee4922a8ddaabaf0406d2b28c7e5ce2f20ff", size = 23164395, upload-time = "2026-06-03T22:37:18.11Z" },
- { url = "https://files.pythonhosted.org/packages/d6/83/0c2242b77c51ac33a0ddd8b06790429a0b8b9623974c9594ab2b0070ec47/uv-0.11.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32d7988c0dfb6f90941f201c871a4478e96e4f2a32bdb2256d62a78ee20593fc", size = 24541708, upload-time = "2026-06-03T22:37:08.093Z" },
- { url = "https://files.pythonhosted.org/packages/54/10/b1404fc52c0eddc3655f57a8b76e79dcf8dd02568382272f17e2fa68c4bb/uv-0.11.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d663bacb97e2e8412d1c26eace28c7ebbde9d6f5d7d78760fafd114d693817f", size = 25575501, upload-time = "2026-06-03T22:37:47.526Z" },
- { url = "https://files.pythonhosted.org/packages/7c/17/4cda5994195ba9ce1f6971d40d5f2ceec58e2a79030d9052b3bf322557b1/uv-0.11.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:574f5dd4f31666661ea6386d3b91c5f0e8b84a8cae98ebba447c4674f2e6a4c7", size = 24827200, upload-time = "2026-06-03T22:37:34.039Z" },
- { url = "https://files.pythonhosted.org/packages/5a/74/2bd8b51e1d76210fd424ae55ec3f34ded5a10eeff3dd38aeb03c816a0af2/uv-0.11.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:731d9fab8db5d41590af64236d03f8069c8da665fd0f9493b85985f19c86cd90", size = 24872664, upload-time = "2026-06-03T22:37:11.301Z" },
- { url = "https://files.pythonhosted.org/packages/06/b1/44b0764f656bbdd0728118610a63f2feddd9cbe450f974d80c5bb56aad34/uv-0.11.19-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:301fd78309fc545c2cec2bfcc61a6bbdde876856c6d2041502737cf44085c178", size = 23617890, upload-time = "2026-06-03T22:37:44.796Z" },
- { url = "https://files.pythonhosted.org/packages/d2/25/312fa33cd4c34e7618f86cad0c9fdb312d8fef2e7fc61944c1a2f1bf1256/uv-0.11.19-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:62b0b35a51d3034ff30ecd0f381e9bbc20d5b335754f54b098da29424d551ceb", size = 24267220, upload-time = "2026-06-03T22:37:39.425Z" },
- { url = "https://files.pythonhosted.org/packages/8d/25/13856aeff9e14c98ee3e1ceae4d209301cbdeabde93abcd758433601dc82/uv-0.11.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65e932720daed1af1f720a0ff5f9b33ee5f7ad97488dcceceb85154fc1323b82", size = 24376177, upload-time = "2026-06-03T22:37:50.276Z" },
- { url = "https://files.pythonhosted.org/packages/45/7d/590b3ab420e03504cf658d2981e1fcb4af60f3858d42da1d4d8740141dd9/uv-0.11.19-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8f90b6687a480d154595aa619fb836a9a20d00ce37293db8099aad924f2b18f9", size = 23808336, upload-time = "2026-06-03T22:37:26.086Z" },
- { url = "https://files.pythonhosted.org/packages/9e/8e/40acebd4ea419c870930580623e8367e23d810a0ecb8cc2f44d852a27293/uv-0.11.19-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:28b0d612a766eb25756dbaa315433b726e93affa467d29a2682cc317547952ba", size = 25080747, upload-time = "2026-06-03T22:37:13.886Z" },
- { url = "https://files.pythonhosted.org/packages/9c/d3/4037b2acb2bb73b1a3ee47a1d23864ecc503f5840387afd29f621d4fd2ec/uv-0.11.19-py3-none-win32.whl", hash = "sha256:aa6a7e8d07b33ad22f4732848ebb1d9486503973c248d6e632c06ce4339fe347", size = 22459533, upload-time = "2026-06-03T22:37:36.741Z" },
- { url = "https://files.pythonhosted.org/packages/d4/43/f374fad7ad94e4a8c47cf09f00d803c76c6cc7f225668c41f4e2fb5de000/uv-0.11.19-py3-none-win_amd64.whl", hash = "sha256:480fc34a8d0967af6a90b3f99a6e5687cd5c6e29528de96bec04d6e305a59363", size = 25143888, upload-time = "2026-06-03T22:37:42.169Z" },
- { url = "https://files.pythonhosted.org/packages/18/98/d2db53ae036528b0a9407529ef175ee200b01f626c9c160978784c8af870/uv-0.11.19-py3-none-win_arm64.whl", hash = "sha256:50e4d4796ca1a6da359a4f723a0fea86640c381d3ff4fa759a41badd7cb52dee", size = 23601290, upload-time = "2026-06-03T22:37:31.393Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/73/be32c2f6ba30fa9d8b3baceb478107cc23722d4aaab87145a332e4985185/uv-0.11.19-py3-none-linux_armv6l.whl", hash = "sha256:c729f56ffef9b945053412c839695e8a0b13758aa15b7763e95a7dd539a6f522", size = 23620003 },
+ { url = "https://files.pythonhosted.org/packages/fd/ed/3aefe4a4ca4ac9204c6745670dbe12f4add69194d40f5abd1c7bd45ba9af/uv-0.11.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a98495b9dd67287d8c1a0786f98cb037a50f0ee6c3d648572edaa7137aabc277", size = 23183211 },
+ { url = "https://files.pythonhosted.org/packages/5b/eb/5d1469f9e709d56066f292978711fbf1f805b7fb46f901d3c1f260fd9908/uv-0.11.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fdd881cd6d80782afcf8c1d446dd15a42985167fd812b763d38ba1e4a8d944d", size = 21754003 },
+ { url = "https://files.pythonhosted.org/packages/7b/93/109b5ee6678f54492f94fdef74149643eaa1f2f4716906a2a10816b31247/uv-0.11.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7222f45b5541551057bfc2e3021f113800704f665c119fdf3ea700c6c4859b21", size = 23518832 },
+ { url = "https://files.pythonhosted.org/packages/08/0c/8c59bbcf78e94ca9994256920efa99d1c4dc9d0b966eb62ebba075585a16/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:2e0e0b8ad59ec56f1440d6e4313b64a1d8119275dcec73d19eef33c43f99428c", size = 23163128 },
+ { url = "https://files.pythonhosted.org/packages/89/d6/69caf9e6f11c84b5fb92df190b46fbecb7dc6645ae891c6ed66d7aaaa310/uv-0.11.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4aa17ffd719daf37b7a6265efd3ee4922a8ddaabaf0406d2b28c7e5ce2f20ff", size = 23164395 },
+ { url = "https://files.pythonhosted.org/packages/d6/83/0c2242b77c51ac33a0ddd8b06790429a0b8b9623974c9594ab2b0070ec47/uv-0.11.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32d7988c0dfb6f90941f201c871a4478e96e4f2a32bdb2256d62a78ee20593fc", size = 24541708 },
+ { url = "https://files.pythonhosted.org/packages/54/10/b1404fc52c0eddc3655f57a8b76e79dcf8dd02568382272f17e2fa68c4bb/uv-0.11.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d663bacb97e2e8412d1c26eace28c7ebbde9d6f5d7d78760fafd114d693817f", size = 25575501 },
+ { url = "https://files.pythonhosted.org/packages/7c/17/4cda5994195ba9ce1f6971d40d5f2ceec58e2a79030d9052b3bf322557b1/uv-0.11.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:574f5dd4f31666661ea6386d3b91c5f0e8b84a8cae98ebba447c4674f2e6a4c7", size = 24827200 },
+ { url = "https://files.pythonhosted.org/packages/5a/74/2bd8b51e1d76210fd424ae55ec3f34ded5a10eeff3dd38aeb03c816a0af2/uv-0.11.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:731d9fab8db5d41590af64236d03f8069c8da665fd0f9493b85985f19c86cd90", size = 24872664 },
+ { url = "https://files.pythonhosted.org/packages/06/b1/44b0764f656bbdd0728118610a63f2feddd9cbe450f974d80c5bb56aad34/uv-0.11.19-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:301fd78309fc545c2cec2bfcc61a6bbdde876856c6d2041502737cf44085c178", size = 23617890 },
+ { url = "https://files.pythonhosted.org/packages/d2/25/312fa33cd4c34e7618f86cad0c9fdb312d8fef2e7fc61944c1a2f1bf1256/uv-0.11.19-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:62b0b35a51d3034ff30ecd0f381e9bbc20d5b335754f54b098da29424d551ceb", size = 24267220 },
+ { url = "https://files.pythonhosted.org/packages/8d/25/13856aeff9e14c98ee3e1ceae4d209301cbdeabde93abcd758433601dc82/uv-0.11.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65e932720daed1af1f720a0ff5f9b33ee5f7ad97488dcceceb85154fc1323b82", size = 24376177 },
+ { url = "https://files.pythonhosted.org/packages/45/7d/590b3ab420e03504cf658d2981e1fcb4af60f3858d42da1d4d8740141dd9/uv-0.11.19-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8f90b6687a480d154595aa619fb836a9a20d00ce37293db8099aad924f2b18f9", size = 23808336 },
+ { url = "https://files.pythonhosted.org/packages/9e/8e/40acebd4ea419c870930580623e8367e23d810a0ecb8cc2f44d852a27293/uv-0.11.19-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:28b0d612a766eb25756dbaa315433b726e93affa467d29a2682cc317547952ba", size = 25080747 },
+ { url = "https://files.pythonhosted.org/packages/9c/d3/4037b2acb2bb73b1a3ee47a1d23864ecc503f5840387afd29f621d4fd2ec/uv-0.11.19-py3-none-win32.whl", hash = "sha256:aa6a7e8d07b33ad22f4732848ebb1d9486503973c248d6e632c06ce4339fe347", size = 22459533 },
+ { url = "https://files.pythonhosted.org/packages/d4/43/f374fad7ad94e4a8c47cf09f00d803c76c6cc7f225668c41f4e2fb5de000/uv-0.11.19-py3-none-win_amd64.whl", hash = "sha256:480fc34a8d0967af6a90b3f99a6e5687cd5c6e29528de96bec04d6e305a59363", size = 25143888 },
+ { url = "https://files.pythonhosted.org/packages/18/98/d2db53ae036528b0a9407529ef175ee200b01f626c9c160978784c8af870/uv-0.11.19-py3-none-win_arm64.whl", hash = "sha256:50e4d4796ca1a6da359a4f723a0fea86640c381d3ff4fa759a41badd7cb52dee", size = 23601290 },
]
[[package]]
@@ -5932,9 +5903,9 @@ dependencies = [
{ name = "click" },
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502 },
]
[package.optional-dependencies]
@@ -5952,38 +5923,38 @@ standard = [
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" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
- { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
- { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
- { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
- { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
- { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
- { 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" },
+ { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420 },
+ { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677 },
+ { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819 },
+ { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529 },
+ { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267 },
+ { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
]
[[package]]
@@ -5995,28 +5966,28 @@ dependencies = [
{ name = "protobuf", marker = "sys_platform != 'win32'" },
{ name = "sniffio", marker = "sys_platform != 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/60/60/961ce40492a56ef831a905dfe03df4a81c0705152f6a8e49c541c634f49e/valkey_glide-2.4.1-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:d7285d03c2df040f26874b7f4ae96f040da2daecc9a34fa99da6f4e6ce5149c8", size = 7482152, upload-time = "2026-05-28T21:41:02.205Z" },
- { url = "https://files.pythonhosted.org/packages/a4/b2/5a05567f0fc385dcbbbf6ab1061f0bc00443d51c2996e95eed45feaedda9/valkey_glide-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d2e82b74127897ccb7a957ad455787816a75fdc8c60a5e8004aef65ea93e99c", size = 6928601, upload-time = "2026-05-28T21:41:04.543Z" },
- { url = "https://files.pythonhosted.org/packages/c5/d9/7ea2b47cff0a2f99921eb0db404215f828ced7814bd09ede9c93b65d20bc/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4094128cb07e06e87013b7afab1e9388f8f5aeebe48ea6cbd54de15bd772e644", size = 7236977, upload-time = "2026-05-28T21:41:06.055Z" },
- { url = "https://files.pythonhosted.org/packages/00/7a/6cda6b42156ed260e765e4ad2d6ab831607775e218a00fbb0d93411c4e8f/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f8dc0f3a36adb1cbe4e167972ca4758acdfed6baf58a4db94bbb713df56c8f5", size = 7691446, upload-time = "2026-05-28T21:41:07.833Z" },
- { url = "https://files.pythonhosted.org/packages/c5/b4/da8c058baaee414a6bb2450742359f3b3b6993b23281bf227c5089f0099c/valkey_glide-2.4.1-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:5f8df64f6a4f0fd7203113103101fdf0aaa7ff0e7557312611de11ab89c6db75", size = 7472646, upload-time = "2026-05-28T21:41:09.451Z" },
- { url = "https://files.pythonhosted.org/packages/f5/94/e1e311cb56597272b9cb69afb3fe8e2e7dd3371f88c92836015deddc6f49/valkey_glide-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b45e35f44c17e88f8cd8082f8d8061a9763238c44ef20b11b615f6d87235864a", size = 6943375, upload-time = "2026-05-28T21:41:11.079Z" },
- { url = "https://files.pythonhosted.org/packages/76/00/0e42e2f6866ebf0de552e076dc585a487b488b5b818c52460d28b50de65b/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf812b498925a30abab6e1a9f82f5eb821e967904fe7724729b2c82c47e29edf", size = 7237469, upload-time = "2026-05-28T21:41:12.733Z" },
- { url = "https://files.pythonhosted.org/packages/f5/4c/c5dd9a1ed995453b0d9ca75a5af87e881c14e6eebdbf5a5fa78c3bae23fc/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:214e2faca98966eea3eaf9e09de616862423815a5059843a9884125e2427a344", size = 7678744, upload-time = "2026-05-28T21:41:14.634Z" },
- { url = "https://files.pythonhosted.org/packages/6a/2f/3df5702fc68684cef3e09f9cb6ed85578ddb08dc43593b1694c977f396fa/valkey_glide-2.4.1-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:c18976553ba663c03f7cc18c7e6075f4cbd2236c18b051e3d55bb213c6c44cb4", size = 7472972, upload-time = "2026-05-28T21:41:16.063Z" },
- { url = "https://files.pythonhosted.org/packages/54/a3/6a74c6f996fa9e411e66b6f0e645fead2e0a341f1371e4cf3212efa54412/valkey_glide-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43006e19cd63d66051263fa34a8ad47ba7d08a199585689b3f12f56ed6c9a005", size = 6943012, upload-time = "2026-05-28T21:41:17.492Z" },
- { url = "https://files.pythonhosted.org/packages/fc/e7/d10ec41dca703f8c5dcbcba2b905e660c1cf56be53c4d5e368d7aa23d220/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b652a2a62aad87738e8f0e0aa5bf660ba91449c9fdb88550ccbc42e5fec08fe7", size = 7237842, upload-time = "2026-05-28T21:41:18.995Z" },
- { url = "https://files.pythonhosted.org/packages/0a/a3/8916a9ed9e871686db444c86e601773245852ba1ad451ce1bb06f7aed91d/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd27d26947fd9f1b6e9eaf0abce4bccfde779c1e618b310c4d725424b609793", size = 7678919, upload-time = "2026-05-28T21:41:20.502Z" },
- { url = "https://files.pythonhosted.org/packages/05/35/6d39ec3cbd24d85ad8e1051e29e6509c0999f760aff5af7851c1a1981471/valkey_glide-2.4.1-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:91fb7ff97acdabc8f641255b548a48627bb731e65037b1126745bf8a0022e87d", size = 7471906, upload-time = "2026-05-28T21:41:22.135Z" },
- { url = "https://files.pythonhosted.org/packages/ab/fc/3c28f794b7d35e13101598669c1d249c0a9f0408c545c87212e364c6ee4e/valkey_glide-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d49a2537c2de44b0fc57691b1ae6c3d6f481e6f7f7eb879c0d28921d0aaec67d", size = 6943495, upload-time = "2026-05-28T21:41:23.783Z" },
- { url = "https://files.pythonhosted.org/packages/2e/15/fb884631f5df78dc538c56bca9391165e40906b9b63ca65633d1be5bf980/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cded9f14e448da5a96f61c066395f2c7e2846f2afe74cacc8634da0ae0c3425f", size = 7257720, upload-time = "2026-05-28T21:41:25.361Z" },
- { url = "https://files.pythonhosted.org/packages/73/79/0b881017194386d21812b929a81dd8afd51d6b8d92280895b45913854785/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f249ab5bd0d69befe35897cf51a8fc9e01e9c8c9fe03087a68e6fe6d3e31d0d", size = 7682318, upload-time = "2026-05-28T21:41:26.996Z" },
- { url = "https://files.pythonhosted.org/packages/7a/4d/f2b4e508692fcd21e76c7cbdc4f988bec7f4675e60f4f35ef482a826f6ae/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:775df9c7421a187c41caf003e4af5f073ed7e4b8abe50f8b9bec712cb03e12bf", size = 7479155, upload-time = "2026-05-28T21:41:42.399Z" },
- { url = "https://files.pythonhosted.org/packages/52/d8/8a3495f5582dccb4c8e7faf6a73baf3dbc4580701923f06d8abf210ff22d/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0d87f21c77004240189cc3c5aab156966487afd81ffdee04225a52c7bd7132e4", size = 6938571, upload-time = "2026-05-28T21:41:44.078Z" },
- { url = "https://files.pythonhosted.org/packages/f3/5a/a70077f76c2f18e94ec4309857b248beb7a8c7a3a50e30242abde2c3827d/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44376ef5fe7a25287095b073d8abde510a50b1ead0143662394b3da9717863ef", size = 7260021, upload-time = "2026-05-28T21:41:45.837Z" },
- { url = "https://files.pythonhosted.org/packages/aa/12/72d31522e06fcc9b391118c1f69a09002224e78114b1db0d01b96008dc59/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a59cc0a21d7a8b1b3caeb299f23817429b5fe6579bd4cb016382e6b7a10de984", size = 7693093, upload-time = "2026-05-28T21:41:47.617Z" },
+ { url = "https://files.pythonhosted.org/packages/60/60/961ce40492a56ef831a905dfe03df4a81c0705152f6a8e49c541c634f49e/valkey_glide-2.4.1-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:d7285d03c2df040f26874b7f4ae96f040da2daecc9a34fa99da6f4e6ce5149c8", size = 7482152 },
+ { url = "https://files.pythonhosted.org/packages/a4/b2/5a05567f0fc385dcbbbf6ab1061f0bc00443d51c2996e95eed45feaedda9/valkey_glide-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d2e82b74127897ccb7a957ad455787816a75fdc8c60a5e8004aef65ea93e99c", size = 6928601 },
+ { url = "https://files.pythonhosted.org/packages/c5/d9/7ea2b47cff0a2f99921eb0db404215f828ced7814bd09ede9c93b65d20bc/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4094128cb07e06e87013b7afab1e9388f8f5aeebe48ea6cbd54de15bd772e644", size = 7236977 },
+ { url = "https://files.pythonhosted.org/packages/00/7a/6cda6b42156ed260e765e4ad2d6ab831607775e218a00fbb0d93411c4e8f/valkey_glide-2.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f8dc0f3a36adb1cbe4e167972ca4758acdfed6baf58a4db94bbb713df56c8f5", size = 7691446 },
+ { url = "https://files.pythonhosted.org/packages/c5/b4/da8c058baaee414a6bb2450742359f3b3b6993b23281bf227c5089f0099c/valkey_glide-2.4.1-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:5f8df64f6a4f0fd7203113103101fdf0aaa7ff0e7557312611de11ab89c6db75", size = 7472646 },
+ { url = "https://files.pythonhosted.org/packages/f5/94/e1e311cb56597272b9cb69afb3fe8e2e7dd3371f88c92836015deddc6f49/valkey_glide-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b45e35f44c17e88f8cd8082f8d8061a9763238c44ef20b11b615f6d87235864a", size = 6943375 },
+ { url = "https://files.pythonhosted.org/packages/76/00/0e42e2f6866ebf0de552e076dc585a487b488b5b818c52460d28b50de65b/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf812b498925a30abab6e1a9f82f5eb821e967904fe7724729b2c82c47e29edf", size = 7237469 },
+ { url = "https://files.pythonhosted.org/packages/f5/4c/c5dd9a1ed995453b0d9ca75a5af87e881c14e6eebdbf5a5fa78c3bae23fc/valkey_glide-2.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:214e2faca98966eea3eaf9e09de616862423815a5059843a9884125e2427a344", size = 7678744 },
+ { url = "https://files.pythonhosted.org/packages/6a/2f/3df5702fc68684cef3e09f9cb6ed85578ddb08dc43593b1694c977f396fa/valkey_glide-2.4.1-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:c18976553ba663c03f7cc18c7e6075f4cbd2236c18b051e3d55bb213c6c44cb4", size = 7472972 },
+ { url = "https://files.pythonhosted.org/packages/54/a3/6a74c6f996fa9e411e66b6f0e645fead2e0a341f1371e4cf3212efa54412/valkey_glide-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:43006e19cd63d66051263fa34a8ad47ba7d08a199585689b3f12f56ed6c9a005", size = 6943012 },
+ { url = "https://files.pythonhosted.org/packages/fc/e7/d10ec41dca703f8c5dcbcba2b905e660c1cf56be53c4d5e368d7aa23d220/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b652a2a62aad87738e8f0e0aa5bf660ba91449c9fdb88550ccbc42e5fec08fe7", size = 7237842 },
+ { url = "https://files.pythonhosted.org/packages/0a/a3/8916a9ed9e871686db444c86e601773245852ba1ad451ce1bb06f7aed91d/valkey_glide-2.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd27d26947fd9f1b6e9eaf0abce4bccfde779c1e618b310c4d725424b609793", size = 7678919 },
+ { url = "https://files.pythonhosted.org/packages/05/35/6d39ec3cbd24d85ad8e1051e29e6509c0999f760aff5af7851c1a1981471/valkey_glide-2.4.1-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:91fb7ff97acdabc8f641255b548a48627bb731e65037b1126745bf8a0022e87d", size = 7471906 },
+ { url = "https://files.pythonhosted.org/packages/ab/fc/3c28f794b7d35e13101598669c1d249c0a9f0408c545c87212e364c6ee4e/valkey_glide-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d49a2537c2de44b0fc57691b1ae6c3d6f481e6f7f7eb879c0d28921d0aaec67d", size = 6943495 },
+ { url = "https://files.pythonhosted.org/packages/2e/15/fb884631f5df78dc538c56bca9391165e40906b9b63ca65633d1be5bf980/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cded9f14e448da5a96f61c066395f2c7e2846f2afe74cacc8634da0ae0c3425f", size = 7257720 },
+ { url = "https://files.pythonhosted.org/packages/73/79/0b881017194386d21812b929a81dd8afd51d6b8d92280895b45913854785/valkey_glide-2.4.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f249ab5bd0d69befe35897cf51a8fc9e01e9c8c9fe03087a68e6fe6d3e31d0d", size = 7682318 },
+ { url = "https://files.pythonhosted.org/packages/7a/4d/f2b4e508692fcd21e76c7cbdc4f988bec7f4675e60f4f35ef482a826f6ae/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:775df9c7421a187c41caf003e4af5f073ed7e4b8abe50f8b9bec712cb03e12bf", size = 7479155 },
+ { url = "https://files.pythonhosted.org/packages/52/d8/8a3495f5582dccb4c8e7faf6a73baf3dbc4580701923f06d8abf210ff22d/valkey_glide-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0d87f21c77004240189cc3c5aab156966487afd81ffdee04225a52c7bd7132e4", size = 6938571 },
+ { url = "https://files.pythonhosted.org/packages/f3/5a/a70077f76c2f18e94ec4309857b248beb7a8c7a3a50e30242abde2c3827d/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44376ef5fe7a25287095b073d8abde510a50b1ead0143662394b3da9717863ef", size = 7260021 },
+ { url = "https://files.pythonhosted.org/packages/aa/12/72d31522e06fcc9b391118c1f69a09002224e78114b1db0d01b96008dc59/valkey_glide-2.4.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a59cc0a21d7a8b1b3caeb299f23817429b5fe6579bd4cb016382e6b7a10de984", size = 7693093 },
]
[[package]]
@@ -6028,36 +5999,36 @@ dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258 },
]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
- { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
- { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
- { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
- { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
- { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
- { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
- { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
- { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
- { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
- { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
- { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
- { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
- { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
- { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
- { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
- { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
- { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
- { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 },
+ { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 },
+ { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 },
+ { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 },
+ { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 },
+ { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 },
+ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 },
+ { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 },
+ { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 },
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 },
]
[[package]]
@@ -6067,84 +6038,84 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
- { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
- { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
- { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
- { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
- { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
- { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
- { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
- { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
- { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
- { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
- { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
- { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
- { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
- { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
- { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
- { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
- { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
- { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
- { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
- { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
- { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
- { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
- { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
- { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
- { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
- { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
- { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
- { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
- { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
- { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
- { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
- { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
- { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
- { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
- { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
- { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
- { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
- { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
- { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
- { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
- { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
- { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
- { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
- { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
- { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
- { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
- { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
- { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
- { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
- { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
- { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
- { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
- { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
- { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
- { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
- { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
- { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
- { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
- { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
- { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
- { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
- { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
- { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
- { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
- { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
- { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
- { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
- { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
- { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
- { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
- { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
- { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
- { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
- { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529 },
+ { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384 },
+ { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789 },
+ { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521 },
+ { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722 },
+ { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088 },
+ { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923 },
+ { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080 },
+ { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432 },
+ { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046 },
+ { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473 },
+ { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598 },
+ { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210 },
+ { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745 },
+ { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769 },
+ { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374 },
+ { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485 },
+ { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813 },
+ { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816 },
+ { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186 },
+ { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812 },
+ { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196 },
+ { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657 },
+ { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042 },
+ { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410 },
+ { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209 },
+ { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 },
+ { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 },
+ { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 },
+ { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 },
+ { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 },
+ { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 },
+ { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 },
+ { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 },
+ { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 },
+ { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 },
+ { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 },
+ { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 },
+ { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 },
+ { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 },
+ { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 },
+ { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 },
+ { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 },
+ { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 },
+ { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 },
+ { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 },
+ { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 },
+ { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 },
+ { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 },
+ { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 },
+ { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 },
+ { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 },
+ { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 },
+ { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 },
+ { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 },
+ { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 },
+ { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 },
+ { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 },
+ { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 },
+ { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 },
+ { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 },
+ { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 },
+ { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 },
+ { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 },
+ { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 },
+ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 },
+ { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 },
+ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 },
+ { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250 },
+ { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117 },
+ { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493 },
+ { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546 },
]
[[package]]
@@ -6154,60 +6125,60 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bracex" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854 },
]
[[package]]
name = "websocket-client"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
+ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
- { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
- { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
- { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
- { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
- { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
- { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
- { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
- { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
- { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
- { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
- { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
- { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
- { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
- { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
- { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
- { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
- { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
- { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
- { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
- { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
- { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
- { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
- { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
- { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
- { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
- { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
- { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
- { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
- { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
- { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
- { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
- { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423 },
+ { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082 },
+ { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330 },
+ { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878 },
+ { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883 },
+ { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252 },
+ { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521 },
+ { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958 },
+ { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918 },
+ { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388 },
+ { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828 },
+ { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 },
+ { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 },
+ { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 },
+ { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 },
+ { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 },
+ { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 },
+ { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 },
+ { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 },
+ { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 },
+ { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 },
+ { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 },
+ { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 },
+ { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 },
+ { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 },
+ { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 },
+ { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 },
+ { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 },
+ { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 },
+ { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
+ { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
+ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
+ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
]
[[package]]
@@ -6217,72 +6188,72 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166 },
]
[[package]]
name = "wrapt"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/86/31/afb4cf08b9892430ec419a3f0f469fb978cb013f4432e0edb9c2cf06f081/wrapt-2.1.0.tar.gz", hash = "sha256:757ff1de7e1d8db1839846672aaecf4978af433cc57e808255b83980e9651914", size = 80924, upload-time = "2026-01-31T23:25:58.917Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/86/31/afb4cf08b9892430ec419a3f0f469fb978cb013f4432e0edb9c2cf06f081/wrapt-2.1.0.tar.gz", hash = "sha256:757ff1de7e1d8db1839846672aaecf4978af433cc57e808255b83980e9651914", size = 80924 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/97/0a/de541b2543e33144043cd58da09bda8d837ba42e13ae90baca32b0553023/wrapt-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d877003dbc601e1365bd03f6a980965a20d585f90c056f33e1fc241b63a6f0e7", size = 60558, upload-time = "2026-01-31T23:25:27.784Z" },
- { url = "https://files.pythonhosted.org/packages/84/2e/7e48207420e6ca7e7a05c0e4ebe9464ec9965c8face256f3ef8cc2acd862/wrapt-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:771ec962fe3ccb078177c9b8f3529e204ffcbb11d62d509e0a438e6a83f7ca68", size = 61501, upload-time = "2026-01-31T23:26:46.477Z" },
- { url = "https://files.pythonhosted.org/packages/67/2b/639a4970ecdc7143acb69a1162c76b0f1620218ad502c33e1a88d28f00b1/wrapt-2.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73e742368b52f9cf0921e1d2bcb8a6a44ede2e372e33df6e77caa136a942099f", size = 113954, upload-time = "2026-01-31T23:26:01.493Z" },
- { url = "https://files.pythonhosted.org/packages/81/5d/8d9177c8c0ecaf5313b462be63c5aa9672044b02bfd644dd65c6cb420d2a/wrapt-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e9129d1b582c55ad0dfb9e29e221daa0e02b18c67d8642bc8d08dd7038b3aed", size = 115994, upload-time = "2026-01-31T23:25:57.118Z" },
- { url = "https://files.pythonhosted.org/packages/e3/e3/c5a514a0ed1dc463f5b6b4e31abbaa3b8df48b9fd391a6e8412608155a29/wrapt-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc9e37bfe67f6ea738851dd606640a87692ff81bcc76df313fb75d08e05e855f", size = 115245, upload-time = "2026-01-31T23:26:11.171Z" },
- { url = "https://files.pythonhosted.org/packages/35/9c/2fc6a31f5758266de2cf9dc6111d3bda7b7dd6cbdcabfd755103bbcda08f/wrapt-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:46583aae3c807aa76f96355c4943031225785ed160c84052612bba0e9d456639", size = 113679, upload-time = "2026-01-31T23:25:19.475Z" },
- { url = "https://files.pythonhosted.org/packages/6c/81/ce52694dc8184f4898c01c8af20e145b348fc7a0e4766a7345c45f0e9ce6/wrapt-2.1.0-cp311-cp311-win32.whl", hash = "sha256:e3958ba70aef2895d8c62c2d31f51ced188f60451212294677b92f4b32c12978", size = 57865, upload-time = "2026-01-31T23:25:50.947Z" },
- { url = "https://files.pythonhosted.org/packages/85/31/0df5d38243c2a538e7bd481e676d286b41f98a729e0d37cfed9f4421ad4d/wrapt-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0ff9797e6e0b82b330ef80b0cdba7fcd0ca056d4c7af2ca44e3d05fd47929ede", size = 60227, upload-time = "2026-01-31T23:25:35.954Z" },
- { url = "https://files.pythonhosted.org/packages/a3/79/b587edbab21d6b8a7460234440c784e08344bcdf4fdfd9a6e9125ea14923/wrapt-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:4b0a29509ef7b501abe47b693a3c91d1f21c9a948711f6ce7afa81eb274c7eae", size = 58648, upload-time = "2026-01-31T23:25:32.887Z" },
- { url = "https://files.pythonhosted.org/packages/f8/6f/c731b1fbbcdf9bd202809c6fa354c4237b663dd82a95035a7cbe899cfd25/wrapt-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a64c0fb29c89810973f312a04c067b63523e7303b9a2653820cbf16474c2e5cf", size = 61149, upload-time = "2026-01-31T23:25:29.092Z" },
- { url = "https://files.pythonhosted.org/packages/b2/da/7022458a1d99f0c59720a0b0fd4b1966f8df6d41e741aadfe43bc5350547/wrapt-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5509d9150ed01c4149e40020fa68e917d5c4bb77d311e79535565c2a0418afcb", size = 61743, upload-time = "2026-01-31T23:26:14.338Z" },
- { url = "https://files.pythonhosted.org/packages/b5/f4/57cc12c3fc6f4fe6ccfc15567cc1ac8aeb53a9946a675adc3df7a1ee4e6a/wrapt-2.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:52bb58b3207ace156b6134235fd43140994597704fd07d148cbcfb474ee084ea", size = 121331, upload-time = "2026-01-31T23:25:37.294Z" },
- { url = "https://files.pythonhosted.org/packages/5e/a4/a96ea114298f81f02c07313da85fd46a2a57bbe12389d0619ac3371f691c/wrapt-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7112cbf72fc4035afe1e3314a311654c41dd92c2932021ef76f5ca87583917b3", size = 122907, upload-time = "2026-01-31T23:26:49.604Z" },
- { url = "https://files.pythonhosted.org/packages/ac/43/df73362b6e47f92aaff0fc3fc459314025c795f75d61724c83232dee199c/wrapt-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e90656b433808a0ab68e95aaf9f588aea5c8c7a514e180849dfc638ba00ec449", size = 121337, upload-time = "2026-01-31T23:26:04.072Z" },
- { url = "https://files.pythonhosted.org/packages/51/4f/8147e3b9a7887cee4eeb3a3414265ad4649a156832a08063f55aa7842af0/wrapt-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e45f54903da38fc4f6f66397fd550fc0dac6164b4c5e721c1b4eb05664181821", size = 120461, upload-time = "2026-01-31T23:26:43.055Z" },
- { url = "https://files.pythonhosted.org/packages/35/b1/eea720fcca8a05dec848a6d11a47c20f59bdabdcc444ba3be0589350eb7a/wrapt-2.1.0-cp312-cp312-win32.whl", hash = "sha256:6653bf30dbbafd55cb4553195cc60b94920b6711a8835866c0e02aa9f22c5598", size = 58089, upload-time = "2026-01-31T23:26:47.773Z" },
- { url = "https://files.pythonhosted.org/packages/af/79/8a8f3f8c71ee3379191b69e47f32115fa25cdb6d5b581d74c64d5c897fa7/wrapt-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d61238a072501ed071a9f4b9567d10c2eb3d2f1a0258ae79b47160871d8f29c3", size = 60330, upload-time = "2026-01-31T23:26:12.518Z" },
- { url = "https://files.pythonhosted.org/packages/08/4e/e992d05c3d2f7163883a65ead2620ff5fe7b3d44d7c2136ce981e40e453d/wrapt-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:9e971000347f61271725e801ef44fa5d01b52720e59737f0d96280bffb98c5d1", size = 58727, upload-time = "2026-01-31T23:26:53.222Z" },
- { url = "https://files.pythonhosted.org/packages/30/93/b414826a5aaf2fdcfe73c2e649cbeb2e098fef4820d1217554ee64f45666/wrapt-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:875a10a6f3b667f90a39010af26acf684ba831d9b18a86b242899d57c74550fa", size = 61155, upload-time = "2026-01-31T23:26:24.462Z" },
- { url = "https://files.pythonhosted.org/packages/58/9e/8b21ea776bf2a3c858e3377ecde4b348893ec44dc1726baaf583ca22c56e/wrapt-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e00f8559ceac0fb45091daad5f15d37f2c22bdc28ed71521d47ff01aad8fff3d", size = 61747, upload-time = "2026-01-31T23:25:53.987Z" },
- { url = "https://files.pythonhosted.org/packages/da/ec/48cd2470ad09557dfe6fccfe9de98698cc0df3786a6d4d97e8edd574d67a/wrapt-2.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ce0cf4c79c19904aaf2e822af280d7b3c23ad902f57e31c5a19433bc86e5d36d", size = 121342, upload-time = "2026-01-31T23:26:32.156Z" },
- { url = "https://files.pythonhosted.org/packages/3b/4e/e8447b31be27b6057cdfc904a38632a765c3407fb4d10d11e5c1d0c203d5/wrapt-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dd4f8c2256fcde1a85037a1837afc52e8d32d086fd669ae469455fd9a988d6", size = 122951, upload-time = "2026-01-31T23:25:08.936Z" },
- { url = "https://files.pythonhosted.org/packages/7e/b6/73a6c9277e844ffe11f3002ad27a84ff5418248def33af9435d24dfe6c5b/wrapt-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:737e1e491473047cb66944b8b8fd23f3f542019afd6cf0569d1356d18a7ea6d5", size = 121373, upload-time = "2026-01-31T23:26:18.322Z" },
- { url = "https://files.pythonhosted.org/packages/85/04/869384435fecf829dc05621ffa02dab0f2f830be5d42fa8d8ac7b0b4c9fa/wrapt-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38de19e30e266c15d542ceb0603e657db4e82c53e7f47fd70674ae5da2b41180", size = 120468, upload-time = "2026-01-31T23:25:13.689Z" },
- { url = "https://files.pythonhosted.org/packages/80/ac/42a5378d9b5b486122ae0572c46ae8d69ab6486b9f13961e6b9706297ff5/wrapt-2.1.0-cp313-cp313-win32.whl", hash = "sha256:bc7d496b6e16bd2f77e37e8969b21a7b58d6954e46c6689986fb67b9078100e5", size = 58095, upload-time = "2026-01-31T23:26:33.481Z" },
- { url = "https://files.pythonhosted.org/packages/86/de/538fcef30f70a1aaadab4cab7d0396037518d7ec2b064557171147ce297f/wrapt-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:57df799e67b011847ef7ac64b05ed4633e56b64e7e7cab5eb83dc9689dbe0acf", size = 60344, upload-time = "2026-01-31T23:25:10.615Z" },
- { url = "https://files.pythonhosted.org/packages/08/13/27884668b21e9f0a625c13ebd6a8d70ad8371250ec8519881858404686bf/wrapt-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:01559d2961c29edc6263849fd9d32b29a20737da67648c7fd752a67bd96208c7", size = 58734, upload-time = "2026-01-31T23:26:00.099Z" },
- { url = "https://files.pythonhosted.org/packages/c9/a3/e558c5b8f3a097aa1e942e2d75923adebfdfafb5a51ec425d1d062e49ab0/wrapt-2.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:66f588c8b3a44863156cfaccb516f946a64b3b03a6880822ab0b878135ca1f5c", size = 62972, upload-time = "2026-01-31T23:26:08.576Z" },
- { url = "https://files.pythonhosted.org/packages/93/b6/7157e98107099fad846f1e79308cc0954e26b25b01c03f1624ba7f57ec54/wrapt-2.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:355779ff720c11a2a5cffd03332dbce1005cb4747dca65b0fc8cdd5f8bf1037e", size = 63610, upload-time = "2026-01-31T23:26:39.9Z" },
- { url = "https://files.pythonhosted.org/packages/e4/8e/b8992671e4b4d3ce2a53af930588c204bf37b66eb212bd1722f2a5a8cf62/wrapt-2.1.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a0471df3fb4e85a9ff62f7142cdb169e31172467cdb79a713f9b1319c555903", size = 152538, upload-time = "2026-01-31T23:26:27.696Z" },
- { url = "https://files.pythonhosted.org/packages/8c/f6/79f9fd4b3c0a8715e651fff1cc1182a983fd971376d5688a06fa94e31acd/wrapt-2.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bacf063143fa86f15b00a21259a81c95c527a18d504b8c820835366d361c879", size = 158702, upload-time = "2026-01-31T23:25:11.848Z" },
- { url = "https://files.pythonhosted.org/packages/9e/46/f88b52beb813eeb830d9134bc6eaf3e53cde4e3cfa1804e383754d4104fe/wrapt-2.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c87cd4f61a3b7cd65113e74006e1cd6352b74807fcc65d440e8342f001f8de5e", size = 155564, upload-time = "2026-01-31T23:25:15.033Z" },
- { url = "https://files.pythonhosted.org/packages/93/31/97145ea71e3e5a1b419af5c410b07b258155dc7cc1a6302791a93e991c83/wrapt-2.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2893498fe898719ac8fb6b4fe36ca86892bec1e2480d94e3bd1bc592c00527ad", size = 150165, upload-time = "2026-01-31T23:26:09.848Z" },
- { url = "https://files.pythonhosted.org/packages/10/bd/f33551d5bfbb0ddab81296cffc15570570039a973c0f99bba474be0fadf2/wrapt-2.1.0-cp313-cp313t-win32.whl", hash = "sha256:cbc07f101f5f1e7c23ec06a07e45715f459de992108eeb381b21b76d94dbaf4f", size = 59785, upload-time = "2026-01-31T23:25:52.23Z" },
- { url = "https://files.pythonhosted.org/packages/5f/3a/9a76be7a36442f43841bb6336e262e09a915b2fb5dfc2822ffce1fb903d2/wrapt-2.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2ccc89cd504fc29c32f0b24046e8edf3ef0fcbc5d5efe8c91b303c099863d2c8", size = 63085, upload-time = "2026-01-31T23:26:05.363Z" },
- { url = "https://files.pythonhosted.org/packages/7a/35/65a13c2df008d189ebca5fec534011c5dd69ab4f47e6923b403321816fbf/wrapt-2.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:0b660be1c9cdfb4c711baab4ccbd0e9d1b65a0480d38729ec8cdbf3b29cb7f15", size = 60254, upload-time = "2026-01-31T23:25:06.052Z" },
- { url = "https://files.pythonhosted.org/packages/6f/eb/7c9eb1ea9b10ea98d9983a147c877a2ae927acb4a86e2dc4a0b548f05ad1/wrapt-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f7bf95bae7ac5f2bbcb307464b3b0ff70569dd3b036a87b1cf7efb2c76e66e5", size = 61316, upload-time = "2026-01-31T23:25:20.739Z" },
- { url = "https://files.pythonhosted.org/packages/6d/c2/1c3d16d6b644f688913a00e2dc10f59adca817b5b3ee034ce4e9a692ab63/wrapt-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:be2f541a242818829526e5d08c716b6730970ed0dc1b76ba962a546947d0f005", size = 61813, upload-time = "2026-01-31T23:25:49.714Z" },
- { url = "https://files.pythonhosted.org/packages/8c/51/b6170084b6b771cc62374d924e328df2e81f687399a835f003497cad1110/wrapt-2.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad3aa174d06a14b4758d5a1678b9adde8b8e657c6695de9a3d4c223f4fcbbcce", size = 120309, upload-time = "2026-01-31T23:25:16.866Z" },
- { url = "https://files.pythonhosted.org/packages/f8/34/467829f0dd79f50878b2e67b67c67c816a6326a27d252d4192ef815b4a09/wrapt-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bffa584240d41bc3127510e07a752f94223d73bb1283ac2e99ac44235762efd2", size = 122690, upload-time = "2026-01-31T23:26:16.914Z" },
- { url = "https://files.pythonhosted.org/packages/df/5b/244c61a65e0bc9d4a18cfa2a2b3b05f8065290284fc60436a7ea5047ee10/wrapt-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9b2da9c8f1723994b335dbf9f496fbfabc76bcdd001f73772b8eb2118a714cea", size = 121115, upload-time = "2026-01-31T23:26:44.518Z" },
- { url = "https://files.pythonhosted.org/packages/86/7d/f9b5e103d3caf23a72c04a1baf2b61c4a14d1feb440d3c98c26725b4503a/wrapt-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eabe95ea5fbe1524a53c0f3fc535c99f2aa376ec1451b0b79d943d2240d80e36", size = 119487, upload-time = "2026-01-31T23:25:34.186Z" },
- { url = "https://files.pythonhosted.org/packages/f8/49/b61fdc4680dd5cd6828977341b9fd729e2c623338bfe65647f5c0ff8195e/wrapt-2.1.0-cp314-cp314-win32.whl", hash = "sha256:2cd647097df1df78f027ac7d5d663f05daa1a117b69cf7f476cb299f90557747", size = 58519, upload-time = "2026-01-31T23:25:04.426Z" },
- { url = "https://files.pythonhosted.org/packages/6a/4f/42ab43e496d0d19caed9f69366d0f28f7f08c139297e78b17dab6ecbb6d5/wrapt-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0fc3e388a14ef8101c685dc80b4d2932924a639a03e5c44b5ffabbda2f1f2dc", size = 60767, upload-time = "2026-01-31T23:25:21.954Z" },
- { url = "https://files.pythonhosted.org/packages/ef/15/0337768ac97a8758bc0fc1afdf5f656075a7facf198f62bbe8a22b789277/wrapt-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7c06653908a23a85c4b2455b9d37c085f9756c09058df87b4a2fce2b2f8d58c2", size = 59056, upload-time = "2026-01-31T23:26:25.814Z" },
- { url = "https://files.pythonhosted.org/packages/d6/f1/58f4674d1db44912003a51b34e8d9823a832fbbb39162e9dbe06e5f6424e/wrapt-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c70b4829c6f2f4af4cdaa16442032fcaf882063304160555e4a19b43fd2c6c9d", size = 63061, upload-time = "2026-01-31T23:26:06.601Z" },
- { url = "https://files.pythonhosted.org/packages/02/c1/07f6bf6619285f39cd616314217170c6160da99a46ad6ae4a60044f6ab5a/wrapt-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7fd4c4ee51ebdf245549d54a7c2181a4f39caac97c9dc8a050b5ba814067a29", size = 63620, upload-time = "2026-01-31T23:25:30.326Z" },
- { url = "https://files.pythonhosted.org/packages/46/82/f7df1648762260f60c4e22c066a17d95f20267c94bfe653fab4f08e2c297/wrapt-2.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b158558438874e5fd5cb505b5a635bd08c84857bc937973d9e12e1166cdf3b", size = 152546, upload-time = "2026-01-31T23:25:02.102Z" },
- { url = "https://files.pythonhosted.org/packages/78/b7/d953336e09bac13a9ffa9073e167c5dec8aaa4a717a8551bf64cb4683590/wrapt-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2e156fe2d41700b837be9b1d8d80ebab44e9891589bc7c41578ef110184e29", size = 158704, upload-time = "2026-01-31T23:25:43.269Z" },
- { url = "https://files.pythonhosted.org/packages/39/a1/2ed57e46b30af2a5a750c85a9dd30d2244ef10e2f8db150560126d8cbd24/wrapt-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f1e9bac6a6c1ba65e0ac50e32c575266734a07b6c17e718c4babd91e2faa69b", size = 155563, upload-time = "2026-01-31T23:25:39.17Z" },
- { url = "https://files.pythonhosted.org/packages/d0/8c/4f54f7ea5addf208be44459393185aaa193bd2d0b8ecf4683b159fcc5238/wrapt-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12687e6271df7ae5706bee44cc1f77fecb7805976ec9f14f58381b30ae2aceb5", size = 150189, upload-time = "2026-01-31T23:25:44.654Z" },
- { url = "https://files.pythonhosted.org/packages/b7/cc/e8290a1cd94297fbc1e9fbad06481b5a7c918f2db6645c550f05ee47f359/wrapt-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:38bbe336ee32f67eb99f886bd4f040d91310b7e660061bb03b9083d26e8cf915", size = 60431, upload-time = "2026-01-31T23:25:48.34Z" },
- { url = "https://files.pythonhosted.org/packages/d0/df/af5d244938853e3adb1251ca1397e9fa78d3e92adc808a0af0a8547585d3/wrapt-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0fa64a9a07df7f85b352adc42b43e7f44085fb11191b8f5b9b77219f7aaf7e17", size = 63859, upload-time = "2026-01-31T23:26:23.2Z" },
- { url = "https://files.pythonhosted.org/packages/39/c4/28b6f2804e8bc05d17114dfed03a80bce5b83ca2113fd44eecbef12275d1/wrapt-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:da379cbdf3b7d97ace33a69a391b7a7e2130b1aca94dc447246217994233974c", size = 60446, upload-time = "2026-01-31T23:25:41.001Z" },
- { url = "https://files.pythonhosted.org/packages/57/e9/70983b75d4abd6f85cffc6df79c623220ec5a579ceaacabac35c904b7b52/wrapt-2.1.0-py3-none-any.whl", hash = "sha256:e035693a0d25ea5bf5826df3e203dff7d091b0d5442aaefec9ca8f2bab38417f", size = 43886, upload-time = "2026-01-31T23:25:07.22Z" },
+ { url = "https://files.pythonhosted.org/packages/97/0a/de541b2543e33144043cd58da09bda8d837ba42e13ae90baca32b0553023/wrapt-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d877003dbc601e1365bd03f6a980965a20d585f90c056f33e1fc241b63a6f0e7", size = 60558 },
+ { url = "https://files.pythonhosted.org/packages/84/2e/7e48207420e6ca7e7a05c0e4ebe9464ec9965c8face256f3ef8cc2acd862/wrapt-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:771ec962fe3ccb078177c9b8f3529e204ffcbb11d62d509e0a438e6a83f7ca68", size = 61501 },
+ { url = "https://files.pythonhosted.org/packages/67/2b/639a4970ecdc7143acb69a1162c76b0f1620218ad502c33e1a88d28f00b1/wrapt-2.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73e742368b52f9cf0921e1d2bcb8a6a44ede2e372e33df6e77caa136a942099f", size = 113954 },
+ { url = "https://files.pythonhosted.org/packages/81/5d/8d9177c8c0ecaf5313b462be63c5aa9672044b02bfd644dd65c6cb420d2a/wrapt-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e9129d1b582c55ad0dfb9e29e221daa0e02b18c67d8642bc8d08dd7038b3aed", size = 115994 },
+ { url = "https://files.pythonhosted.org/packages/e3/e3/c5a514a0ed1dc463f5b6b4e31abbaa3b8df48b9fd391a6e8412608155a29/wrapt-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cc9e37bfe67f6ea738851dd606640a87692ff81bcc76df313fb75d08e05e855f", size = 115245 },
+ { url = "https://files.pythonhosted.org/packages/35/9c/2fc6a31f5758266de2cf9dc6111d3bda7b7dd6cbdcabfd755103bbcda08f/wrapt-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:46583aae3c807aa76f96355c4943031225785ed160c84052612bba0e9d456639", size = 113679 },
+ { url = "https://files.pythonhosted.org/packages/6c/81/ce52694dc8184f4898c01c8af20e145b348fc7a0e4766a7345c45f0e9ce6/wrapt-2.1.0-cp311-cp311-win32.whl", hash = "sha256:e3958ba70aef2895d8c62c2d31f51ced188f60451212294677b92f4b32c12978", size = 57865 },
+ { url = "https://files.pythonhosted.org/packages/85/31/0df5d38243c2a538e7bd481e676d286b41f98a729e0d37cfed9f4421ad4d/wrapt-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0ff9797e6e0b82b330ef80b0cdba7fcd0ca056d4c7af2ca44e3d05fd47929ede", size = 60227 },
+ { url = "https://files.pythonhosted.org/packages/a3/79/b587edbab21d6b8a7460234440c784e08344bcdf4fdfd9a6e9125ea14923/wrapt-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:4b0a29509ef7b501abe47b693a3c91d1f21c9a948711f6ce7afa81eb274c7eae", size = 58648 },
+ { url = "https://files.pythonhosted.org/packages/f8/6f/c731b1fbbcdf9bd202809c6fa354c4237b663dd82a95035a7cbe899cfd25/wrapt-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a64c0fb29c89810973f312a04c067b63523e7303b9a2653820cbf16474c2e5cf", size = 61149 },
+ { url = "https://files.pythonhosted.org/packages/b2/da/7022458a1d99f0c59720a0b0fd4b1966f8df6d41e741aadfe43bc5350547/wrapt-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5509d9150ed01c4149e40020fa68e917d5c4bb77d311e79535565c2a0418afcb", size = 61743 },
+ { url = "https://files.pythonhosted.org/packages/b5/f4/57cc12c3fc6f4fe6ccfc15567cc1ac8aeb53a9946a675adc3df7a1ee4e6a/wrapt-2.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:52bb58b3207ace156b6134235fd43140994597704fd07d148cbcfb474ee084ea", size = 121331 },
+ { url = "https://files.pythonhosted.org/packages/5e/a4/a96ea114298f81f02c07313da85fd46a2a57bbe12389d0619ac3371f691c/wrapt-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7112cbf72fc4035afe1e3314a311654c41dd92c2932021ef76f5ca87583917b3", size = 122907 },
+ { url = "https://files.pythonhosted.org/packages/ac/43/df73362b6e47f92aaff0fc3fc459314025c795f75d61724c83232dee199c/wrapt-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e90656b433808a0ab68e95aaf9f588aea5c8c7a514e180849dfc638ba00ec449", size = 121337 },
+ { url = "https://files.pythonhosted.org/packages/51/4f/8147e3b9a7887cee4eeb3a3414265ad4649a156832a08063f55aa7842af0/wrapt-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e45f54903da38fc4f6f66397fd550fc0dac6164b4c5e721c1b4eb05664181821", size = 120461 },
+ { url = "https://files.pythonhosted.org/packages/35/b1/eea720fcca8a05dec848a6d11a47c20f59bdabdcc444ba3be0589350eb7a/wrapt-2.1.0-cp312-cp312-win32.whl", hash = "sha256:6653bf30dbbafd55cb4553195cc60b94920b6711a8835866c0e02aa9f22c5598", size = 58089 },
+ { url = "https://files.pythonhosted.org/packages/af/79/8a8f3f8c71ee3379191b69e47f32115fa25cdb6d5b581d74c64d5c897fa7/wrapt-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d61238a072501ed071a9f4b9567d10c2eb3d2f1a0258ae79b47160871d8f29c3", size = 60330 },
+ { url = "https://files.pythonhosted.org/packages/08/4e/e992d05c3d2f7163883a65ead2620ff5fe7b3d44d7c2136ce981e40e453d/wrapt-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:9e971000347f61271725e801ef44fa5d01b52720e59737f0d96280bffb98c5d1", size = 58727 },
+ { url = "https://files.pythonhosted.org/packages/30/93/b414826a5aaf2fdcfe73c2e649cbeb2e098fef4820d1217554ee64f45666/wrapt-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:875a10a6f3b667f90a39010af26acf684ba831d9b18a86b242899d57c74550fa", size = 61155 },
+ { url = "https://files.pythonhosted.org/packages/58/9e/8b21ea776bf2a3c858e3377ecde4b348893ec44dc1726baaf583ca22c56e/wrapt-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e00f8559ceac0fb45091daad5f15d37f2c22bdc28ed71521d47ff01aad8fff3d", size = 61747 },
+ { url = "https://files.pythonhosted.org/packages/da/ec/48cd2470ad09557dfe6fccfe9de98698cc0df3786a6d4d97e8edd574d67a/wrapt-2.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ce0cf4c79c19904aaf2e822af280d7b3c23ad902f57e31c5a19433bc86e5d36d", size = 121342 },
+ { url = "https://files.pythonhosted.org/packages/3b/4e/e8447b31be27b6057cdfc904a38632a765c3407fb4d10d11e5c1d0c203d5/wrapt-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dd4f8c2256fcde1a85037a1837afc52e8d32d086fd669ae469455fd9a988d6", size = 122951 },
+ { url = "https://files.pythonhosted.org/packages/7e/b6/73a6c9277e844ffe11f3002ad27a84ff5418248def33af9435d24dfe6c5b/wrapt-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:737e1e491473047cb66944b8b8fd23f3f542019afd6cf0569d1356d18a7ea6d5", size = 121373 },
+ { url = "https://files.pythonhosted.org/packages/85/04/869384435fecf829dc05621ffa02dab0f2f830be5d42fa8d8ac7b0b4c9fa/wrapt-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38de19e30e266c15d542ceb0603e657db4e82c53e7f47fd70674ae5da2b41180", size = 120468 },
+ { url = "https://files.pythonhosted.org/packages/80/ac/42a5378d9b5b486122ae0572c46ae8d69ab6486b9f13961e6b9706297ff5/wrapt-2.1.0-cp313-cp313-win32.whl", hash = "sha256:bc7d496b6e16bd2f77e37e8969b21a7b58d6954e46c6689986fb67b9078100e5", size = 58095 },
+ { url = "https://files.pythonhosted.org/packages/86/de/538fcef30f70a1aaadab4cab7d0396037518d7ec2b064557171147ce297f/wrapt-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:57df799e67b011847ef7ac64b05ed4633e56b64e7e7cab5eb83dc9689dbe0acf", size = 60344 },
+ { url = "https://files.pythonhosted.org/packages/08/13/27884668b21e9f0a625c13ebd6a8d70ad8371250ec8519881858404686bf/wrapt-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:01559d2961c29edc6263849fd9d32b29a20737da67648c7fd752a67bd96208c7", size = 58734 },
+ { url = "https://files.pythonhosted.org/packages/c9/a3/e558c5b8f3a097aa1e942e2d75923adebfdfafb5a51ec425d1d062e49ab0/wrapt-2.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:66f588c8b3a44863156cfaccb516f946a64b3b03a6880822ab0b878135ca1f5c", size = 62972 },
+ { url = "https://files.pythonhosted.org/packages/93/b6/7157e98107099fad846f1e79308cc0954e26b25b01c03f1624ba7f57ec54/wrapt-2.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:355779ff720c11a2a5cffd03332dbce1005cb4747dca65b0fc8cdd5f8bf1037e", size = 63610 },
+ { url = "https://files.pythonhosted.org/packages/e4/8e/b8992671e4b4d3ce2a53af930588c204bf37b66eb212bd1722f2a5a8cf62/wrapt-2.1.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a0471df3fb4e85a9ff62f7142cdb169e31172467cdb79a713f9b1319c555903", size = 152538 },
+ { url = "https://files.pythonhosted.org/packages/8c/f6/79f9fd4b3c0a8715e651fff1cc1182a983fd971376d5688a06fa94e31acd/wrapt-2.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bacf063143fa86f15b00a21259a81c95c527a18d504b8c820835366d361c879", size = 158702 },
+ { url = "https://files.pythonhosted.org/packages/9e/46/f88b52beb813eeb830d9134bc6eaf3e53cde4e3cfa1804e383754d4104fe/wrapt-2.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c87cd4f61a3b7cd65113e74006e1cd6352b74807fcc65d440e8342f001f8de5e", size = 155564 },
+ { url = "https://files.pythonhosted.org/packages/93/31/97145ea71e3e5a1b419af5c410b07b258155dc7cc1a6302791a93e991c83/wrapt-2.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2893498fe898719ac8fb6b4fe36ca86892bec1e2480d94e3bd1bc592c00527ad", size = 150165 },
+ { url = "https://files.pythonhosted.org/packages/10/bd/f33551d5bfbb0ddab81296cffc15570570039a973c0f99bba474be0fadf2/wrapt-2.1.0-cp313-cp313t-win32.whl", hash = "sha256:cbc07f101f5f1e7c23ec06a07e45715f459de992108eeb381b21b76d94dbaf4f", size = 59785 },
+ { url = "https://files.pythonhosted.org/packages/5f/3a/9a76be7a36442f43841bb6336e262e09a915b2fb5dfc2822ffce1fb903d2/wrapt-2.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2ccc89cd504fc29c32f0b24046e8edf3ef0fcbc5d5efe8c91b303c099863d2c8", size = 63085 },
+ { url = "https://files.pythonhosted.org/packages/7a/35/65a13c2df008d189ebca5fec534011c5dd69ab4f47e6923b403321816fbf/wrapt-2.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:0b660be1c9cdfb4c711baab4ccbd0e9d1b65a0480d38729ec8cdbf3b29cb7f15", size = 60254 },
+ { url = "https://files.pythonhosted.org/packages/6f/eb/7c9eb1ea9b10ea98d9983a147c877a2ae927acb4a86e2dc4a0b548f05ad1/wrapt-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f7bf95bae7ac5f2bbcb307464b3b0ff70569dd3b036a87b1cf7efb2c76e66e5", size = 61316 },
+ { url = "https://files.pythonhosted.org/packages/6d/c2/1c3d16d6b644f688913a00e2dc10f59adca817b5b3ee034ce4e9a692ab63/wrapt-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:be2f541a242818829526e5d08c716b6730970ed0dc1b76ba962a546947d0f005", size = 61813 },
+ { url = "https://files.pythonhosted.org/packages/8c/51/b6170084b6b771cc62374d924e328df2e81f687399a835f003497cad1110/wrapt-2.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ad3aa174d06a14b4758d5a1678b9adde8b8e657c6695de9a3d4c223f4fcbbcce", size = 120309 },
+ { url = "https://files.pythonhosted.org/packages/f8/34/467829f0dd79f50878b2e67b67c67c816a6326a27d252d4192ef815b4a09/wrapt-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bffa584240d41bc3127510e07a752f94223d73bb1283ac2e99ac44235762efd2", size = 122690 },
+ { url = "https://files.pythonhosted.org/packages/df/5b/244c61a65e0bc9d4a18cfa2a2b3b05f8065290284fc60436a7ea5047ee10/wrapt-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9b2da9c8f1723994b335dbf9f496fbfabc76bcdd001f73772b8eb2118a714cea", size = 121115 },
+ { url = "https://files.pythonhosted.org/packages/86/7d/f9b5e103d3caf23a72c04a1baf2b61c4a14d1feb440d3c98c26725b4503a/wrapt-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eabe95ea5fbe1524a53c0f3fc535c99f2aa376ec1451b0b79d943d2240d80e36", size = 119487 },
+ { url = "https://files.pythonhosted.org/packages/f8/49/b61fdc4680dd5cd6828977341b9fd729e2c623338bfe65647f5c0ff8195e/wrapt-2.1.0-cp314-cp314-win32.whl", hash = "sha256:2cd647097df1df78f027ac7d5d663f05daa1a117b69cf7f476cb299f90557747", size = 58519 },
+ { url = "https://files.pythonhosted.org/packages/6a/4f/42ab43e496d0d19caed9f69366d0f28f7f08c139297e78b17dab6ecbb6d5/wrapt-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0fc3e388a14ef8101c685dc80b4d2932924a639a03e5c44b5ffabbda2f1f2dc", size = 60767 },
+ { url = "https://files.pythonhosted.org/packages/ef/15/0337768ac97a8758bc0fc1afdf5f656075a7facf198f62bbe8a22b789277/wrapt-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7c06653908a23a85c4b2455b9d37c085f9756c09058df87b4a2fce2b2f8d58c2", size = 59056 },
+ { url = "https://files.pythonhosted.org/packages/d6/f1/58f4674d1db44912003a51b34e8d9823a832fbbb39162e9dbe06e5f6424e/wrapt-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c70b4829c6f2f4af4cdaa16442032fcaf882063304160555e4a19b43fd2c6c9d", size = 63061 },
+ { url = "https://files.pythonhosted.org/packages/02/c1/07f6bf6619285f39cd616314217170c6160da99a46ad6ae4a60044f6ab5a/wrapt-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7fd4c4ee51ebdf245549d54a7c2181a4f39caac97c9dc8a050b5ba814067a29", size = 63620 },
+ { url = "https://files.pythonhosted.org/packages/46/82/f7df1648762260f60c4e22c066a17d95f20267c94bfe653fab4f08e2c297/wrapt-2.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b158558438874e5fd5cb505b5a635bd08c84857bc937973d9e12e1166cdf3b", size = 152546 },
+ { url = "https://files.pythonhosted.org/packages/78/b7/d953336e09bac13a9ffa9073e167c5dec8aaa4a717a8551bf64cb4683590/wrapt-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2e156fe2d41700b837be9b1d8d80ebab44e9891589bc7c41578ef110184e29", size = 158704 },
+ { url = "https://files.pythonhosted.org/packages/39/a1/2ed57e46b30af2a5a750c85a9dd30d2244ef10e2f8db150560126d8cbd24/wrapt-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f1e9bac6a6c1ba65e0ac50e32c575266734a07b6c17e718c4babd91e2faa69b", size = 155563 },
+ { url = "https://files.pythonhosted.org/packages/d0/8c/4f54f7ea5addf208be44459393185aaa193bd2d0b8ecf4683b159fcc5238/wrapt-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12687e6271df7ae5706bee44cc1f77fecb7805976ec9f14f58381b30ae2aceb5", size = 150189 },
+ { url = "https://files.pythonhosted.org/packages/b7/cc/e8290a1cd94297fbc1e9fbad06481b5a7c918f2db6645c550f05ee47f359/wrapt-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:38bbe336ee32f67eb99f886bd4f040d91310b7e660061bb03b9083d26e8cf915", size = 60431 },
+ { url = "https://files.pythonhosted.org/packages/d0/df/af5d244938853e3adb1251ca1397e9fa78d3e92adc808a0af0a8547585d3/wrapt-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0fa64a9a07df7f85b352adc42b43e7f44085fb11191b8f5b9b77219f7aaf7e17", size = 63859 },
+ { url = "https://files.pythonhosted.org/packages/39/c4/28b6f2804e8bc05d17114dfed03a80bce5b83ca2113fd44eecbef12275d1/wrapt-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:da379cbdf3b7d97ace33a69a391b7a7e2130b1aca94dc447246217994233974c", size = 60446 },
+ { url = "https://files.pythonhosted.org/packages/57/e9/70983b75d4abd6f85cffc6df79c623220ec5a579ceaacabac35c904b7b52/wrapt-2.1.0-py3-none-any.whl", hash = "sha256:e035693a0d25ea5bf5826df3e203dff7d091b0d5442aaefec9ca8f2bab38417f", size = 43886 },
]
[[package]]
@@ -6292,121 +6263,121 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 },
]
[[package]]
name = "xmltodict"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
+ { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580 },
]
[[package]]
name = "xxhash"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" },
- { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" },
- { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" },
- { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" },
- { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" },
- { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" },
- { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" },
- { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" },
- { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" },
- { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" },
- { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" },
- { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" },
- { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" },
- { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" },
- { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" },
- { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" },
- { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" },
- { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" },
- { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" },
- { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" },
- { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" },
- { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" },
- { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" },
- { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" },
- { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" },
- { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" },
- { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" },
- { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" },
- { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" },
- { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" },
- { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" },
- { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" },
- { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" },
- { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" },
- { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" },
- { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" },
- { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" },
- { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" },
- { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" },
- { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
- { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
- { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
- { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
- { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
- { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
- { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
- { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
- { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
- { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" },
- { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" },
- { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" },
- { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" },
- { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" },
- { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" },
- { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
- { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
- { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
- { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
- { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
- { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
- { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" },
- { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" },
- { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" },
- { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" },
- { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" },
- { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" },
- { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" },
- { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" },
- { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" },
- { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" },
- { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" },
- { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" },
- { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" },
- { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" },
- { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" },
- { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" },
- { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" },
- { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" },
- { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" },
- { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" },
- { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" },
- { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" },
- { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" },
- { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" },
- { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" },
- { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" },
- { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" },
- { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" },
- { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" },
- { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" },
- { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" },
- { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" },
- { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" },
- { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" },
- { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844 },
+ { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809 },
+ { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665 },
+ { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550 },
+ { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384 },
+ { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749 },
+ { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880 },
+ { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912 },
+ { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654 },
+ { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867 },
+ { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012 },
+ { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409 },
+ { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574 },
+ { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481 },
+ { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861 },
+ { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744 },
+ { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816 },
+ { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035 },
+ { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914 },
+ { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163 },
+ { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411 },
+ { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883 },
+ { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392 },
+ { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898 },
+ { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655 },
+ { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001 },
+ { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431 },
+ { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617 },
+ { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534 },
+ { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876 },
+ { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738 },
+ { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821 },
+ { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127 },
+ { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975 },
+ { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241 },
+ { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471 },
+ { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936 },
+ { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440 },
+ { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990 },
+ { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689 },
+ { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068 },
+ { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495 },
+ { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620 },
+ { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542 },
+ { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880 },
+ { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956 },
+ { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072 },
+ { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409 },
+ { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736 },
+ { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833 },
+ { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348 },
+ { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070 },
+ { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907 },
+ { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839 },
+ { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304 },
+ { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930 },
+ { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787 },
+ { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916 },
+ { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799 },
+ { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044 },
+ { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754 },
+ { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846 },
+ { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343 },
+ { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074 },
+ { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388 },
+ { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614 },
+ { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024 },
+ { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541 },
+ { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305 },
+ { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848 },
+ { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142 },
+ { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547 },
+ { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214 },
+ { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290 },
+ { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795 },
+ { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955 },
+ { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072 },
+ { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579 },
+ { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854 },
+ { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965 },
+ { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484 },
+ { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162 },
+ { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007 },
+ { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956 },
+ { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401 },
+ { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083 },
+ { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913 },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586 },
+ { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526 },
+ { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898 },
+ { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662 },
+ { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056 },
+ { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251 },
+ { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481 },
+ { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565 },
]
[[package]]
@@ -6418,186 +6389,186 @@ dependencies = [
{ name = "multidict" },
{ name = "propcache" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" },
- { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" },
- { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },
- { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" },
- { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" },
- { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" },
- { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" },
- { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" },
- { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" },
- { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" },
- { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" },
- { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" },
- { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" },
- { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" },
- { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" },
- { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" },
- { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
- { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
- { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
- { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
- { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
- { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
- { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
- { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
- { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
- { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
- { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
- { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
- { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
- { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
- { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
- { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
- { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
- { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
- { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
- { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
- { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
- { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
- { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
- { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
- { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
- { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
- { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
- { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
- { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
- { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
- { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
- { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
- { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
- { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
- { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
- { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
- { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
- { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
- { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
- { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
- { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
- { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
- { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
- { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
- { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
- { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
- { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
- { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
- { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
- { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
- { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
- { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
- { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
- { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
- { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
- { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
- { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
- { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
- { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
- { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
- { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
- { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
- { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
- { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
- { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
- { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
- { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
- { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
- { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
- { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
- { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
- { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
- { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
- { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
- { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
- { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
- { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
- { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
- { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
- { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
- { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607 },
+ { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027 },
+ { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963 },
+ { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406 },
+ { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581 },
+ { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924 },
+ { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890 },
+ { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819 },
+ { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601 },
+ { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072 },
+ { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311 },
+ { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094 },
+ { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944 },
+ { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804 },
+ { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858 },
+ { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637 },
+ { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000 },
+ { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338 },
+ { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909 },
+ { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940 },
+ { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825 },
+ { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705 },
+ { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518 },
+ { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267 },
+ { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797 },
+ { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535 },
+ { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324 },
+ { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803 },
+ { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220 },
+ { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589 },
+ { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213 },
+ { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330 },
+ { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980 },
+ { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424 },
+ { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821 },
+ { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243 },
+ { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361 },
+ { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036 },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671 },
+ { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059 },
+ { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356 },
+ { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331 },
+ { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590 },
+ { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316 },
+ { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431 },
+ { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555 },
+ { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965 },
+ { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205 },
+ { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209 },
+ { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966 },
+ { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312 },
+ { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967 },
+ { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949 },
+ { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818 },
+ { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626 },
+ { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129 },
+ { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776 },
+ { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879 },
+ { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996 },
+ { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047 },
+ { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947 },
+ { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943 },
+ { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715 },
+ { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857 },
+ { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520 },
+ { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504 },
+ { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282 },
+ { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080 },
+ { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696 },
+ { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121 },
+ { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080 },
+ { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661 },
+ { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645 },
+ { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361 },
+ { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451 },
+ { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814 },
+ { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799 },
+ { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990 },
+ { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292 },
+ { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888 },
+ { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223 },
+ { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981 },
+ { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303 },
+ { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820 },
+ { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203 },
+ { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173 },
+ { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562 },
+ { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828 },
+ { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551 },
+ { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512 },
+ { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400 },
+ { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140 },
+ { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473 },
+ { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056 },
+ { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292 },
+ { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171 },
+ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814 },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 },
]
[[package]]
name = "zstandard"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513 }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
- { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
- { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
- { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
- { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
- { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
- { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
- { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
- { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
- { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
- { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
- { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
- { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
- { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
- { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
- { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
- { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
- { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
- { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
- { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
- { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
- { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
- { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
- { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
- { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
- { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
- { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
- { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
- { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
- { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
- { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
- { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
- { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
- { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
- { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
- { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
- { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
- { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
- { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
- { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
- { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
- { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
- { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
- { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
- { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
- { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
- { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
- { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
- { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
- { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
- { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
- { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
- { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
- { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
- { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
- { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
- { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
- { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
- { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
- { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
- { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
- { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
- { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
- { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
- { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
- { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254 },
+ { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559 },
+ { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020 },
+ { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126 },
+ { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390 },
+ { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914 },
+ { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635 },
+ { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277 },
+ { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377 },
+ { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493 },
+ { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018 },
+ { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672 },
+ { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753 },
+ { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047 },
+ { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484 },
+ { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183 },
+ { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533 },
+ { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738 },
+ { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436 },
+ { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019 },
+ { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012 },
+ { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148 },
+ { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652 },
+ { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993 },
+ { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806 },
+ { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659 },
+ { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933 },
+ { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008 },
+ { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517 },
+ { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292 },
+ { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237 },
+ { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922 },
+ { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276 },
+ { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679 },
+ { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735 },
+ { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440 },
+ { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070 },
+ { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001 },
+ { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120 },
+ { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230 },
+ { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173 },
+ { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736 },
+ { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368 },
+ { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022 },
+ { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889 },
+ { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952 },
+ { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054 },
+ { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113 },
+ { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936 },
+ { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232 },
+ { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671 },
+ { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887 },
+ { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658 },
+ { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849 },
+ { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095 },
+ { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751 },
+ { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818 },
+ { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402 },
+ { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108 },
+ { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248 },
+ { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330 },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123 },
+ { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591 },
+ { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513 },
+ { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118 },
+ { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940 },
]
diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx
index 8711cbd6f..33f3a4846 100644
--- a/web/src/app/auth/space/callback/page.tsx
+++ b/web/src/app/auth/space/callback/page.tsx
@@ -1,6 +1,11 @@
import { useEffect, useState, useCallback, Suspense, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
+import {
+ beginAuthenticatedSession,
+ bootstrapWorkspaceSession,
+ getPendingInvitationToken,
+} from '@/app/infra/http';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
@@ -23,6 +28,7 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
type SpaceOAuthLoginResult = {
token: string;
user: string;
+ workspace_uuid?: string;
};
const pendingSpaceOAuthLogins = new Map<
@@ -32,19 +38,23 @@ const pendingSpaceOAuthLogins = new Map<
function getOrCreateSpaceOAuthLoginPromise(
authCode: string,
+ state: string,
+ workspaceUuid?: string,
+ launchAssertion?: string,
): Promise {
- const pendingRequest = pendingSpaceOAuthLogins.get(authCode);
+ const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
+ const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
if (pendingRequest) {
return pendingRequest;
}
const requestPromise = httpClient
- .exchangeSpaceOAuthCode(authCode)
+ .exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
.finally(() => {
- pendingSpaceOAuthLogins.delete(authCode);
+ pendingSpaceOAuthLogins.delete(requestKey);
});
- pendingSpaceOAuthLogins.set(authCode, requestPromise);
+ pendingSpaceOAuthLogins.set(requestKey, requestPromise);
return requestPromise;
}
@@ -58,29 +68,57 @@ function SpaceOAuthCallbackContent() {
'loading' | 'confirm' | 'success' | 'error'
>('loading');
const [errorMessage, setErrorMessage] = useState('');
+ const [terminalErrorCode, setTerminalErrorCode] = useState<
+ 'space_account_not_registered' | 'space_account_binding_required' | null
+ >(null);
const [isBindMode, setIsBindMode] = useState(false);
const [code, setCode] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [localEmail, setLocalEmail] = useState('');
const handleOAuthCallback = useCallback(
- async (authCode: string) => {
+ async (
+ authCode: string,
+ state: string,
+ workspaceUuid?: string,
+ launchAssertion?: string,
+ ) => {
try {
- const response = await getOrCreateSpaceOAuthLoginPromise(authCode);
+ const response = await getOrCreateSpaceOAuthLoginPromise(
+ authCode,
+ state,
+ workspaceUuid,
+ launchAssertion,
+ );
if (!isMountedRef.current) {
return;
}
- localStorage.setItem('token', response.token);
- if (response.user) {
- localStorage.setItem('userEmail', response.user);
+ beginAuthenticatedSession(response.token, response.user);
+ if (getPendingInvitationToken()) {
+ navigate('/invitations/accept', { replace: true });
+ return;
+ }
+ const workspaceResult = await bootstrapWorkspaceSession({
+ preferredWorkspaceUuid: response.workspace_uuid,
+ });
+ if (workspaceResult.status === 'unavailable') {
+ throw new Error('No Workspace is available for this Account');
+ }
+ if (response.workspace_uuid) {
+ navigate('/home', { replace: true });
+ return;
}
setStatus('success');
toast.success(t('common.spaceLoginSuccess'));
// If wizard state exists, redirect back to wizard instead of home
const wizardState = localStorage.getItem('langbot_wizard_state');
- const redirectTo = wizardState ? '/wizard' : '/home';
+ const destination = wizardState ? '/wizard' : '/home';
+ const redirectTo =
+ workspaceResult.status === 'selection-required'
+ ? `/workspaces/select?returnTo=${encodeURIComponent(destination)}`
+ : destination;
setTimeout(() => {
navigate(redirectTo);
}, 1000);
@@ -90,7 +128,15 @@ function SpaceOAuthCallbackContent() {
}
setStatus('error');
- const errorObj = err as { msg?: string };
+ const errorObj = err as { code?: string; msg?: string };
+ if (
+ errorObj.code === 'space_account_not_registered' ||
+ errorObj.code === 'space_account_binding_required'
+ ) {
+ setTerminalErrorCode(errorObj.code);
+ setErrorMessage(t(`account.${errorObj.code}`));
+ return;
+ }
const errMsg = (errorObj?.msg || '').toLowerCase();
if (errMsg.includes('account email mismatch')) {
setErrorMessage(t('account.spaceEmailMismatch'));
@@ -113,14 +159,19 @@ function SpaceOAuthCallbackContent() {
return;
}
- localStorage.setItem('token', response.token);
- if (response.user) {
- localStorage.setItem('userEmail', response.user);
+ beginAuthenticatedSession(response.token, response.user);
+ const workspaceResult = await bootstrapWorkspaceSession();
+ if (workspaceResult.status === 'unavailable') {
+ throw new Error('No Workspace is available for this Account');
}
setStatus('success');
toast.success(t('account.bindSpaceSuccess'));
+ const redirectTo =
+ workspaceResult.status === 'selection-required'
+ ? '/workspaces/select?returnTo=%2Fhome'
+ : '/home';
setTimeout(() => {
- navigate('/home');
+ navigate(redirectTo);
}, 1000);
} catch (err) {
if (!isMountedRef.current) {
@@ -128,7 +179,11 @@ function SpaceOAuthCallbackContent() {
}
setStatus('error');
- const errorObj = err as { msg?: string };
+ const errorObj = err as { code?: string; msg?: string };
+ if (errorObj.code === 'space_account_email_mismatch') {
+ setErrorMessage(t('account.spaceEmailMismatch'));
+ return;
+ }
const errMsg = (errorObj?.msg || '').toLowerCase();
if (errMsg.includes('account email mismatch')) {
setErrorMessage(t('account.spaceEmailMismatch'));
@@ -152,6 +207,8 @@ function SpaceOAuthCallbackContent() {
const errorDescription = searchParams.get('error_description');
const mode = searchParams.get('mode');
const state = searchParams.get('state');
+ const workspaceUuid = searchParams.get('workspace_uuid');
+ const launchAssertion = searchParams.get('launch_assertion');
if (error) {
setStatus('error');
@@ -161,15 +218,13 @@ function SpaceOAuthCallbackContent() {
return;
}
- if (!authCode) {
- setStatus('error');
- setErrorMessage(t('common.spaceLoginNoCode'));
- return;
- }
-
- setCode(authCode);
-
if (mode === 'bind') {
+ if (!authCode) {
+ setStatus('error');
+ setErrorMessage(t('common.spaceLoginNoCode'));
+ return;
+ }
+ setCode(authCode);
// Bind mode - verify state (token) exists
if (!state) {
setStatus('error');
@@ -180,9 +235,31 @@ function SpaceOAuthCallbackContent() {
setIsBindMode(true);
setLocalEmail(localStorage.getItem('userEmail') || '');
setStatus('confirm');
+ } else if (workspaceUuid || launchAssertion) {
+ if (!workspaceUuid || !launchAssertion) {
+ setStatus('error');
+ setErrorMessage(t('common.spaceLoginFailed'));
+ return;
+ }
+ handleOAuthCallback(
+ authCode ?? '',
+ state ?? '',
+ workspaceUuid,
+ launchAssertion,
+ );
} else {
- // Normal login/register mode
- handleOAuthCallback(authCode);
+ if (!authCode) {
+ setStatus('error');
+ setErrorMessage(t('common.spaceLoginNoCode'));
+ return;
+ }
+ setCode(authCode);
+ if (!state) {
+ setStatus('error');
+ setErrorMessage(t('common.spaceLoginFailed'));
+ return;
+ }
+ handleOAuthCallback(authCode, state);
}
return () => {
isMountedRef.current = false;
@@ -216,9 +293,11 @@ function SpaceOAuthCallbackContent() {
? t('account.bindSpaceSuccess')
: t('common.spaceLoginSuccess'))}
{status === 'error' &&
- (isBindMode
- ? t('account.bindSpaceFailed')
- : t('common.spaceLoginError'))}
+ (terminalErrorCode
+ ? t(`account.${terminalErrorCode}Title`)
+ : isBindMode
+ ? t('account.bindSpaceFailed')
+ : t('common.spaceLoginError'))}
{status === 'loading' &&
diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx
index 358f37da3..1dfcb74e0 100644
--- a/web/src/app/home/add-extension/page.tsx
+++ b/web/src/app/home/add-extension/page.tsx
@@ -636,7 +636,8 @@ function AddExtensionContent() {
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
httpClient
.installPluginFromGithub(
- selectedAsset.download_url,
+ selectedAsset.id,
+ selectedRelease.id,
githubOwner,
githubRepo,
selectedRelease.tag_name,
diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx
index 6c0a9ccc1..52556515d 100644
--- a/web/src/app/home/bots/BotDetailContent.tsx
+++ b/web/src/app/home/bots/BotDetailContent.tsx
@@ -29,11 +29,17 @@ import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
+import { useCurrentWorkspace } from '@/app/infra/http';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
+ const currentWorkspace = useCurrentWorkspace();
+ const canManage =
+ currentWorkspace?.permissions.includes('resource.manage') ?? false;
+ const canViewMonitoring =
+ currentWorkspace?.permissions.includes('resource.view') ?? false;
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
@@ -131,19 +137,23 @@ export default function BotDetailContent({ id }: { id: string }) {
{/* Header */}
{t('bots.createBot')}
-
+ {canManage && (
+
+ )}
{/* Content */}
@@ -164,6 +174,7 @@ export default function BotDetailContent({ id }: { id: string }) {
id="bot-enable-switch"
checked={botEnabled}
onCheckedChange={handleEnableToggle}
+ disabled={!canManage}
/>
- setShowDeleteConfirm(true)}
- className="shrink-0"
- >
-
- {t('common.delete')}
-
+ {canManage && (
+ setShowDeleteConfirm(true)}
+ className="shrink-0"
+ >
+
+ {t('common.delete')}
+
+ )}
@@ -185,14 +197,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
)}
- handleImportedSkills([skillName])}
- onSkillUpdated={handleSkillUpdated}
- />
+
diff --git a/web/src/app/home/skills/page.tsx b/web/src/app/home/skills/page.tsx
index 9d50040db..b6529b995 100644
--- a/web/src/app/home/skills/page.tsx
+++ b/web/src/app/home/skills/page.tsx
@@ -7,9 +7,13 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
+import { useCurrentWorkspace } from '@/app/infra/http';
export default function SkillsPage() {
const { t } = useTranslation();
+ const currentWorkspace = useCurrentWorkspace();
+ const canManage =
+ currentWorkspace?.permissions.includes('resource.manage') ?? false;
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
@@ -61,7 +65,11 @@ export default function SkillsPage() {
{t('common.cancel')}
-
+
{t('common.save')}
@@ -72,13 +80,15 @@ export default function SkillsPage() {
)}
- {}}
- />
+
);
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts
index 9da29e4b4..4c4504707 100644
--- a/web/src/app/infra/entities/api/index.ts
+++ b/web/src/app/infra/entities/api/index.ts
@@ -346,10 +346,16 @@ export interface ApiRespSystemInfo {
debug: boolean;
version: string;
edition: string;
+ /** Independent instance-level gate for local stdio MCP transports. */
+ mcp_stdio_enabled: boolean;
cloud_service_url: string;
enable_marketplace: boolean;
allow_modify_login_info: boolean;
disable_models_service: boolean;
+ invitation_delivery?: {
+ enabled: boolean;
+ provider: 'resend' | 'smtp' | null;
+ };
limitation: SystemLimitation;
/** Public outbound IPs of the deployment (``system.outbound_ips`` in
* config.yaml). Shown on adapter config forms whose platform requires
diff --git a/web/src/app/infra/entities/workspace.ts b/web/src/app/infra/entities/workspace.ts
new file mode 100644
index 000000000..a64fd0f07
--- /dev/null
+++ b/web/src/app/infra/entities/workspace.ts
@@ -0,0 +1,66 @@
+export type WorkspaceRole =
+ | 'owner'
+ | 'admin'
+ | 'developer'
+ | 'operator'
+ | 'viewer';
+
+export interface Workspace {
+ uuid: string;
+ instance_uuid: string;
+ name: string;
+ slug: string;
+ type: 'personal' | 'team';
+ status: 'provisioning' | 'active' | 'suspended' | 'archived' | 'deleted';
+ source: 'local' | 'cloud_projection';
+}
+
+export interface WorkspaceMembership {
+ uuid: string;
+ workspace_uuid: string;
+ account_uuid: string;
+ email: string;
+ role: WorkspaceRole;
+ status: 'active' | 'disabled' | 'removed';
+ joined_at: string | null;
+ created_at: string;
+}
+
+export interface CurrentWorkspace {
+ workspace: Workspace;
+ membership: WorkspaceMembership;
+ permissions: string[];
+ placement_generation: number;
+ /** Signed Cloud display metadata; never used for client-side authorization. */
+ plan_name?: string | null;
+}
+
+export interface WorkspaceSpaceBilling {
+ credits: number | null;
+ owner_space_bound: boolean;
+ is_workspace_owner: boolean;
+}
+
+/** Account-scoped Workspace entry returned before a Workspace is selected. */
+export type WorkspaceBootstrapEntry = CurrentWorkspace;
+
+export interface WorkspaceBootstrapResponse {
+ workspaces: WorkspaceBootstrapEntry[];
+}
+
+export interface WorkspaceInvitation {
+ uuid: string;
+ workspace_uuid: string;
+ normalized_email: string;
+ role: Exclude;
+ status: 'pending' | 'accepted' | 'revoked' | 'expired';
+ expires_at: string;
+ created_at: string;
+}
+
+export type WorkspaceInvitationDeliveryStatus = 'sent' | 'link_only' | 'failed';
+
+export interface WorkspaceInvitationDelivery {
+ status: WorkspaceInvitationDeliveryStatus;
+ provider: 'resend' | 'smtp' | null;
+}
diff --git a/web/src/app/infra/hooks/useMCPStdioPolicy.ts b/web/src/app/infra/hooks/useMCPStdioPolicy.ts
new file mode 100644
index 000000000..0e8a9b95c
--- /dev/null
+++ b/web/src/app/infra/hooks/useMCPStdioPolicy.ts
@@ -0,0 +1,32 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import { httpClient } from '@/app/infra/http/HttpClient';
+
+/**
+ * Load the instance-level stdio MCP gate independently of Box health.
+ *
+ * The hook fails closed while loading or when System Info is unavailable.
+ * This is only a WebUI guard; the backend loader enforces the same gate at
+ * the final transport boundary.
+ */
+export function useMCPStdioPolicy() {
+ const [enabled, setEnabled] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(async () => {
+ try {
+ const info = await httpClient.getSystemInfo();
+ setEnabled(info.mcp_stdio_enabled === true);
+ } catch {
+ setEnabled(false);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ return { enabled, loading, refresh };
+}
diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts
index 371b82942..e1733eaff 100644
--- a/web/src/app/infra/http/BackendClient.ts
+++ b/web/src/app/infra/http/BackendClient.ts
@@ -1,4 +1,4 @@
-import { BaseHttpClient } from './BaseHttpClient';
+import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
import {
ApiRespProviderRequesters,
ApiRespProviderRequester,
@@ -61,6 +61,16 @@ import type { PluginLogEntry } from '@/app/infra/entities/plugin';
import type { I18nObject } from '@/app/infra/entities/common';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
+import type {
+ CurrentWorkspace,
+ Workspace,
+ WorkspaceInvitation,
+ WorkspaceInvitationDelivery,
+ WorkspaceMembership,
+ WorkspaceBootstrapResponse,
+ WorkspaceRole,
+ WorkspaceSpaceBilling,
+} from '@/app/infra/entities/workspace';
/**
* 后端服务客户端
@@ -733,13 +743,15 @@ export class BackendClient extends BaseHttpClient {
}
public installPluginFromGithub(
- assetUrl: string,
+ assetId: number,
+ releaseId: number,
owner: string,
repo: string,
releaseTag: string,
): Promise {
return this.post('/api/v1/plugins/install/github', {
- asset_url: assetUrl,
+ asset_id: assetId,
+ release_id: releaseId,
owner,
repo,
release_tag: releaseTag,
@@ -1057,6 +1069,10 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/plugins/debug-info');
}
+ public getBoxRuntimeStatus(): Promise {
+ return this.get('/api/v1/box/runtime-status');
+ }
+
public getBoxStatus(): Promise {
return this.get('/api/v1/box/status');
}
@@ -1067,19 +1083,29 @@ export class BackendClient extends BaseHttpClient {
// ============ User API ============
public checkIfInited(): Promise<{ initialized: boolean }> {
- return this.get('/api/v1/user/init');
+ return this.get('/api/v1/user/init', undefined, { skipWorkspace: true });
}
public initUser(user: string, password: string): Promise