diff --git a/.claude/docs/REPO_WALKTHROUGH.md b/.claude/docs/REPO_WALKTHROUGH.md index 48c8b41dba..d08684da0e 100644 --- a/.claude/docs/REPO_WALKTHROUGH.md +++ b/.claude/docs/REPO_WALKTHROUGH.md @@ -36,6 +36,9 @@ src/ │ │ ├── client_types.py # Client-side type definitions │ │ ├── utils.py # Shared utilities │ │ │ +│ │ ├── harness/ # Agent protocols and captured trace collection +│ │ │ └── capture/ # Dialect adapters, session routing, exact tokens, SSE replay +│ │ │ │ │ ├── env_server/ # Server-side components │ │ │ ├── interfaces.py # Environment abstract base class │ │ │ ├── http_server.py # HTTPEnvServer (FastAPI + WebSocket) @@ -58,6 +61,8 @@ src/ │ │ ├── local_python_executor.py # Python code execution │ │ └── git_server_client.py # Git operations │ │ +│ ├── harbor/ # Harbor tasks, harness seams, provider qualification, live UI +│ │ │ ├── discovery/ # RFC 011 metadata-only repository catalogs │ │ ├── models.py # Declaration profile and identity invariants │ │ ├── repository.py # Bounded reads from one committed Git tree @@ -111,6 +116,7 @@ envs/ │ ├── echo_environment.py # Environment implementation │ └── Dockerfile # Container definition │ +├── harbor_env/ # Thin Harbor package and trainer session factory ├── thinkingbox_env/ # Stateful MCP business-workflow benchmark adapter ├── coding_env/ # Python code execution environment ├── chat_env/ # Conversational environment @@ -273,3 +279,9 @@ ThinkingBox examples: `example_usage.py` is a public-client smoke test, while `e | `envs/echo_env/` | Reference implementation - start here | | `rfcs/001-abstractions.md` | Core architectural decisions | | `.claude/docs/INVARIANTS.md` | Rules that must never be broken | + +## Harbor integration + +`src/openenv/core/harness/capture/` records inference calls independently of a trainer or tokenizer. `src/openenv/harbor/` runs Harbor tasks, reconciles their trajectories, and exposes the shared training contract through clients and the Gradio playground. `envs/harbor_env/` is the installable environment wrapper. `examples/harbor/nemo_shell_profile/` provides the explicitly qualified NeMo shell workflow. + +See `docs/source/guides/harbor-provider-qualification.md` for provider and adapter evidence, `rfcs/012-harbor-capture-providers.md` for explicit evaluation/training and session ownership, and `tests/envs/test_harbor*.py` / `test_capture*.py` for deterministic capture regressions. diff --git a/.gitignore b/.gitignore index 51fe1e842b..89d1cc387f 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,9 @@ docs/source/_env_assets/ # Sphinx-gallery generated output docs/source/auto_getting_started/ docs/source/sg_execution_times.rst + +# Gradio UI build artifacts +.gradio/ + +# Local preservation archives and isolated preparation worktrees +/temp/ diff --git a/docs/source/_toctree.yml b/docs/source/_toctree.yml index 3a788cdaab..c5e973ef44 100644 --- a/docs/source/_toctree.yml +++ b/docs/source/_toctree.yml @@ -15,6 +15,8 @@ title: Catalog Discovery - local: guides/rl-integration title: RL Training + - local: guides/harbor-provider-qualification + title: Harbor Provider Qualification - local: guides/rewards title: Rewards - local: guides/concepts @@ -77,6 +79,8 @@ title: Terminus - local: environments/coding_tools title: Coding Tools + - local: environments/harbor + title: Harbor - local: environments/chat title: Chat - local: environments/atari diff --git a/docs/source/environments.md b/docs/source/environments.md index dd1c6d2702..a1256485b8 100644 --- a/docs/source/environments.md +++ b/docs/source/environments.md @@ -320,5 +320,12 @@ The OpenEnv community has built a catalog of ready-to-run environments that cove 🤗 HF +
+
Harbor
+

Serve Harbor tasks across agent harnesses and sandboxes, with captured traces for evaluation and exact-token training.

+
+ 📄 Docs +
+
diff --git a/docs/source/environments/harbor.md b/docs/source/environments/harbor.md new file mode 100644 index 0000000000..89ef4d0ff8 --- /dev/null +++ b/docs/source/environments/harbor.md @@ -0,0 +1,557 @@ + +# Harbor Environment + +**Train one policy against many coding agents.** Pick a Harbor dataset, pick an agent, pick a +sandbox, and get back the exact token ids and per-token logprobs of every model call the agent made, +plus the task's own reward. + +## Overview + +An agent harness is a moving part you probably do not want to own. opencode, codex, claude-code and +gemini-cli each have their own loop, their own tool surface and their own wire format, and a policy +trained against exactly one of them learns that one's habits. + +The usual cost of supporting several is one integration per agent. Here it is one integration total: + +| | | +|---|---| +| **16 harnesses** | validated end to end, across 4 wire dialects | +| **23 sandbox backends** | from Harbor, 4 with credential checks wired in | +| **any Harbor dataset** | HF repo, local directory, or Harbor registry name | + +All three are chosen **per rollout**, so one server covers the whole matrix and rotating the harness +during training is a config change rather than a new environment. + +Harbor supplies the tasks, sandboxes, agents and verifiers. This environment adds the OpenEnv +surface: dataset discovery over the Task API, one `run_rollout` MCP tool, a capture proxy, and a UI. + +### What you get back + +Every rollout carries the reward from the task's verifier and the full trace: each conversation with +its messages, and per turn the text, tool calls and finish reason. + +Against vLLM or SGLang you also get the training contract, per model call: + +```python +turn.prompt_token_ids # the engine's own tokenisation of everything before this turn +turn.completion_token_ids # what it sampled +turn.per_token_logps # the behaviour-policy logprob of each sampled token +``` + +Which of the two you got is on the result as `rollout_type` (`"train"` or `"eval"`) and +`capture_level`, decided by probing the endpoint before the server starts. Against a hosted provider +the token fields are empty and `rollout_type == "eval"`; nothing pretends otherwise, and +`to_turn_records` raises rather than handing a trainer empty lists. + +## The intercept + +The agent is a black box. It is a real CLI tool running in a sandbox, and it was never written with +training in mind. So instead of modifying it, an OpenAI-spec proxy is placed between it and your +model, and every call is recorded as it passes. + +``` +agent in a sandbox + | base URL points at the proxy, API key IS the capture session id + v +capture proxy --normalise to chat, ask for token ids--> your endpoint + ^ | + | replay in the agent's own dialect <----------------------+ + | + +-- every call becomes a node in a rollout graph, linked by token prefix + (by message prefix when the endpoint returns no token ids) +``` + +Three properties make this work across agents rather than for one: + +**Nothing is tokenised locally.** The engine tokenises each prompt in order to serve it and hands +back `prompt_token_ids`, so turn *k+1*'s prompt is by construction the canonical tokenisation of +everything before it, tool results included. Re-rendering a prompt offline with a chat template +drifts from what the model actually saw, and a prompt that differs by one token silently splits one +long conversation into several short ones. + +**Four wire dialects.** Coding agents did not converge on one API. chat-completions, OpenAI +Responses, Anthropic Messages and Google `generateContent` are all translated to a single upstream +shape and replayed in the dialect the agent expects, streaming included. That is what makes codex, +claude-code and gemini-cli work rather than only the chat-completions agents. + +**The API key is the session id.** One proxy serves many concurrent rollouts with no port each, and +a caller without a registered session is rejected, so the proxy is safe to expose to a sandbox. + +Turns are linked into a graph by **exact token prefix**: a call whose `prompt_token_ids` begin with +an existing node's full sequence becomes its child. Nothing else is consulted, because request ids +and timestamps are per-agent and the prefix is not. Conversations, retries and subagent branches +fall out of that for free, and a branch the agent abandoned is marked discarded so it is never +trained with the reward the main path earned. + +## Prerequisites + +You need an OpenAI-compatible endpoint. Which *kind* of rollout you get depends on what it can +return, and that is probed at startup rather than configured: + +| | eval | train | +|---|---|---| +| reward, `rewards` dict, step results | yes | yes | +| full trace: conversations, per-turn text, tool calls | yes | yes | +| `prompt_token_ids`, `completion_token_ids`, `per_token_logps` | no | yes | +| `contract.json`, TRL rollout func | no | yes | + +**For trainable rollouts** the endpoint must return token ids *and* the sampling distribution's +logprobs. Both are checked by probing, because both fail silently: + +```bash +vllm serve --return-tokens-as-token-ids --logprobs-mode processed_logprobs +# or SGLang built from git main (sgl-project/sglang#30917); no serving flag needed +``` + +Without them the endpoint answers every request normally and returns no token ids. Rollouts would +look perfect and contain nothing trainable, and that failure has no loud edge — so the level is +probed before the server binds a port, printed as `[EVAL ONLY]`, stamped on every result, and no +training contract is ever built from it. + +**For eval rollouts** any reachable OpenAI-spec endpoint works, including hosted ones: + +```bash +openenv harbor serve --llm-url https://api.openai.com/v1 --api-key $OPENAI_API_KEY --model gpt-5.6-sol +openenv harbor serve --llm-url https://router.huggingface.co/v1 --api-key $HF_API_KEY --model Qwen/Qwen3.6-35B-A3B +openenv harbor serve --llm-url https://api.anthropic.com/v1 --api-key $ANTHROPIC_API_KEY --model claude-sonnet-5 +``` + +Anthropic needs no `--auth-header`: its OpenAI-compatible `/v1/chat/completions` accepts +`Authorization: Bearer`. Its `/v1/models` does not — that route wants `x-api-key` *and* +`anthropic-version` — so the model list comes back empty and `--model` is required. An endpoint that +publishes no usable model list is not treated as unreachable for exactly this reason; the completion +probe is what decides. + +`--api-key` (or `$OPENENV_LLM_API_KEY`) authenticates the proxy to the endpoint; `--auth-header` +changes the header name when a provider wants something other than `Authorization: Bearer`. This is +not the key the agent receives — that one is a capture session id, minted per rollout — and it never +leaves the server process. + +A hosted provider also rejects parameters a local vLLM accepts, per model rather than per endpoint. +Every current OpenAI model refuses `max_tokens` (wants `max_completion_tokens`), any `temperature` +other than 1, and `logprobs` outright, while opencode, codex and qwen-coder all send the first two. +`gpt-5.6` additionally refuses **function tools** on `/v1/chat/completions` unless +`reasoning_effort` is `"none"` — which for a coding agent is every call that matters; left unhandled +it presents as a rollout that makes one model call and then idles until the agent timeout. + +The proxy reads each such 400, applies the one edit the provider names, retries, and caches the fix — +so the agents work unchanged. Every applied fix is reported on the result, at startup and in the UI, +because these are changed experiments rather than cosmetic rewrites: dropping `temperature` alters +the sampling distribution, and `reasoning_effort: "none"` turns reasoning off. For `gpt-5.6` the +better answer is the Responses route the error message itself recommends. + +### What startup checks about *agents*, not just capture + +The capture probe sends no tools. Every validated harness sends a tool manifest on every call, so a +second, non-fatal probe asks the way a harness asks and reports: + +| finding | meaning | +|---|---| +| `tools: ok` | a tool call came back; agents can work here | +| `no_tool_calling` (FATAL) | the endpoint refuses a manifest outright; no agent rollout is possible | +| `no_tool_call_emitted` (WARN) | manifest accepted, but it answered in prose | +| `behaviour_changed` (WARN) | a compat fix changed how the MODEL behaves, not just a field name | + +The last one is the reason this probe exists. `gpt-5.6` accepts function tools on +`/v1/chat/completions` only if `reasoning_effort` is `"none"`, and with reasoning off it emits one +valid tool call and then agentic loops die: goose and codex each managed a single model call and 0/3 +tasks, while both scored 3/3 against a non-reasoning model on the same endpoint. Without a tool in the +probe body that demand is never made, so `harbor info` looked healthy and the failure only appeared +minutes into a rollout. Now it is reported before a sandbox is booted. + +Truncation is deliberately treated as inconclusive rather than a failure: a reasoning model spends +output tokens thinking before it calls anything, and a small cap made `Qwen3.6-35B-A3B` look +tool-incapable when it in fact worked with all 16 harnesses. + +### Why `--logprobs-mode processed_logprobs` is not optional + +`token_ids` comes from the `return_token_ids` **request** parameter, not from either serving flag, so +a vLLM started with neither flag still returns aligned, negative, correctly-counted logprobs and would +grade as fully trainable. But vLLM's `logprobs_mode` defaults to `raw_logprobs` — the values *before* +temperature and top-k/top-p are applied — and GRPO's importance ratio needs the logprob under the +policy that actually sampled the token. + +Startup measures which you have, rather than inferring it: the gap between the top two logprobs is +requested at temperature 1.0 and 2.0. Processed logprobs are `logsoftmax(logits / T)`, so the gap +scales by `1/T` while the normalising constant cancels; raw logprobs cannot move at all. Measured on +two live Qwen3.5-4B servers: + +| | gap @T=1.0 | gap @T=2.0 | verdict | +|---|---|---|---| +| `--logprobs-mode processed_logprobs` | 6.7500 | 3.3750 | `processed` | +| default | 6.7500 | 6.7500 | `raw` | + +A measured `raw` endpoint is downgraded to EVAL rather than refused — it is still a perfectly good +eval backend — and `OPENENV_ALLOW_RAW_LOGPROBS=1` overrides that if you know better than the probe. +The gap is compared rather than the values themselves because a data-parallel engine answers +consecutive calls from different replicas; comparing values directly misread one such engine. + +Install the extra, which brings Harbor and every sandbox backend: + +```bash +pip install "openenv[harbor]" # needs Python 3.12 or newer +``` + +## Quick Start + +### 1. See what this machine can do + +```bash +openenv harbor info \ + --llm-url $LLM \ + --dataset AdithyaSK/data_agent_rl_environment_eval +``` + +``` +llm Qwen/Qwen3.5-9B [ok] +sandboxes 2 of 4 usable + [ok] e2b + [ok] modal + [--] docker Docker daemon is not running. + [--] daytona SDK not installed (daytona). +datasets 1 split(s), 366 tasks +harnesses 16 validated of 30 known +``` + +Read-only, boots nothing. It tells you which sandboxes have working credentials **and** an +importable SDK, so you find out here rather than 90 seconds into a rollout. + +### 2. Run one rollout, no server + +```bash +openenv harbor rollout \ + --llm-url $LLM \ + --dataset AdithyaSK/data_agent_rl_environment_eval \ + --task-index 0 --harness opencode --sandbox e2b \ + --out rollout.json +``` + +``` +[opencode / e2b] task 0: 0000_369_369503_qa_1 ... + ok reward=1.00 turns=9 roots=2 multi-turn tokens=1043 atif=match 48s +``` + +This path involves no env server, which makes it the one to reach for when something breaks: if +`rollout` works and `serve` does not, the fault is in the serving layer and nothing below it. + +### 3. Serve it + +```bash +openenv harbor serve --llm-url $LLM --dataset org/train,org/eval +``` + +You get a Task API for discovery, one long-running `run_rollout` MCP tool, and a UI at `/web`. + +```python +from harbor_env import HarborEnv + +with HarborEnv(base_url="http://localhost:8000") as env: + split = env.splits()[0]["name"] + result = env.run_rollout(split=split, task_index=0, harness="opencode", sandbox="e2b") + + print(result.reward, result.n_turns) + for turn in result.turns: + print(len(turn.completion_token_ids), sum(turn.per_token_logps)) +``` + +`harness` and `sandbox` are per call, so consecutive rollouts against the same server can use +different agents and different backends. + +## CLI reference + +Four commands. Every flag below is the complete set, with its type and default. `openenv harbor + --help` prints the same thing. + +Exit codes: + +| code | meaning | +|---|---| +| `0` | success | +| `1` | ran, but failed. `rollout` returns this if **any** rollout in the batch was unusable | +| `2` | usage error: a missing or invalid flag. Nothing ran | + +The `1` and `2` split matters if you are scripting this: `2` means the command never started, so +retrying it unchanged will fail the same way. + +### `openenv harbor info` + +Report what this machine can run. Read-only: boots no sandbox, starts no server, makes no rollout. + +| flag | type | default | meaning | +|---|---|---|---| +| `--llm-url` | str | `""` | OpenAI-spec endpoint. Optional here; without it the LLM section is skipped and the rest still reports | +| `--model` | str | `""` | Served model id. Auto-detected when the endpoint serves exactly one | +| `--dataset` | str | none | Dataset spec. Repeatable, or comma-separated | +| `--env-file` | path | `""` | dotenv with provider credentials, loaded before the checks | +| `--verbose` | flag | off | List all 30 harnesses, not only the 16 validated | +| `--json` | flag | off | Emit machine-readable JSON instead of the text report | + +```bash +openenv harbor info --llm-url $LLM --dataset org/tasks --json +``` + +The JSON has four top-level keys: `llm`, `sandboxes`, `datasets`, `harnesses`. Each sandbox entry is +`{name, available, detail}`, so a script can select a backend without parsing prose: + +```bash +openenv harbor info --llm-url $LLM --json \ + | jq -r '.sandboxes[] | select(.available) | .name' +``` + +### `openenv harbor rollout` + +Run rollouts with no env server involved. This is the debugging path: if `rollout` works and `serve` +does not, the fault is in the serving layer and nothing below it. + +| flag | type | default | meaning | +|---|---|---|---| +| `--llm-url` | str | **required** | OpenAI-spec endpoint. No default and no env fallback, on purpose | +| `--dataset` | str | **required** | Dataset spec. Only the first is used by this command | +| `--task-index` | int | `0` | Index into the split. Stable: index is a task's identity | +| `-n`, `--n-tasks` | int | `1` | Run this many consecutive tasks from `--task-index` | +| `--harness` | str | `opencode` | A validated seam name, or `module:Class` for your own agent | +| `--sandbox` | str | `e2b` | Harbor environment type | +| `--model` | str | `""` | Served model id. Auto-detected when unambiguous | +| `--port` | int | `8100` | Local port for the capture proxy. One per concurrent process | +| `--expose` | str | `gradio` | How the sandbox reaches the proxy: `gradio`, `cloudflare`, `direct` | +| `--reward-key` | str | `""` | Which reward key is the training signal, for multi-reward tasks | +| `--trials-dir` | path | tmp | Where Harbor writes trial artifacts | +| `--keep-sandbox` | flag | off | Leave sandboxes alive for debugging | +| `--force-build` | flag | off | Rebuild the sandbox image, bypassing the content-hash cache | +| `--env-file` | path | `""` | dotenv with provider credentials | +| `--out` | path | `""` | Write the full result JSON, token ids and logprobs included | + +```bash +openenv harbor rollout --llm-url $LLM --dataset org/tasks \ + --task-index 0 -n 5 --harness codex --sandbox modal --out results.json +``` + +`-n` runs tasks sequentially in one process, reusing one proxy and one forward. To parallelise, run +several processes and **give each its own `--port`**. Two processes sharing a port is refused with +an error naming the process that holds it. + +Use `--force-build` when a task has never been built on this account, or when a cached image has +drifted because the task pins its dependencies loosely. + +### `openenv harbor serve` + +Start the env server: Task API for discovery, one long-running `run_rollout` MCP tool, and a UI at +`/web`. + +| flag | type | default | meaning | +|---|---|---|---| +| `--llm-url` | str | **required** | OpenAI-spec endpoint | +| `--dataset` | str | none | Dataset specs to serve as splits. Repeatable | +| `--model` | str | `""` | Served model id | +| `--host` | str | `0.0.0.0` | Bind address | +| `--port` | int | `8000` | Env server port. Faces the trainer and the browser | +| `--capture-port` | int | `8100` | Capture proxy port. Faces the sandbox | +| `--expose` | str | `gradio` | How the sandbox reaches the proxy | +| `--env-file` | path | `""` | dotenv with provider credentials | + +| `--api-key` | str | `$OPENENV_LLM_API_KEY` | Credential for the endpoint, for a hosted provider | +| `--auth-header` | str | `Authorization` | Header to send it under, e.g. `x-api-key` | + +Refuses to start only if the endpoint is unreachable. One that cannot return token ids starts as an eval deployment and says so. + +### `openenv harbor push` + +Deploy the same server to a Hugging Face Space. + +| flag | type | default | meaning | +|---|---|---|---| +| `--llm-url` | str | **required** | Endpoint the deployed Space will use | +| `--repo-id` | str | **required** | Target Space, e.g. `you/harbor-env` | +| `--dataset` | str | none | Dataset specs. Repeatable | +| `--model` | str | `""` | Served model id | +| `--bucket` | str | Space name | Storage bucket holding the task suites. `none` disables the mount and downloads instead | +| `--hardware` | str | `""` | Space hardware, e.g. `cpu-basic` | +| `--private` | flag | off | Create it private. Rollouts then cannot work, see below | +| `--recreate` | flag | off | Delete the Space first, then deploy fresh | +| `--dry-run` | flag | off | Print exactly what would be sent and stop | +| `--env-file` | path | `""` | dotenv whose provider keys become Space **secrets** | + +```bash +openenv harbor push --llm-url $LLM --dataset org/train,org/eval \ + --repo-id you/harbor-env --env-file .env --dry-run +``` + +`--private` is supported but rollouts will not work on a private Space: the capture proxy is served +at `/capture`, and a private Space requires an auth header the sandboxed agent does not +send. Use it only to park a deployment. + +## Supported harnesses + +16 of the 30 known agents are validated end to end. "Validated" means a real rollout produced token +ids and logprobs, and where the agent emits a trajectory, its own record agreed with the capture. + +| harness | dialect | runs | +|---|---|---| +| `opencode` | chat-completions | in sandbox | +| `goose` | chat-completions | in sandbox | +| `qwen-coder` | chat-completions | in sandbox | +| `swe-agent` | chat-completions | in sandbox | +| `mini-swe-agent` | chat-completions | in sandbox | +| `openhands-sdk` | chat-completions | in sandbox | +| `openclaw` | chat-completions | in sandbox | +| `hermes` | chat-completions | in sandbox | +| `kimi-cli` | chat-completions | in sandbox | +| `pi` | chat-completions | in sandbox | +| `vibe` | chat-completions | in sandbox | +| `terminus-2` | chat-completions | **host side** | +| `codex` | OpenAI Responses | in sandbox | +| `trae-agent` | OpenAI Responses | in sandbox | +| `claude-code` | Anthropic Messages | in sandbox | +| `gemini-cli` | Google generateContent | in sandbox | + +Supporting four dialects rather than chat-completions alone is what makes the last four rows work. + +`terminus-2` runs in the server process rather than inside the sandbox, so it reaches the proxy on +localhost and needs no public URL. + +The other 14 known agents have a seam but are untested; run `openenv harbor info --verbose` to list +them. Anything Harbor supports can be reached with `--harness module:Class`. + +## Supported sandboxes + +`openenv harbor info` checks these four by default and reports why any is unusable: + +| sandbox | credentials | validated | +|---|---|---| +| `e2b` | `E2B_API_KEY` | yes, extensively | +| `modal` | `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET`, or `~/.modal.toml` | yes, extensively | +| `docker` | none, but the daemon must be running | works, not swept | +| `daytona` | `DAYTONA_API_KEY`, or `DAYTONA_JWT_TOKEN` + `DAYTONA_ORGANIZATION_ID` | not swept | + +e2b and modal were compared on identical tasks and came out indistinguishable, which is the check +that matters: a backend-specific capture bug is exactly what a single-backend test hides. + +Harbor registers 23 backends in total. Any of them can be passed to `--sandbox`; the four above are +the ones with a credential check wired in. + +## Environment details + +### Where the proxy runs + +Locally there are two ports: the env server faces the trainer and the browser, the capture proxy +faces the sandbox and is the only one published. Sharing one port would expose the env server the +moment the proxy became reachable. + +Hosted, that inverts. A Space has one port and one public URL, so the proxy is mounted on the env +server's own app at `/capture` and nothing is forwarded. It still rejects callers without a +registered session id, which is what keeps a public mount from being an open relay. + +### Sandboxes and providers + +Two different things share the word "sandbox", and mixing them up is a common early confusion: + +- **Harbor backends** are where the *agent* runs. That is what `--sandbox` selects. +- **OpenEnv providers** (`local_docker`, `hf_sandbox`, `modal`, `aca`, ...) host the *env server* + itself and have no `exec`. + +A backend counts as usable only if its class imports **and** Harbor's own preflight passes. +Credentials alone are not enough: a provider with valid keys but no SDK installed would otherwise +report available and fail at rollout time. + +### Rewards + +The verifier produces a dictionary. OpenEnv wants one number. The dictionary is forwarded unchanged +and the scalar is chosen by an explicit rule: + +1. one key, use it +2. a key named `reward`, use it +3. otherwise fail and ask for `--reward-key` + +Combining several keys automatically would be inventing reward semantics, so it refuses instead. +All shaping belongs in the trainer. + +**`reward=None` is not zero.** It means the verifier never ran. A dead sandbox scored as zero looks +like a wrong answer, which is how an infrastructure failure gets mistaken for a model result. + +### Reading a result + +| field | meaning | +|---|---| +| `ok` | the rollout is usable. `False` means something failed, and `error` says what | +| `reward` | the verifier's number, or `None` if it never ran | +| `n_turns` | model calls captured | +| `n_roots` | independent conversations. More than one means subagents or auxiliary calls | +| `turns[]` | per-call token ids, logprobs, text and tool calls | +| `conversations[]` | the full message list per conversation, system prompt included | +| `atif` | `match`, `MISMATCH`, or `none`. See below | +| `findings` | warnings worth reading before training on the rollout | + +`atif` is an independent cross-check. Harbor's own trajectory file records what the *harness* +thought happened; the capture records what crossed the wire. Two measurements of the same rollout +through completely different paths. `match` means they agree call for call. + +**A failed rollout returns a result, never an exception.** That is deliberate. In an in-process +design one exception on one rank hangs every rank at the distributed barrier, so behind an HTTP +boundary a failure has to come back as `ok=False` instead. + +## Deploying to Hugging Face Spaces + +```bash +openenv harbor push \ + --llm-url $LLM \ + --dataset org/train,org/eval \ + --repo-id you/harbor-env \ + --env-file .env +``` + +Configuration travels as Space variables, provider credentials as Space secrets. Add `--dry-run` +to print exactly what would be sent first, and `--recreate` to delete and redeploy for a clean test. + +Two details that matter: + +**Task suites are mounted, not downloaded.** A Harbor suite is thousands of small files and Space +disk is ephemeral, so a download is re-paid on every restart. `push` syncs the suites into a storage +bucket named after the Space and mounts it at `/data`. The copy is server side, and re-running +`push` copies only what is new. + +**The Space must be public.** The capture proxy is served at `/capture`, and a private +Space requires an auth header that the agent inside the sandbox does not send. This is safe because +the proxy rejects any caller without a registered session id, so a public mount is not an open +relay. + +## Troubleshooting + +**Rollout finishes with zero model calls.** The agent never reached the proxy. Usually the endpoint +URL is wrong, the model name did not resolve, or auth was rejected. Check the `findings` field, +which names the likely cause. + +**`llm ... [FAILED]` at startup.** The endpoint is unreachable — wrong URL, wrong model name, or +a missing `--api-key` for a provider that needs one. The findings name which. + +**`llm ... [EVAL ONLY]` at startup.** The endpoint answers but returns no token ids, so rollouts +carry the reward and the trace and nothing trainable. Expected for OpenAI, Anthropic and HF +Inference Providers. If you meant to train, restart the engine with +`--return-tokens-as-token-ids --logprobs-mode processed_logprobs`, or build SGLang from git main — +released SGLang carries only `return_prompt_token_ids`, which is not enough. + +**`contract.json` is missing after a rollout.** The rollout was an eval rollout; check +`rollout_type` and `capture_level` on the result. There is deliberately no all-zero contract to +download, because a file named `contract.json` containing no contract is more convincing than an +empty list. + +**A sandbox shows `[--]` in `info`.** The detail column says why, and it is usually a missing +credential or a missing SDK. Install everything with `pip install "openenv[harbor]"`. + +**`atif=none`.** That harness writes no trajectory file, so no cross-check is possible. Capture is +unaffected. + +**Many roots for one rollout.** Normal for agents that run subagents or auxiliary calls. Each root +is a separate conversation, and only agent conversations are counted as trainable. + +**Exit code 137.** The agent was killed inside the sandbox, almost always by the OOM killer on a +large input. That is a task failure, not a capture failure. + +## References + +- [Harbor](https://github.com/laude-institute/harbor), which provides the datasets, sandboxes, + agents and verifiers +- [Polar](https://github.com/NVIDIA-NeMo/ProRL-Agent-Server) + ([paper](https://arxiv.org/abs/2605.24220)), the black-box approach this capture layer follows, + and the source of the vendored dialect transformers +- [verifiers](https://github.com/willccbb/verifiers), whose `Dialect` model informed the auxiliary + route and streaming handling diff --git a/docs/source/guides/harbor-provider-qualification.md b/docs/source/guides/harbor-provider-qualification.md new file mode 100644 index 0000000000..3cdece7ad3 --- /dev/null +++ b/docs/source/guides/harbor-provider-qualification.md @@ -0,0 +1,99 @@ +# Harbor provider qualification + +An installed adapter is not evidence that a harness works with a particular model provider. Qualify the actual harness version, model route, sandbox, capture implementation, and task set together. + +## Evaluation and training capture + +Use explicit `purpose="eval"` for evaluation. Hosted OpenAI, native Anthropic, and Hugging Face routes can produce graded evaluation traces without engine token IDs. An eval trace must not export a training contract, even when its endpoint happens to provide token IDs. + +Use `purpose="train"` only with a verified token-capable endpoint. Training export preserves engine prompt IDs, sampled completion IDs, processed log probabilities, and loss masks. `openenv.harbor.contract.to_trace_entries` rejects evaluation traces and fatal capture findings. Do not reconstruct token IDs by tokenizing rendered conversation text or fill missing log probabilities with zeros. + +A prompt rewrite may create several training rows from one rollout. That does not by itself make the sampled tokens invalid. Report rows per rollout, repeated context, retained supervision, and downstream weighting separately. Capture correctness does not establish an efficient training configuration. + +Native Anthropic requests retain their original signed blocks and supported native metadata. Translation to another harness protocol rejects output semantics that cannot be preserved. The native streaming bridge buffers the upstream response and replays SDK-compatible events; it does not provide upstream first-token streaming latency. + +## Evidence and support tiers + +A qualification report has one cell per harness/provider pair. The provider names are `openai`, `anthropic`, `hf`, and `vllm`. Keep capture artifacts and attempt configuration alongside the report, including exact model routes, available revision pins, harness versions, task identities, sampling, and source hashes. + +The report distinguishes: + +- `eval_pass`: a completed, graded rollout with captured calls, no fatal capture findings, and no training export. A task score of zero is still a valid evaluation; an infrastructure failure or missing grade is not a benchmark zero. +- `capture_and_reader_pass`: exact capture passed validation and the real training reader retained the expected supervision. +- `optimizer_pass`: the current capture artifacts were consumed by a real optimizer diagnostic. Record model revision, input fingerprints, consumed rows, finite losses, and finite nonzero gradients. Explicitly state whether this was diagnostic replay and whether weight synchronization was tested. +- `failed`, `blocked`, `in_progress`, and `not_run`: retain these outcomes rather than replacing them with a passing result from a different configuration. + +`harness_maturity_rows` derives support tiers from validated report cells. Stable requires all three eval profiles and a current-capture optimizer pass on vLLM. Partial or pending support is experimental. Four failed or blocked profiles are unstable for the tested matrix. None of these labels claim universal compatibility or production-scale reliability beyond the recorded coverage. + +Set `OPENENV_HARBOR_QUALIFICATION_REPORT` to the report JSON path to display evidence in Gradio. The UI defaults to stable harnesses, provides an experimental opt-in, and excludes unstable harnesses. With no report, adapters are unqualified and require the experimental opt-in. Changing the filter invalidates the prior selection. Recorded results do not certify a newly entered endpoint or automatically pin its harness installation. For profile-specific evidence, the UI passes the recorded profile to the rollout: ACP supports `opencode-1.18.30`; NeMo supports `shell-1.9.0` when the example workflow package is available in the checkout. The selected profile is displayed in the agent label. Profile selection creates a local seam copy and does not mutate the global adapter registry. Programmatic callers can pass `harness_profile=` to `run_rollout` or `build_trial_config`; unknown profiles fail explicitly. + +## Recorded qualification: 15 September 2026 + +The completed qualification attempted all 29 adapters on four provider profiles, with two fixed tasks per pair (116 pairs). Results are compatibility smoke tests, not benchmark pass@1 scores. “Stable” means passing this recorded coverage; it does not certify arbitrary models, harness upgrades, or production-scale reliability. + +| Provider profile | Model | Passing adapters | +|---|---|---:| +| OpenAI evaluation | `gpt-5.4-mini-2026-03-17` | 21/29 | +| Native Anthropic evaluation | `claude-sonnet-4-5-20250929` | 20/29 | +| Hugging Face evaluation | `Qwen/Qwen3.5-9B:together` | 19/29 | +| vLLM training capture and optimizer diagnostic | `Qwen/Qwen3.5-4B` | 21/29 | + +The HF route is pinned, but its hosted weights are not an immutable revision. The vLLM model revision is `851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a`. That profile used vLLM 0.25.1, TP=1, DP=1, BF16, a 131072-token context, processed log probabilities, engine token IDs, Qwen3 XML tool parsing, Qwen3 reasoning parsing with thinking disabled, and no image/video inputs. + +There are **14 stable, 9 experimental, and 6 unstable adapters**. A failed pair means the two-task qualification did not pass; it does not necessarily mean both tasks failed or that the adapter can never support that provider. + +| Adapter | Tier | OpenAI | Anthropic | HF | vLLM | +|---|---|---|---|---|---| +| acp | experimental | failed | eval_pass | eval_pass | optimizer_pass | +| antigravity-cli | experimental | failed | failed | eval_pass | optimizer_pass | +| antigravity-sdk | unstable | failed | failed | failed | failed | +| claude-code | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| cline-cli | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| codex | experimental | eval_pass | eval_pass | failed | optimizer_pass | +| computer-1 | unstable | failed | failed | failed | failed | +| copilot-cli | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| cursor-cli | unstable | failed | failed | failed | failed | +| devin | unstable | failed | failed | failed | failed | +| eve | unstable | failed | failed | failed | failed | +| gemini-cli | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| goose | experimental | eval_pass | eval_pass | failed | optimizer_pass | +| grok-build | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| kimi-cli | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| mimo | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| mini-swe-agent | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| nemo-agent | experimental | eval_pass | eval_pass | failed | optimizer_pass | +| openclaw | experimental | eval_pass | eval_pass | failed | failed | +| opencode | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| openhands | experimental | eval_pass | eval_pass | eval_pass | failed | +| openhands-sdk | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| pi | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| qwen-coder | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| rovodev-cli | unstable | failed | failed | failed | failed | +| swe-agent | experimental | eval_pass | failed | eval_pass | optimizer_pass | +| terminus-2 | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | +| trae-agent | experimental | eval_pass | failed | eval_pass | optimizer_pass | +| vibe | stable | eval_pass | eval_pass | eval_pass | optimizer_pass | + +### Scope and known limitations + +The optimizer diagnostics consumed 99 current capture rows across 21 adapters using the real `AsyncGRPOTrainer`, with finite losses and finite nonzero gradients. They used a diagnostic advantage of +1 and did not synchronize weights. This establishes capture consumption by the trainer, not reward-normalized learning, long-run stability, or correct weighting when a rollout produces multiple rows. Claude Code and other prompt-rewriting harnesses still need row-budget and weighting checks for a particular training configuration. + +ACP qualification applies only to the `opencode-1.18.30` profile, and NeMo qualification only to `shell-1.9.0`. ACP has partial native usage evidence; NeMo lacks independent native token counts. Engine capture remains authoritative, and these results do not qualify arbitrary ACP agents or NeMo workflows. + +Codex, Goose, and NeMo retain HF failures. Antigravity CLI retains OpenAI and Anthropic failures. SWE-agent timed out on Anthropic; Trae-agent captured no Anthropic calls. OpenClaw and OpenHands retain training trajectory reconciliation failures. Antigravity SDK also failed strict reconciliation despite executing tools. Missing vendor credentials or application prerequisites prevented qualification of Cursor, Devin, Rovo Dev, and Eve. Computer-1 needs a separate desktop/vision qualification. Keep these failures visible; do not relax token checks to promote an adapter. + +The final combined regression run passed 567 tests with two skips; native Anthropic SDK streaming replay was also checked separately. Live qualification and optimizer replay used separate services and source snapshots. Updating this documentation or a qualification report does not restart training, change an existing training snapshot, or deploy the adapter changes. A running process continues to use its configured source and services. + +## Regression and live validation + +Run the deterministic Harbor tests from the repository root: + +```bash +PYTHONPATH=src:envs python -m pytest tests/envs/test_harbor*.py -q +``` + +These tests cover provider conversion, capture graphs, export masks, reconciliation, routing, lifecycle behavior, and evidence gates. They are not a replacement for live harness execution. + +For live qualification, use isolated services and immutable source snapshots. Fix the task set and versions before launch; bound sandbox concurrency; save each result before proceeding. Resume by scheduling only missing cases into a new attempt directory, preserving prior failures and provenance. Reusing a capture in optimizer evidence requires its exact source hash and row fingerprint to match; a newer retry must not inherit an older optimizer pass. + +Some adapters require a separately supplied application, workflow, vision input, or vendor account. Report the missing prerequisite or restriction. Do not substitute a different agent, silently remove observations, relax token checks, or claim success merely because an endpoint is reachable. diff --git a/envs/harbor_env/README.md b/envs/harbor_env/README.md new file mode 100644 index 0000000000..3d7e54195b --- /dev/null +++ b/envs/harbor_env/README.md @@ -0,0 +1,565 @@ +--- +title: Harbor +emoji: ⚓ +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 8000 +--- + +# Harbor Environment + +**Train one policy against many coding agents.** Pick a Harbor dataset, pick an agent, pick a +sandbox, and get back the exact token ids and per-token logprobs of every model call the agent made, +plus the task's own reward. + +## Overview + +An agent harness is a moving part you probably do not want to own. opencode, codex, claude-code and +gemini-cli each have their own loop, their own tool surface and their own wire format, and a policy +trained against exactly one of them learns that one's habits. + +The usual cost of supporting several is one integration per agent. Here it is one integration total: + +| | | +|---|---| +| **16 harnesses** | validated end to end, across 4 wire dialects | +| **23 sandbox backends** | from Harbor, 4 with credential checks wired in | +| **any Harbor dataset** | HF repo, local directory, or Harbor registry name | + +All three are chosen **per rollout**, so one server covers the whole matrix and rotating the harness +during training is a config change rather than a new environment. + +Harbor supplies the tasks, sandboxes, agents and verifiers. This environment adds the OpenEnv +surface: dataset discovery over the Task API, one `run_rollout` MCP tool, a capture proxy, and a UI. + +### What you get back + +Every rollout carries the reward from the task's verifier and the full trace: each conversation with +its messages, and per turn the text, tool calls and finish reason. + +Against vLLM or SGLang you also get the training contract, per model call: + +```python +turn.prompt_token_ids # the engine's own tokenisation of everything before this turn +turn.completion_token_ids # what it sampled +turn.per_token_logps # the behaviour-policy logprob of each sampled token +``` + +Which of the two you got is on the result as `rollout_type` (`"train"` or `"eval"`) and +`capture_level`, decided by probing the endpoint before the server starts. Against a hosted provider +the token fields are empty and `rollout_type == "eval"`; nothing pretends otherwise, and +`to_turn_records` raises rather than handing a trainer empty lists. + +## The intercept + +The agent is a black box. It is a real CLI tool running in a sandbox, and it was never written with +training in mind. So instead of modifying it, an OpenAI-spec proxy is placed between it and your +model, and every call is recorded as it passes. + +``` +agent in a sandbox + | base URL points at the proxy, API key IS the capture session id + v +capture proxy --normalise to chat, ask for token ids--> your endpoint + ^ | + | replay in the agent's own dialect <----------------------+ + | + +-- every call becomes a node in a rollout graph, linked by token prefix + (by message prefix when the endpoint returns no token ids) +``` + +Three properties make this work across agents rather than for one: + +**Nothing is tokenised locally.** The engine tokenises each prompt in order to serve it and hands +back `prompt_token_ids`, so turn *k+1*'s prompt is by construction the canonical tokenisation of +everything before it, tool results included. Re-rendering a prompt offline with a chat template +drifts from what the model actually saw, and a prompt that differs by one token silently splits one +long conversation into several short ones. + +**Four wire dialects.** Coding agents did not converge on one API. chat-completions, OpenAI +Responses, Anthropic Messages and Google `generateContent` are all translated to a single upstream +shape and replayed in the dialect the agent expects, streaming included. That is what makes codex, +claude-code and gemini-cli work rather than only the chat-completions agents. + +**The API key is the session id.** One proxy serves many concurrent rollouts with no port each, and +a caller without a registered session is rejected, so the proxy is safe to expose to a sandbox. + +Turns are linked into a graph by **exact token prefix**: a call whose `prompt_token_ids` begin with +an existing node's full sequence becomes its child. Nothing else is consulted, because request ids +and timestamps are per-agent and the prefix is not. Conversations, retries and subagent branches +fall out of that for free, and a branch the agent abandoned is marked discarded so it is never +trained with the reward the main path earned. + +## Prerequisites + +You need an OpenAI-compatible endpoint. Which *kind* of rollout you get depends on what it can +return, and that is probed at startup rather than configured: + +| | eval | train | +|---|---|---| +| reward, `rewards` dict, step results | yes | yes | +| full trace: conversations, per-turn text, tool calls | yes | yes | +| `prompt_token_ids`, `completion_token_ids`, `per_token_logps` | no | yes | +| `contract.json`, TRL rollout func | no | yes | + +**For trainable rollouts** the endpoint must return token ids *and* the sampling distribution's +logprobs. Both are checked by probing, because both fail silently: + +```bash +vllm serve --return-tokens-as-token-ids --logprobs-mode processed_logprobs +# or SGLang built from git main (sgl-project/sglang#30917); no serving flag needed +``` + +Without them the endpoint answers every request normally and returns no token ids. Rollouts would +look perfect and contain nothing trainable, and that failure has no loud edge — so the level is +probed before the server binds a port, printed as `[EVAL ONLY]`, stamped on every result, and no +training contract is ever built from it. + +**For eval rollouts** any reachable OpenAI-spec endpoint works, including hosted ones: + +```bash +openenv harbor serve --llm-url https://api.openai.com/v1 --api-key $OPENAI_API_KEY --model gpt-5.6-sol +openenv harbor serve --llm-url https://router.huggingface.co/v1 --api-key $HF_API_KEY --model Qwen/Qwen3.6-35B-A3B +openenv harbor serve --llm-url https://api.anthropic.com/v1 --api-key $ANTHROPIC_API_KEY --model claude-sonnet-5 +``` + +Anthropic needs no `--auth-header`: its OpenAI-compatible `/v1/chat/completions` accepts +`Authorization: Bearer`. Its `/v1/models` does not — that route wants `x-api-key` *and* +`anthropic-version` — so the model list comes back empty and `--model` is required. An endpoint that +publishes no usable model list is not treated as unreachable for exactly this reason; the completion +probe is what decides. + +`--api-key` (or `$OPENENV_LLM_API_KEY`) authenticates the proxy to the endpoint; `--auth-header` +changes the header name when a provider wants something other than `Authorization: Bearer`. This is +not the key the agent receives — that one is a capture session id, minted per rollout — and it never +leaves the server process. + +A hosted provider also rejects parameters a local vLLM accepts, per model rather than per endpoint. +Every current OpenAI model refuses `max_tokens` (wants `max_completion_tokens`), any `temperature` +other than 1, and `logprobs` outright, while opencode, codex and qwen-coder all send the first two. +`gpt-5.6` additionally refuses **function tools** on `/v1/chat/completions` unless +`reasoning_effort` is `"none"` — which for a coding agent is every call that matters; left unhandled +it presents as a rollout that makes one model call and then idles until the agent timeout. + +The proxy reads each such 400, applies the one edit the provider names, retries, and caches the fix — +so the agents work unchanged. Every applied fix is reported on the result, at startup and in the UI, +because these are changed experiments rather than cosmetic rewrites: dropping `temperature` alters +the sampling distribution, and `reasoning_effort: "none"` turns reasoning off. For `gpt-5.6` the +better answer is the Responses route the error message itself recommends. + +### What startup checks about *agents*, not just capture + +The capture probe sends no tools. Every validated harness sends a tool manifest on every call, so a +second, non-fatal probe asks the way a harness asks and reports: + +| finding | meaning | +|---|---| +| `tools: ok` | a tool call came back; agents can work here | +| `no_tool_calling` (FATAL) | the endpoint refuses a manifest outright; no agent rollout is possible | +| `no_tool_call_emitted` (WARN) | manifest accepted, but it answered in prose | +| `behaviour_changed` (WARN) | a compat fix changed how the MODEL behaves, not just a field name | + +The last one is the reason this probe exists. `gpt-5.6` accepts function tools on +`/v1/chat/completions` only if `reasoning_effort` is `"none"`, and with reasoning off it emits one +valid tool call and then agentic loops die: goose and codex each managed a single model call and 0/3 +tasks, while both scored 3/3 against a non-reasoning model on the same endpoint. Without a tool in the +probe body that demand is never made, so `harbor info` looked healthy and the failure only appeared +minutes into a rollout. Now it is reported before a sandbox is booted. + +Truncation is deliberately treated as inconclusive rather than a failure: a reasoning model spends +output tokens thinking before it calls anything, and a small cap made `Qwen3.6-35B-A3B` look +tool-incapable when it in fact worked with all 16 harnesses. + +### Why `--logprobs-mode processed_logprobs` is not optional + +`token_ids` comes from the `return_token_ids` **request** parameter, not from either serving flag, so +a vLLM started with neither flag still returns aligned, negative, correctly-counted logprobs and would +grade as fully trainable. But vLLM's `logprobs_mode` defaults to `raw_logprobs` — the values *before* +temperature and top-k/top-p are applied — and GRPO's importance ratio needs the logprob under the +policy that actually sampled the token. + +Startup measures which you have, rather than inferring it: the gap between the top two logprobs is +requested at temperature 1.0 and 2.0. Processed logprobs are `logsoftmax(logits / T)`, so the gap +scales by `1/T` while the normalising constant cancels; raw logprobs cannot move at all. Measured on +two live Qwen3.5-4B servers: + +| | gap @T=1.0 | gap @T=2.0 | verdict | +|---|---|---|---| +| `--logprobs-mode processed_logprobs` | 6.7500 | 3.3750 | `processed` | +| default | 6.7500 | 6.7500 | `raw` | + +A measured `raw` endpoint is downgraded to EVAL rather than refused — it is still a perfectly good +eval backend — and `OPENENV_ALLOW_RAW_LOGPROBS=1` overrides that if you know better than the probe. +The gap is compared rather than the values themselves because a data-parallel engine answers +consecutive calls from different replicas; comparing values directly misread one such engine. + +Install the extra, which brings Harbor and every sandbox backend: + +```bash +pip install "openenv[harbor]" # needs Python 3.12 or newer +``` + +## Quick Start + +### 1. See what this machine can do + +```bash +openenv harbor info \ + --llm-url $LLM \ + --dataset AdithyaSK/data_agent_rl_environment_eval +``` + +``` +llm Qwen/Qwen3.5-9B [ok] +sandboxes 2 of 4 usable + [ok] e2b + [ok] modal + [--] docker Docker daemon is not running. + [--] daytona SDK not installed (daytona). +datasets 1 split(s), 366 tasks +harnesses 16 validated of 30 known +``` + +Read-only, boots nothing. It tells you which sandboxes have working credentials **and** an +importable SDK, so you find out here rather than 90 seconds into a rollout. + +### 2. Run one rollout, no server + +```bash +openenv harbor rollout \ + --llm-url $LLM \ + --dataset AdithyaSK/data_agent_rl_environment_eval \ + --task-index 0 --harness opencode --sandbox e2b \ + --out rollout.json +``` + +``` +[opencode / e2b] task 0: 0000_369_369503_qa_1 ... + ok reward=1.00 turns=9 roots=2 multi-turn tokens=1043 atif=match 48s +``` + +This path involves no env server, which makes it the one to reach for when something breaks: if +`rollout` works and `serve` does not, the fault is in the serving layer and nothing below it. + +### 3. Serve it + +```bash +openenv harbor serve --llm-url $LLM --dataset org/train,org/eval +``` + +You get a Task API for discovery, one long-running `run_rollout` MCP tool, and a UI at `/web`. + +```python +from harbor_env import HarborEnv + +with HarborEnv(base_url="http://localhost:8000") as env: + split = env.splits()[0]["name"] + result = env.run_rollout(split=split, task_index=0, harness="opencode", sandbox="e2b") + + print(result.reward, result.n_turns) + for turn in result.turns: + print(len(turn.completion_token_ids), sum(turn.per_token_logps)) +``` + +`harness` and `sandbox` are per call, so consecutive rollouts against the same server can use +different agents and different backends. + +## CLI reference + +Four commands. Every flag below is the complete set, with its type and default. `openenv harbor + --help` prints the same thing. + +Exit codes: + +| code | meaning | +|---|---| +| `0` | success | +| `1` | ran, but failed. `rollout` returns this if **any** rollout in the batch was unusable | +| `2` | usage error: a missing or invalid flag. Nothing ran | + +The `1` and `2` split matters if you are scripting this: `2` means the command never started, so +retrying it unchanged will fail the same way. + +### `openenv harbor info` + +Report what this machine can run. Read-only: boots no sandbox, starts no server, makes no rollout. + +| flag | type | default | meaning | +|---|---|---|---| +| `--llm-url` | str | `""` | OpenAI-spec endpoint. Optional here; without it the LLM section is skipped and the rest still reports | +| `--model` | str | `""` | Served model id. Auto-detected when the endpoint serves exactly one | +| `--dataset` | str | none | Dataset spec. Repeatable, or comma-separated | +| `--env-file` | path | `""` | dotenv with provider credentials, loaded before the checks | +| `--verbose` | flag | off | List all 30 harnesses, not only the 16 validated | +| `--json` | flag | off | Emit machine-readable JSON instead of the text report | + +```bash +openenv harbor info --llm-url $LLM --dataset org/tasks --json +``` + +The JSON has four top-level keys: `llm`, `sandboxes`, `datasets`, `harnesses`. Each sandbox entry is +`{name, available, detail}`, so a script can select a backend without parsing prose: + +```bash +openenv harbor info --llm-url $LLM --json \ + | jq -r '.sandboxes[] | select(.available) | .name' +``` + +### `openenv harbor rollout` + +Run rollouts with no env server involved. This is the debugging path: if `rollout` works and `serve` +does not, the fault is in the serving layer and nothing below it. + +| flag | type | default | meaning | +|---|---|---|---| +| `--llm-url` | str | **required** | OpenAI-spec endpoint. No default and no env fallback, on purpose | +| `--dataset` | str | **required** | Dataset spec. Only the first is used by this command | +| `--task-index` | int | `0` | Index into the split. Stable: index is a task's identity | +| `-n`, `--n-tasks` | int | `1` | Run this many consecutive tasks from `--task-index` | +| `--harness` | str | `opencode` | A validated seam name, or `module:Class` for your own agent | +| `--sandbox` | str | `e2b` | Harbor environment type | +| `--model` | str | `""` | Served model id. Auto-detected when unambiguous | +| `--port` | int | `8100` | Local port for the capture proxy. One per concurrent process | +| `--expose` | str | `gradio` | How the sandbox reaches the proxy: `gradio`, `cloudflare`, `direct` | +| `--reward-key` | str | `""` | Which reward key is the training signal, for multi-reward tasks | +| `--trials-dir` | path | tmp | Where Harbor writes trial artifacts | +| `--keep-sandbox` | flag | off | Leave sandboxes alive for debugging | +| `--force-build` | flag | off | Rebuild the sandbox image, bypassing the content-hash cache | +| `--env-file` | path | `""` | dotenv with provider credentials | +| `--out` | path | `""` | Write the full result JSON, token ids and logprobs included | + +```bash +openenv harbor rollout --llm-url $LLM --dataset org/tasks \ + --task-index 0 -n 5 --harness codex --sandbox modal --out results.json +``` + +`-n` runs tasks sequentially in one process, reusing one proxy and one forward. To parallelise, run +several processes and **give each its own `--port`**. Two processes sharing a port is refused with +an error naming the process that holds it. + +Use `--force-build` when a task has never been built on this account, or when a cached image has +drifted because the task pins its dependencies loosely. + +### `openenv harbor serve` + +Start the env server: Task API for discovery, one long-running `run_rollout` MCP tool, and a UI at +`/web`. + +| flag | type | default | meaning | +|---|---|---|---| +| `--llm-url` | str | **required** | OpenAI-spec endpoint | +| `--dataset` | str | none | Dataset specs to serve as splits. Repeatable | +| `--model` | str | `""` | Served model id | +| `--host` | str | `0.0.0.0` | Bind address | +| `--port` | int | `8000` | Env server port. Faces the trainer and the browser | +| `--capture-port` | int | `8100` | Capture proxy port. Faces the sandbox | +| `--expose` | str | `gradio` | How the sandbox reaches the proxy | +| `--env-file` | path | `""` | dotenv with provider credentials | + +| `--api-key` | str | `$OPENENV_LLM_API_KEY` | Credential for the endpoint, for a hosted provider | +| `--auth-header` | str | `Authorization` | Header to send it under, e.g. `x-api-key` | + +Refuses to start only if the endpoint is unreachable. One that cannot return token ids starts as an eval deployment and says so. + +### `openenv harbor push` + +Deploy the same server to a Hugging Face Space. + +| flag | type | default | meaning | +|---|---|---|---| +| `--llm-url` | str | **required** | Endpoint the deployed Space will use | +| `--repo-id` | str | **required** | Target Space, e.g. `you/harbor-env` | +| `--dataset` | str | none | Dataset specs. Repeatable | +| `--model` | str | `""` | Served model id | +| `--bucket` | str | Space name | Storage bucket holding the task suites. `none` disables the mount and downloads instead | +| `--hardware` | str | `""` | Space hardware, e.g. `cpu-basic` | +| `--private` | flag | off | Create it private. Rollouts then cannot work, see below | +| `--recreate` | flag | off | Delete the Space first, then deploy fresh | +| `--dry-run` | flag | off | Print exactly what would be sent and stop | +| `--env-file` | path | `""` | dotenv whose provider keys become Space **secrets** | + +```bash +openenv harbor push --llm-url $LLM --dataset org/train,org/eval \ + --repo-id you/harbor-env --env-file .env --dry-run +``` + +`--private` is supported but rollouts will not work on a private Space: the capture proxy is served +at `/capture`, and a private Space requires an auth header the sandboxed agent does not +send. Use it only to park a deployment. + +## Supported harnesses + +16 of the 30 known agents are validated end to end. "Validated" means a real rollout produced token +ids and logprobs, and where the agent emits a trajectory, its own record agreed with the capture. + +| harness | dialect | runs | +|---|---|---| +| `opencode` | chat-completions | in sandbox | +| `goose` | chat-completions | in sandbox | +| `qwen-coder` | chat-completions | in sandbox | +| `swe-agent` | chat-completions | in sandbox | +| `mini-swe-agent` | chat-completions | in sandbox | +| `openhands-sdk` | chat-completions | in sandbox | +| `openclaw` | chat-completions | in sandbox | +| `hermes` | chat-completions | in sandbox | +| `kimi-cli` | chat-completions | in sandbox | +| `pi` | chat-completions | in sandbox | +| `vibe` | chat-completions | in sandbox | +| `terminus-2` | chat-completions | **host side** | +| `codex` | OpenAI Responses | in sandbox | +| `trae-agent` | OpenAI Responses | in sandbox | +| `claude-code` | Anthropic Messages | in sandbox | +| `gemini-cli` | Google generateContent | in sandbox | + +Supporting four dialects rather than chat-completions alone is what makes the last four rows work. + +`terminus-2` runs in the server process rather than inside the sandbox, so it reaches the proxy on +localhost and needs no public URL. + +The other 14 known agents have a seam but are untested; run `openenv harbor info --verbose` to list +them. Anything Harbor supports can be reached with `--harness module:Class`. + +## Supported sandboxes + +`openenv harbor info` checks these four by default and reports why any is unusable: + +| sandbox | credentials | validated | +|---|---|---| +| `e2b` | `E2B_API_KEY` | yes, extensively | +| `modal` | `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET`, or `~/.modal.toml` | yes, extensively | +| `docker` | none, but the daemon must be running | works, not swept | +| `daytona` | `DAYTONA_API_KEY`, or `DAYTONA_JWT_TOKEN` + `DAYTONA_ORGANIZATION_ID` | not swept | + +e2b and modal were compared on identical tasks and came out indistinguishable, which is the check +that matters: a backend-specific capture bug is exactly what a single-backend test hides. + +Harbor registers 23 backends in total. Any of them can be passed to `--sandbox`; the four above are +the ones with a credential check wired in. + +## Environment details + +### Where the proxy runs + +Locally there are two ports: the env server faces the trainer and the browser, the capture proxy +faces the sandbox and is the only one published. Sharing one port would expose the env server the +moment the proxy became reachable. + +Hosted, that inverts. A Space has one port and one public URL, so the proxy is mounted on the env +server's own app at `/capture` and nothing is forwarded. It still rejects callers without a +registered session id, which is what keeps a public mount from being an open relay. + +### Sandboxes and providers + +Two different things share the word "sandbox", and mixing them up is a common early confusion: + +- **Harbor backends** are where the *agent* runs. That is what `--sandbox` selects. +- **OpenEnv providers** (`local_docker`, `hf_sandbox`, `modal`, `aca`, ...) host the *env server* + itself and have no `exec`. + +A backend counts as usable only if its class imports **and** Harbor's own preflight passes. +Credentials alone are not enough: a provider with valid keys but no SDK installed would otherwise +report available and fail at rollout time. + +### Rewards + +The verifier produces a dictionary. OpenEnv wants one number. The dictionary is forwarded unchanged +and the scalar is chosen by an explicit rule: + +1. one key, use it +2. a key named `reward`, use it +3. otherwise fail and ask for `--reward-key` + +Combining several keys automatically would be inventing reward semantics, so it refuses instead. +All shaping belongs in the trainer. + +**`reward=None` is not zero.** It means the verifier never ran. A dead sandbox scored as zero looks +like a wrong answer, which is how an infrastructure failure gets mistaken for a model result. + +### Reading a result + +| field | meaning | +|---|---| +| `ok` | the rollout is usable. `False` means something failed, and `error` says what | +| `reward` | the verifier's number, or `None` if it never ran | +| `n_turns` | model calls captured | +| `n_roots` | independent conversations. More than one means subagents or auxiliary calls | +| `turns[]` | per-call token ids, logprobs, text and tool calls | +| `conversations[]` | the full message list per conversation, system prompt included | +| `atif` | `match`, `MISMATCH`, or `none`. See below | +| `findings` | warnings worth reading before training on the rollout | + +`atif` is an independent cross-check. Harbor's own trajectory file records what the *harness* +thought happened; the capture records what crossed the wire. Two measurements of the same rollout +through completely different paths. `match` means they agree call for call. + +**A failed rollout returns a result, never an exception.** That is deliberate. In an in-process +design one exception on one rank hangs every rank at the distributed barrier, so behind an HTTP +boundary a failure has to come back as `ok=False` instead. + +## Deploying to Hugging Face Spaces + +```bash +openenv harbor push \ + --llm-url $LLM \ + --dataset org/train,org/eval \ + --repo-id you/harbor-env \ + --env-file .env +``` + +Configuration travels as Space variables, provider credentials as Space secrets. Add `--dry-run` +to print exactly what would be sent first, and `--recreate` to delete and redeploy for a clean test. + +Two details that matter: + +**Task suites are mounted, not downloaded.** A Harbor suite is thousands of small files and Space +disk is ephemeral, so a download is re-paid on every restart. `push` syncs the suites into a storage +bucket named after the Space and mounts it at `/data`. The copy is server side, and re-running +`push` copies only what is new. + +**The Space must be public.** The capture proxy is served at `/capture`, and a private +Space requires an auth header that the agent inside the sandbox does not send. This is safe because +the proxy rejects any caller without a registered session id, so a public mount is not an open +relay. + +## Troubleshooting + +**Rollout finishes with zero model calls.** The agent never reached the proxy. Usually the endpoint +URL is wrong, the model name did not resolve, or auth was rejected. Check the `findings` field, +which names the likely cause. + +**`llm ... [FAILED]` at startup.** The endpoint is unreachable — wrong URL, wrong model name, or +a missing `--api-key` for a provider that needs one. The findings name which. + +**`llm ... [EVAL ONLY]` at startup.** The endpoint answers but returns no token ids, so rollouts +carry the reward and the trace and nothing trainable. Expected for OpenAI, Anthropic and HF +Inference Providers. If you meant to train, restart the engine with +`--return-tokens-as-token-ids --logprobs-mode processed_logprobs`, or build SGLang from git main — +released SGLang carries only `return_prompt_token_ids`, which is not enough. + +**`contract.json` is missing after a rollout.** The rollout was an eval rollout; check +`rollout_type` and `capture_level` on the result. There is deliberately no all-zero contract to +download, because a file named `contract.json` containing no contract is more convincing than an +empty list. + +**A sandbox shows `[--]` in `info`.** The detail column says why, and it is usually a missing +credential or a missing SDK. Install everything with `pip install "openenv[harbor]"`. + +**`atif=none`.** That harness writes no trajectory file, so no cross-check is possible. Capture is +unaffected. + +**Many roots for one rollout.** Normal for agents that run subagents or auxiliary calls. Each root +is a separate conversation, and only agent conversations are counted as trainable. + +**Exit code 137.** The agent was killed inside the sandbox, almost always by the OOM killer on a +large input. That is a task failure, not a capture failure. + +## References + +- [Harbor](https://github.com/laude-institute/harbor), which provides the datasets, sandboxes, + agents and verifiers +- [Polar](https://github.com/NVIDIA-NeMo/ProRL-Agent-Server) + ([paper](https://arxiv.org/abs/2605.24220)), the black-box approach this capture layer follows, + and the source of the vendored dialect transformers +- [verifiers](https://github.com/willccbb/verifiers), whose `Dialect` model informed the auxiliary + route and streaming handling diff --git a/envs/harbor_env/__init__.py b/envs/harbor_env/__init__.py new file mode 100644 index 0000000000..88c078e7d6 --- /dev/null +++ b/envs/harbor_env/__init__.py @@ -0,0 +1,33 @@ +"""harbor_env: run Harbor tasks with token-level capture. + +The implementation lives in `openenv.harbor` and `openenv.core.harness.capture`; this package is +deployment packaging only (manifest, Dockerfile, ASGI entry point). The client and result models are +re-exported here so `from harbor_env import HarborEnv` works, matching every other environment. + +Examples: + +```python +from harbor_env import HarborEnv + +with HarborEnv(base_url="http://localhost:8000") as env: + split = env.splits()[0]["name"] + result = env.run_rollout(split=split, task_index=0, harness="opencode", sandbox="e2b") + print(result.reward, result.n_turns) +``` +""" + +from openenv.harbor.client import HarborEnv +from openenv.harbor.models import ( + HarborConversation, + HarborRolloutResult, + HarborTaskRef, + HarborTurn, +) + +__all__ = [ + "HarborEnv", + "HarborConversation", + "HarborRolloutResult", + "HarborTaskRef", + "HarborTurn", +] diff --git a/envs/harbor_env/client.py b/envs/harbor_env/client.py new file mode 100644 index 0000000000..b07299548b --- /dev/null +++ b/envs/harbor_env/client.py @@ -0,0 +1,5 @@ +"""Typed client for a deployed harbor_env.""" + +from openenv.harbor.client import HarborEnv + +__all__ = ["HarborEnv"] diff --git a/envs/harbor_env/harness.py b/envs/harbor_env/harness.py new file mode 100644 index 0000000000..4eaa5de66f --- /dev/null +++ b/envs/harbor_env/harness.py @@ -0,0 +1,585 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Loop-owning sessions backed by a deployed `harbor_env` server. + + factory = HarborSessionFactory(server_url="http://harbor:8000", split="...", llm_url="http://vllm:8000") + session = factory.create(prompt) # prompt carries the task's instruction + session.wait_for_completion() # the server runs the agent; this blocks + trace = session.fetch_proxy_trace() # per-turn records for a trainer + reward = session.verify(transcript) # the task's own verifier, forwarded + +This is the shape TRL's `HarnessRolloutWorker` consumes in loop-owning mode, and it is deliberately +the same shape `opencode_env.harness` offers — so the training script is the stock one and nothing has +to be added to TRL. The difference is where the work happens: opencode_env runs the agent locally, +while this hands a task to a server that owns the sandbox, the agent and the capture proxy. The +trainer needs no sandbox credentials, no harness installed, and no capture plumbing of its own. + +WHY THE ENGINE IS AN ARGUMENT HERE. `run_rollout` takes `llm_url` per call, so a trainer points the +server at the vLLM it is currently syncing weights into. The server probes that engine and the tier +follows from what it can return: token ids plus processed logprobs give a trainable rollout, anything +less gives an eval one. That is what lets a training run and an eval run share one server. + +The trace carries the engine's prompt ids, sampled ids, behavior logprobs and full per-token loss +mask. No prompt is reconstructed locally. For training, pass `sampling={"temperature": ...}` with +the trainer's temperature: the session pins the full-vocabulary policy across all harnesses. +""" + +from __future__ import annotations + +import hashlib +import logging +from typing import Any + +from openenv.core.env_server.mcp_types import Tool +from openenv.core.harness import ( + Message, + ResourceSession, + ResourceSessionFactory, + ToolResult, + VerifyResult, +) +from openenv.core.harness.capture.upstream import training_sampling +from openenv.harbor.client import HarborEnv +from openenv.harbor.contract import to_trace_entries +from openenv.harbor.models import HarborRolloutResult + +logger = logging.getLogger(__name__) + + +def instruction_id(instruction: str) -> str: + """Stable id for a task, from its instruction text. + + TRL's loop-owning path forwards only `prompt` to the factory — extra dataset columns never arrive, + and `seed` is the group counter rather than a dataset index. So the task has to be recoverable from + the prompt itself. Hashing the instruction keeps the prompt a real prompt (readable in logged + completions, and what the agent is actually asked to do) instead of smuggling an index through it. + """ + return hashlib.sha1(instruction.strip().encode()).hexdigest() + + +def _decoded_arguments(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Tool-call `arguments` as a mapping rather than a JSON string, for chat-template rendering. + + The wire format keeps `arguments` as a string, which is what the OpenAI schema specifies. XML-style + templates — Qwen3.5's among them — iterate it, and iterating a string raises + `Can only get item pairs from a mapping`, so a render that should be a measurement becomes a crash. + + TRL does the same thing before rendering (`_decode_tool_call_arguments`), so skipping it here would + also mean measuring something other than what TRL actually feeds the template. + """ + import json as _json + + out: list[dict[str, Any]] = [] + for message in messages: + calls = message.get("tool_calls") + if not calls: + out.append(message) + continue + decoded = [] + for call in calls: + function = call.get("function") or {} + arguments = function.get("arguments", call.get("arguments")) + if not isinstance(arguments, str): + decoded.append(call) + continue + try: + parsed = _json.loads(arguments or "{}") + except ValueError: + # Not JSON. Pass it through: a template that cannot render it should fail loudly + # rather than have this function invent a shape. + decoded.append(call) + continue + new_call = dict(call) + if call.get("function"): + new_call["function"] = {**function, "arguments": parsed} + else: + new_call["arguments"] = parsed + decoded.append(new_call) + out.append({**message, "tool_calls": decoded}) + return out + + +def measure_prompt_skew( + result: HarborRolloutResult, tokenizer, **template_kwargs +) -> dict[str, Any]: + """How exactly a local re-render reproduces the engine's own prompt, per turn. + + The number nobody has by default. TRL re-renders prompts because `TraceEntry` carries no prompt + ids; this says what that costs for a given model and harness instead of assuming it is free or + assuming it is fatal. `exact_match_frac == 1.0` means the loop-owning path is lossless here. + + Returns: + `dict` with keys: + - `turns` (`int`): turns compared. + - `exact_match_frac` (`float`): fraction whose re-render matched token for token. + - `worst_common_prefix` (`int`): shortest agreeing prefix seen, in tokens. + - `length_deltas` (`list[int]`): re-rendered length minus the engine's, per turn. + """ + compared = 0 + exact = 0 + worst_prefix = -1 + deltas: list[int] = [] + for turn in result.turns or []: + if turn.discarded or not turn.prompt_token_ids or not turn.request_messages: + continue + rendered = tokenizer.apply_chat_template( + _decoded_arguments(turn.request_messages), + tools=turn.request_tools, + add_generation_prompt=True, + tokenize=True, + return_dict=False, + **template_kwargs, + ) + engine = list(turn.prompt_token_ids) + compared += 1 + deltas.append(len(rendered) - len(engine)) + if rendered == engine: + exact += 1 + prefix = 0 + for a, b in zip(rendered, engine): + if a != b: + break + prefix += 1 + worst_prefix = prefix if worst_prefix < 0 else min(worst_prefix, prefix) + return { + "turns": compared, + "exact_match_frac": (exact / compared) if compared else 0.0, + "worst_common_prefix": max(worst_prefix, 0), + "length_deltas": deltas, + } + + +class HarborSession(ResourceSession): + """One Harbor rollout, run by the server, read back here. + + Created idle rather than already-running: `wait_for_completion` is what starts it. The server call + is a single blocking request that boots the sandbox, runs the agent to completion and grades the + workspace, so there is nothing useful to do between `create` and `wait`. + """ + + def __init__( + self, + *, + env: HarborEnv, + split: str, + task_index: int, + instruction: str, + harness: str, + sandbox: str, + llm_url: str, + model: str, + reward_key: str = "", + api_key: str = "", + auth_header: str = "", + agent_timeout_sec: float = 0.0, + agent_step_limit: int = 0, + sampling: dict[str, Any] | None = None, + owns_env: bool = False, + ) -> None: + self._env = env + # Whether closing this session should close the client too. True when the factory handed this + # session a client of its own, which it does for every rollout — see `create`. + self._owns_env = owns_env + self._split = split + self._task_index = task_index + self._instruction = instruction + self._harness = harness + self._sandbox = sandbox + self._llm_url = llm_url + self._model = model + self._reward_key = reward_key + self._api_key = api_key + self._auth_header = auth_header + self._agent_timeout_sec = agent_timeout_sec + self._agent_step_limit = agent_step_limit + self._sampling = training_sampling(sampling) if sampling is not None else None + self.result: HarborRolloutResult | None = None + + # --- ResourceSession ----------------------------------------------------- + + def initial_messages(self) -> list[Message]: + return [{"role": "user", "content": self._instruction}] + + def list_tools(self) -> list[Tool]: + # The agent owns its own tool loop inside the sandbox; none are exposed to the harness. + return [] + + def call_tool(self, name: str, arguments: dict[str, Any]) -> ToolResult: + return ToolResult( + error=( + "HarborSession does not expose external tool calls; the agent owns its own loop " + "inside the server's sandbox." + ) + ) + + def verify( + self, transcript: list[Message], final_state: Any | None = None + ) -> VerifyResult: + """The task's own verifier score, as the server reported it. + + Never recomputed here, and never defaulted to 0: a rollout that did not run has no grade, and + scoring that as zero teaches a policy that a crashed rollout is as good as a wrong answer. + """ + if self.result is None: + return VerifyResult(env_reward=None, done=True) + return VerifyResult(env_reward=self.result.reward, done=True) + + def close(self) -> None: + # The server owns the sandbox and tears it down with the trial, so there is nothing to release + # there. The CLIENT is ours, though: one websocket per session, and leaving it open holds an + # env session on the server until `max_concurrent_envs` is exhausted. + if self._owns_env and self._env is not None: + try: + self._env.close() + except Exception: # noqa: BLE001 - a client that will not close must not fail a rollout + logger.warning("closing this session's client failed", exc_info=True) + self._env = None + + # --- loop-owning extensions --------------------------------------------- + + def wait_for_completion(self, timeout_s: float | None = None) -> int: + """Run the rollout and block until the server is done. Returns a process-style exit code. + + `0` means the server reported a usable rollout. Non-zero means it did not, and the reason is on + `self.result.error` — returned rather than raised, because a rollout failing is an outcome the + trainer has to score around, while an exception here would take down the rollout loop and with + it every training rank waiting on the next batch. + + `timeout_s` overrides agent execution time, as `agent_timeout_sec` does. + Harbor's sandbox setup and teardown have separate timeouts; this is not + an end-to-end RPC deadline. + """ + try: + self.result = self._env.run_rollout( + split=self._split, + task_index=self._task_index, + harness=self._harness, + sandbox=self._sandbox, + reward_key=self._reward_key, + llm_url=self._llm_url, + model=self._model, + api_key=self._api_key, + auth_header=self._auth_header, + # `is not None`, not `or`: 0 is a documented value meaning "defer to the task + # file", and `or` silently replaces it with the factory default. `OpenCodeSession` + # takes the same care for the same reason. + agent_step_limit=self._agent_step_limit, + **({"sampling": self._sampling} if self._sampling is not None else {}), + agent_timeout_sec=( + timeout_s if timeout_s is not None else self._agent_timeout_sec + ), + ) + except Exception as exc: # noqa: BLE001 - see the docstring + logger.warning( + "harbor rollout call failed for %s[%d]: %s: %s", + self._split, + self._task_index, + type(exc).__name__, + exc, + ) + self.result = None + return 1 + if not self.result.ok: + logger.warning( + "harbor rollout %s[%d] not ok: %s", + self._split, + self._task_index, + self.result.error, + ) + else: + self._warn_if_oversized() + return 0 if self.result.ok else 1 + + # A packed row much beyond this has, in practice, meant an OOM in the loss step rather than a + # slow one. Deliberately a log line and not a rejection: what to do about a huge rollout is the + # trainer's decision, and dropping it here would silently shrink a GRPO group. + OVERSIZED_TRAINABLE_TOKENS = 250_000 + + def _warn_if_oversized(self) -> None: + """Name the cause of an OOM that otherwise surfaces in the wrong place. + + AsyncGRPO packs every turn of a rollout into ONE training row, and each turn re-sends the + whole conversation, so packed length grows with the SQUARE of the turn count. When that row is + too large the failure appears much later as `CUBLAS_STATUS_ALLOC_FAILED` inside the chunked LM + head's backward — an OOM wearing a cuBLAS mask, in a stack frame that mentions neither the + rollout nor its turn count. This line is what connects the two. + """ + result = self.result + if result is None: + return + tokens = getattr(result, "n_trainable_tokens", 0) or 0 + if tokens > self.OVERSIZED_TRAINABLE_TOKENS: + logger.warning( + "harbor rollout %s[%d] packs %d trainable tokens across %d turns; a row this large " + "has meant an OOM in the loss step. Cap the agent with agent_step_limit.", + self._split, + self._task_index, + tokens, + getattr(result, "n_turns", -1), + ) + + def fetch_proxy_trace(self) -> list[dict[str, Any]]: + """Per-turn captured records, in TRL's `TraceEntry` shape. + + Empty for an eval rollout, and that is the point: an eval endpoint yields a reward and a + readable trace but no token fields, so there is nothing to train on and this says so by being + empty rather than by handing back rows of zeros. + """ + if self.result is None: + return [] + if self.result.rollout_type != "train": + logger.warning( + "rollout is %s (capture_level=%s), so it carries no trainable turns", + self.result.rollout_type, + self.result.capture_level, + ) + return [] + return to_trace_entries(self.result) + + +class HarborSessionFactory(ResourceSessionFactory[HarborSession]): + """Turns a prompt into a Harbor rollout on a deployed server. + + Args: + server_url (`str`): + Root of a running `harbor_env` server, e.g. `http://localhost:8000`. + split (`str`, *optional*): + Which dataset the server should draw tasks from. Defaults to the server's first. + llm_url (`str`, *optional*): + Engine for these rollouts — for training, the same vLLM the trainer syncs weights into, + which is what makes the rollouts on-policy. Omit to use the server's default engine. + harness (`str`, *optional*, defaults to `"opencode"`): + Which agent runs in the sandbox. + sandbox (`str`, *optional*, defaults to `"e2b"`): + Harbor sandbox backend. + agent_step_limit (`int`, *optional*, defaults to `0`): + Model-call budget at the proxy, including auxiliary calls. Also sets a native step + limit when supported. `0` leaves model calls uncapped. + agent_timeout_sec (`float`, *optional*, defaults to `600.0`): + Override the agent execution timeout. Sandbox setup has separate timeouts. + sampling (`dict`, *optional*): + Explicit full-vocabulary training policy with the trainer's temperature. Validated + before opening clients and forwarded on every session, regardless of harness. + + Examples: + + ```python + factory = HarborSessionFactory( + server_url="http://localhost:8000", + split="AdithyaSK/data_agent_rl_environment_train", + llm_url="http://localhost:8001", + model="Qwen/Qwen3.5-2B", + ) + ``` + """ + + def __init__( + self, + server_url: str, + *, + split: str = "", + llm_url: str = "", + model: str = "", + harness: str = "opencode", + sandbox: str = "e2b", + reward_key: str = "", + api_key: str = "", + auth_header: str = "", + agent_timeout_sec: float = 600.0, + agent_step_limit: int = 0, + sampling: dict[str, Any] | None = None, + num_tasks: int | None = None, + indices: list[int] | None = None, + max_message_size_mb: float = 4096.0, + ) -> None: + self.server_url = server_url.rstrip("/") + self.harness = harness + self.sandbox = sandbox + self.llm_url = llm_url + self.model = model + self.reward_key = reward_key + self.api_key = api_key + self.auth_header = auth_header + self.agent_timeout_sec = agent_timeout_sec + self.agent_step_limit = agent_step_limit + self.sampling = training_sampling(sampling) if sampling is not None else None + self._num_tasks = num_tasks + # Specific tasks, rather than the first N of the split. Which tasks a group trains on decides + # whether it can learn anything at all: a task every generation solves and one none solves both + # give reward_std 0. The band that splits has to be chosen per model — the suite's own + # difficulty numbers were measured with a different harness and point the wrong way here. + # `is not None`, not truthiness: an EMPTY list means a caller's selection matched nothing, + # and falling back to "all tasks" there would silently train on the whole split instead of + # saying so. Only an omitted argument means "no selection". + if indices is not None and not list(indices): + raise ValueError( + "indices is empty: no tasks would be selected. Pass None to use the whole split." + ) + self._indices = list(indices) if indices is not None else None + # A rollout result is quadratic in turns (each turn carries its whole prompt), so the 100 MB + # default is reachable: a 262-turn rollout exceeded it and closed the connection. + self._max_message_size_mb = max_message_size_mb + self._env: HarborEnv | None = None + self._split = split + self._by_instruction: dict[str, int] = {} + self._tasks: list[dict[str, Any]] = [] + + def __getstate__(self) -> dict[str, Any]: + """Drop the live client when this factory is pickled. + + TRL spawns its rollout loop in a separate process and pickles the factory into it. Building + the dataset in the parent calls `prompt_rows()` -> `tasks()` -> `_client()`, which binds an + httpx client and a websocket — so without this the pickle either fails outright or the child + inherits a connection owned by another process and every rollout dies on it. + + The task list and the instruction map are kept: they are plain data, they cost a round trip to + rebuild, and the child needs the same mapping the parent's dataset was built from. + """ + state = dict(self.__dict__) + state["_env"] = None + return state + + # Built lazily so it is created in whichever process actually uses it, and dropped on pickling by + # `__getstate__` above. + def _client(self) -> HarborEnv: + if self._env is None: + self._env = HarborEnv(base_url=self.server_url) + if not self._split: + splits = self._env.splits() + if not splits: + raise RuntimeError(f"{self.server_url} serves no splits") + self._split = splits[0]["name"] + return self._env + + def new_client(self) -> HarborEnv: + """A fresh client for one rollout. Overridable so a caller can substitute a transport. + + Separate from `_client()`, which is the factory's own long-lived connection for task metadata. + """ + return HarborEnv( + base_url=self.server_url, max_message_size_mb=self._max_message_size_mb + ) + + def tasks(self) -> list[dict[str, Any]]: + """The tasks this factory can run, fetched once from the server.""" + if not self._tasks: + env = self._client() + total = env.num_tasks(self._split) + if self._indices is not None: + bad = [i for i in self._indices if not 0 <= i < total] + if bad: + raise IndexError( + f"index(es) {bad[:5]} out of range for {self._split!r} ({total} tasks)" + ) + # Normalised the same way as the range branch below: `get_task` returns a + # `HarborTaskRef` model while `get_task_range` returns dicts, and everything after + # this point does `task.get(...)`. + self._tasks = [ + _as_dict(env.get_task(self._split, i)) for i in self._indices + ] + else: + stop = min(total, self._num_tasks) if self._num_tasks else total + self._tasks = [ + _as_dict(t) for t in env.get_task_range(self._split, 0, stop) + ] + self._by_instruction = {} + collisions: dict[str, int] = {} + for i, task in enumerate(self._tasks): + key = instruction_id(task.get("instruction") or "") + index = int(task.get("index", i)) + if key in self._by_instruction: + collisions[key] = collisions.get(key, 1) + 1 + continue + self._by_instruction[key] = index + if collisions: + # Last-write-wins here would be invisible and wrong: two tasks with identical + # instructions produce two dataset rows that both resolve to one index, so a group + # trains on a task it was not given while every log line looks normal. Keeping the + # first and saying how many were shadowed at least makes it findable. + logger.warning( + "%d task(s) share an instruction with an earlier task and are unreachable " + "through prompt lookup; the first occurrence wins", + sum(collisions.values()) - len(collisions), + ) + return self._tasks + + def prompt_rows(self) -> list[dict[str, Any]]: + """Dataset rows for a trainer: the instruction as the prompt, plus columns worth logging. + + All `num_generations` of a group share a row, so they all get the same task and the group + baseline is well formed without any seed plumbing. + """ + return [ + { + "prompt": [{"role": "user", "content": t.get("instruction") or ""}], + "task_name": t.get("task_name") or "", + "task_index": int(t.get("index", i)), + } + for i, t in enumerate(self.tasks()) + ] + + def create( + self, + task: Any, + seed: int | None = None, + episode_id: str | None = None, + ) -> HarborSession: + instruction = _instruction_of(task) + self.tasks() # ensures the instruction -> index map exists + index = self._by_instruction.get(instruction_id(instruction)) + if index is None: + raise KeyError( + "this prompt does not match any task on the server. Build the dataset from " + "`prompt_rows()` so the instruction the trainer sends is the one the server has." + ) + # A CLIENT PER SESSION, never the factory's shared one. The MCP transport sends and then + # receives on one socket with no request-id correlation, so two rollouts sharing a client raise + # `ConcurrencyError: cannot call recv while another coroutine is already running recv`. With + # `num_generations` rollouts in flight that is every rollout of every step, instantly, and each + # one comes back unscorable — a run that looks like it is working and trains on nothing. + # + # The factory keeps its own client for task metadata, which is fetched once and sequentially. + return HarborSession( + env=self.new_client(), + owns_env=True, + split=self._split, + task_index=index, + instruction=instruction, + harness=self.harness, + sandbox=self.sandbox, + llm_url=self.llm_url, + model=self.model, + reward_key=self.reward_key, + api_key=self.api_key, + auth_header=self.auth_header, + agent_timeout_sec=self.agent_timeout_sec, + agent_step_limit=self.agent_step_limit, + sampling=self.sampling, + ) + + +def _as_dict(task: Any) -> dict[str, Any]: + """A task record as a plain dict, whichever shape the client handed back. + + `HarborEnv.get_task` returns a `HarborTaskRef` model and `get_task_range` returns dicts, so code + downstream that calls `task.get(...)` breaks on one and not the other depending on which path + selected the tasks. + """ + if hasattr(task, "model_dump"): + return task.model_dump() + return dict(task) + + +def _instruction_of(task: Any) -> str: + """The instruction text out of whatever the worker passed as `task`.""" + if isinstance(task, list) and task: + last = task[-1] + if isinstance(last, dict): + return str(last.get("content") or "") + if isinstance(task, dict): + return str(task.get("instruction") or task.get("content") or "") + return str(task or "") diff --git a/envs/harbor_env/models.py b/envs/harbor_env/models.py new file mode 100644 index 0000000000..b325e4a3c3 --- /dev/null +++ b/envs/harbor_env/models.py @@ -0,0 +1,17 @@ +"""Wire types, re-exported so `from harbor_env.models import ...` works like other envs.""" + +from openenv.harbor.models import ( + HarborRolloutResult, + HarborState, + HarborStepResult, + HarborTaskRef, + HarborTurn, +) + +__all__ = [ + "HarborRolloutResult", + "HarborState", + "HarborStepResult", + "HarborTaskRef", + "HarborTurn", +] diff --git a/envs/harbor_env/openenv.yaml b/envs/harbor_env/openenv.yaml new file mode 100644 index 0000000000..be39cba77a --- /dev/null +++ b/envs/harbor_env/openenv.yaml @@ -0,0 +1,6 @@ +spec_version: 1 +name: harbor_env +type: space +runtime: fastapi +app: server.app:app +port: 8000 diff --git a/envs/harbor_env/pyproject.toml b/envs/harbor_env/pyproject.toml new file mode 100644 index 0000000000..3c843640e9 --- /dev/null +++ b/envs/harbor_env/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "openenv-harbor-env" +version = "0.1.0" +description = "Run Harbor tasks with a coding agent and capture token-level training data" +requires-python = ">=3.12" +dependencies = [ + "openenv", + # Every sandbox backend, listed individually rather than via `harbor[cloud]`. + # `harbor[cloud]` cannot be installed at all: it pulls both `langsmith[sandbox]`, which + # requires `websockets>=15`, and `tensorlake`, which requires `websockets>=13,<14`. uv + # reports the pair as unsatisfiable and the image build fails. Neither is a sandbox backend + # we offer, so both are dropped and everything else kept. Re-check on a Harbor upgrade. + "harbor[e2b,modal,daytona,gke,ec2,runloop,novita,blaxel,beam,islo,opensandbox,cwsandbox,use-computer,cua]>=0.22.0", + "huggingface_hub>=1.12", + "fastapi>=0.104", + "uvicorn[standard]>=0.24", + "httpx>=0.27", + "gradio>=5", +] + +[project.scripts] +server = "server.app:main" + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["server"] diff --git a/envs/harbor_env/server/Dockerfile b/envs/harbor_env/server/Dockerfile new file mode 100644 index 0000000000..4c405529f2 --- /dev/null +++ b/envs/harbor_env/server/Dockerfile @@ -0,0 +1,36 @@ +ARG BASE_IMAGE=ghcr.io/huggingface/openenv-base:latest +FROM ${BASE_IMAGE} AS builder + +# Harbor requires Python >= 3.12 while openenv-base ships 3.11, so `uv sync` downloads its own +# interpreter and the venv's bin/python becomes a symlink into uv's install dir. Pinning that dir +# (and creating it up front, so the COPY below cannot fail when uv reuses a system interpreter) +# is what lets the runtime stage carry the interpreter the venv actually points at. Without it the +# venv arrives with a dangling bin/python and the container dies with "not found". +ENV UV_PYTHON_INSTALL_DIR=/opt/uv-python +RUN mkdir -p /opt/uv-python + +WORKDIR /app/env +COPY . /app/env +RUN --mount=type=cache,target=/root/.cache/uv \ + if [ -f uv.lock ]; then uv sync --frozen --no-editable; else uv sync --no-editable; fi + +FROM ${BASE_IMAGE} +COPY --from=builder /opt/uv-python /opt/uv-python +COPY --from=builder /app/env/.venv /app/.venv +COPY --from=builder /app/env /app/env + +# Fail at build time rather than at startup if the interpreter did not survive the stage boundary. +RUN /app/.venv/bin/python -c "import sys; print('venv python', sys.version)" + +ENV PATH="/app/.venv/bin:$PATH" +# `harbor push` bundles the working tree's openenv/ into /app/env when pushing from a source +# checkout; PYTHONPATH puts it ahead of the released wheel in site-packages, which has no +# `openenv.harbor` until this lands upstream. +ENV PYTHONPATH="/app/env:$PYTHONPATH" +ENV ENABLE_WEB_INTERFACE=true + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD /app/.venv/bin/python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +EXPOSE 8000 +CMD ["sh", "-c", "cd /app/env && exec /app/.venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8000"] diff --git a/envs/harbor_env/server/__init__.py b/envs/harbor_env/server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/envs/harbor_env/server/app.py b/envs/harbor_env/server/app.py new file mode 100644 index 0000000000..692da40f20 --- /dev/null +++ b/envs/harbor_env/server/app.py @@ -0,0 +1,144 @@ +"""ASGI entry point for a deployed harbor_env. + +Everything is read from the environment so the same image serves any dataset and any engine without +a rebuild — which is what makes this deployable to a Space: + + OPENENV_DATASETS comma-separated dataset specs (HF repo id, local dir, harbor name@version) + OPENENV_LLM_URL DEFAULT OpenAI-spec endpoint; optional, since rollouts may name their own + OPENENV_MAX_OUTPUT_TOKENS cap on what an agent may request per turn (default 8192) + OPENENV_MODEL served model id; read from the engine when it serves exactly one + OPENENV_LLM_API_KEY credential for a hosted endpoint; a Space SECRET, never a variable + OPENENV_LLM_AUTH_HEADER header to send it under, when not `Authorization` + E2B_API_KEY / MODAL_TOKEN_ID+MODAL_TOKEN_SECRET whichever sandboxes you want offered + +OPENENV_LLM_URL is OPTIONAL. With no engine the server still comes up serving its datasets, and each +rollout names the engine it wants (`run_rollout(llm_url=...)`), which is probed once and cached. That +is the useful way round: a dataset tree is thousands of files and prebuilt sandbox templates, while an +engine restarts every training run — and a train-tier engine and an eval-tier one are usually both +wanted against the same task suite. + +Naming an engine here still works and makes it the default for rollouts that name none. + +An endpoint that cannot return token ids is not a boot failure either: the Space comes up as an EVAL +deployment, which is what a hosted provider can honestly offer. `capture_level` says which it is, and +the UI shows it. + +The capture proxy rides on this same app rather than on a second port. A Space publishes exactly one +port and one URL, so the proxy is mounted at `/capture` and the sandbox reaches it at +`https://.hf.space/capture`. Nothing is forwarded and no second listener is opened. +""" + +from __future__ import annotations + +import os + +from openenv.harbor.serving import HarborService, build_app + +_DATASETS = [ + d.strip() for d in os.environ.get("OPENENV_DATASETS", "").split(",") if d.strip() +] +_LLM_URL = os.environ.get("OPENENV_LLM_URL", "") +_MODEL = os.environ.get("OPENENV_MODEL", "") +_API_KEY = os.environ.get("OPENENV_LLM_API_KEY", "") or None +_AUTH_HEADER = os.environ.get("OPENENV_LLM_AUTH_HEADER", "") or "Authorization" +_LLM: dict = {} +# "text", not "tokens". This is the value used when the probe never ran or never finished — an +# ambiguous model list, an unset model, an endpoint that raised — and defaulting it optimistically +# meant a Space in exactly that state built its proxy at token level and stamped every rollout it +# produced as trainable. Which is the one failure this whole capture level exists to prevent, so the +# unknown case has to assume the weaker tier and be corrected upward only by evidence. +_CAPTURE_LEVEL = "text" + +# Ask the endpoint what it serves when `OPENENV_MODEL` was not set, the same way `harbor serve` does. +# Without this the proxy has no served model id and stops rewriting `model` on the way upstream, so +# whatever name the harness happened to use is forwarded verbatim and the engine rejects it. The +# report is kept so `capabilities()` can state whether capture is actually supported here. +if _LLM_URL: + try: + from openenv.core.harness.capture.validate_llm import list_models, validate_llm + + if not _MODEL: + served = list_models(_LLM_URL, api_key=_API_KEY, auth_header=_AUTH_HEADER) + _MODEL = served[0] if len(served) == 1 else "" + if _MODEL: + _report = validate_llm( + _LLM_URL, _MODEL, api_key=_API_KEY, auth_header=_AUTH_HEADER + ) + _CAPTURE_LEVEL = _report.capture_level or "text" + _LLM = { + "url": _LLM_URL, + "model": _report.model, + "ok": _report.ok, + "findings": _report.findings, + "served_models": _report.served_models, + "capture_level": _report.capture_level, + "rollout_type": _report.rollout_type, + "trainable": _report.trainable, + "reachable": _report.reachable, + "param_fixes": _report.param_fixes, + "authenticated": bool(_API_KEY), + } + except Exception as exc: # noqa: BLE001 - a Space must still boot so the UI can show the fault + _LLM = { + "url": _LLM_URL, + "model": _MODEL, + "ok": False, + "reachable": False, + "capture_level": _CAPTURE_LEVEL, + "findings": [ + f"could not reach the LLM at startup: {type(exc).__name__}: {exc}" + ], + } + + if not _MODEL: + # Reached when the endpoint serves several models and none was named. The proxy then cannot + # rewrite `model` upstream, so nothing will work anyway — but it must not claim to be + # trainable while failing. + _LLM.setdefault("url", _LLM_URL) + _LLM.setdefault("ok", False) + _LLM.setdefault("reachable", False) + _LLM.setdefault( + "findings", + [ + "no model resolved: set OPENENV_MODEL, or point at an endpoint that serves " + "exactly one model" + ], + ) + _LLM["capture_level"] = _CAPTURE_LEVEL + +# Resolve capture before the app is built. A Space gives no separate boot hook, the UI needs the +# proxy's public URL to exist by the time anyone presses Run, and `build_app` has to see the service +# in order to mount it. +# +# Started unconditionally: the proxy has to be listening and publicly reachable before any rollout +# can name an engine, and it is the SESSION that carries the engine. Gating this on OPENENV_LLM_URL +# was what made an engineless server useless — every rollout answered "server not initialised". +_service = HarborService( + llm_url=_LLM_URL, + model=_MODEL, + datasets=_DATASETS, + capture_port=int(os.environ.get("OPENENV_CAPTURE_PORT", "8100")), + expose=os.environ.get("OPENENV_EXPOSE", "gradio"), + api_key=_API_KEY, + auth_header=_AUTH_HEADER, + capture_level=_CAPTURE_LEVEL, + max_output_tokens=int(os.environ.get("OPENENV_MAX_OUTPUT_TOKENS", "8192")) or None, +) +# On a Space this only computes the public URL and flags the app for mounting; off one it +# publishes the capture port the usual way. +_service.start() +HarborService.set_current(_service) + +os.environ.setdefault("ENABLE_WEB_INTERFACE", "true") + +app = build_app(datasets=_DATASETS, llm_url=_LLM_URL, model=_MODEL, llm=_LLM) + + +def main() -> None: + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) + + +if __name__ == "__main__": + main() diff --git a/envs/harbor_env/uv.lock b/envs/harbor_env/uv.lock new file mode 100644 index 0000000000..6c41f39d74 --- /dev/null +++ b/envs/harbor_env/uv.lock @@ -0,0 +1,4979 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "aiofile" +version = "3.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/31/edb06aabd8f8f0b56d659f30800795f40b93cba96be946ce179f6931e3a5/aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41", size = 21600, upload-time = "2026-08-04T22:59:27.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiohttp-retry" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/ebda4d8e3d8cfa1fd3db0fb428db2dd7461d5742cea35178277ad180b033/aiohttp_retry-2.9.1.tar.gz", hash = "sha256:8eb75e904ed4ee5c2ec242fefe85bf04240f685391c4879d8f541d6028ff01f1", size = 13608, upload-time = "2024-11-06T10:44:54.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", size = 9981, upload-time = "2024-11-06T10:44:52.917Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +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" } +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" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "authlib" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "beam-client" +version = "0.2.211" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beta9" }, + { name = "packaging" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/c0/53d1c184c2851e77df57572629b4d9af0d90f2c914a0e23387034027e151/beam_client-0.2.211.tar.gz", hash = "sha256:8ca975ce814ada1100d737764024c7f489ac05a72cba3265517ee6452ff7daed", size = 7225, upload-time = "2026-09-15T18:10:39.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/58/7de080c27aec3dbf6d41d9f3f434ee4917a5fabe6449adcb059501337e53/beam_client-0.2.211-py3-none-any.whl", hash = "sha256:ffa8c9bb086d85ad1bff07bf8ebde65d5e122a4df1469d1aec56598ddf44c561", size = 10935, upload-time = "2026-09-15T18:10:38.648Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "beta9" +version = "0.1.268" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "betterproto-beta9" }, + { name = "click" }, + { name = "cloudpickle" }, + { name = "croniter" }, + { name = "fastapi" }, + { name = "grpcio" }, + { name = "grpclib" }, + { name = "paramiko" }, + { name = "prompt-toolkit" }, + { name = "protobuf" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "typeguard" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog" }, + { name = "websocket-client" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/31/03a2b8337afe63f183c0c98574e9899e2e7dc975864f740091254d9b528e/beta9-0.1.268.tar.gz", hash = "sha256:693e4f65771e40d925946f149d70eedb82baea846de351567a383b7dd11a4b96", size = 297790, upload-time = "2026-09-15T17:51:20.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/47/6e1e136205743ed283ad063a66507689582010ea18dc77684c478325c6d5/beta9-0.1.268-py3-none-any.whl", hash = "sha256:eb21e48b9ae0871449b2434b2e735024a59298ffce64953a066d9d36b8358a75", size = 318857, upload-time = "2026-09-15T17:51:18.988Z" }, +] + +[[package]] +name = "betterproto-beta9" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpclib" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/f4/cf46a42e771a0f247de9de98060d832ccc8ab7287c2547217c563691923e/betterproto_beta9-2.0.1.tar.gz", hash = "sha256:86ff723328a28fcd9081edd362112cd98edc936df0f03006000543e42e431fdc", size = 99549, upload-time = "2025-03-20T20:07:04.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/e4/68bfa1697fe40c72177f8d37c8dcd86946b4f5a2de49f474e07840a76abb/betterproto_beta9-2.0.1-py3-none-any.whl", hash = "sha256:8e6fb7b1ef608eb354a7f5456ad4465c089d6bb19df46dcb89d7d8b131cf9b30", size = 103147, upload-time = "2025-03-20T20:07:03.275Z" }, +] + +[[package]] +name = "bidict" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/f2/8d2dd8276ca05e1f5157b6a0d34efb2f585f47a0fbed61e8aad04b221f0b/bidict-0.24.1.tar.gz", hash = "sha256:4dca6c17f0b01700e9f24359daa5ebabf7be022d99f4cb2a257b6af2a5076c88", size = 30818, upload-time = "2026-08-25T23:45:52.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/53/2a3c7d562271ec6b6e38e7216b3104899f8aa4180c0713cb8aaf69e29cd5/bidict-0.24.1-py3-none-any.whl", hash = "sha256:fd3eaa737917d8a14f4baa391670c433c4e3f6f5fd2cd99d4bf436437f432364", size = 36175, upload-time = "2026-08-25T23:45:51.096Z" }, +] + +[[package]] +name = "blaxel" +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tomli" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/6c/31d4b4b24211cb43cb5f8a3e6ab8b3d70a2e71281a6f9d6783c155e501b4/blaxel-0.4.9.tar.gz", hash = "sha256:fc1c13bf8a81cb5d62ed73d7e4c42056d8cd9c62f3290ddbe56646dc1c20d940", size = 566790, upload-time = "2026-09-11T18:59:10.597Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/4d/09d7853903a93139e9023bd79b1e531c59ff3b06ce12c4ae5a67f5f091a2/blaxel-0.4.9-py3-none-any.whl", hash = "sha256:7e5ebc6d40cbfc7a832fb6cc08c4fbf6bd977835778a828c25fd8cfdfb20c8eb", size = 846426, upload-time = "2026-09-11T18:59:09.127Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.95" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/ed/9d4d4e7d4b874c16f7b3a886efe3e45801e0f9ebb60058adf1bb69cb2073/boto3-1.43.95.tar.gz", hash = "sha256:9d71f299111e1f4e8c28f573a1b7c0555fe40d2147fe9bef852a02bd57cbde60", size = 112666, upload-time = "2026-09-15T19:23:49.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/6e/9c6fb58b1cdddf13b3e9edc94e0c6e08fff456355f6e097684d6117aa8d4/boto3-1.43.95-py3-none-any.whl", hash = "sha256:c906921c4f9ab41e9587af6586f072c8f2be6979e8bde2ec26aa443bb1745019", size = 140026, upload-time = "2026-09-15T19:23:47.563Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.95" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2c/0031468b521eefed325a3da2d6576e068aab95837af0c96effea6438851c/botocore-1.43.95.tar.gz", hash = "sha256:779588da32bd48a7bb0c097da4bcb747260e86d2bfc507369dca56f1450e0722", size = 16106887, upload-time = "2026-09-15T19:23:44.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/77/7ce7c937b31903f1402e478fdc97f98e727656fcea3cb54e74f1ca55793e/botocore-1.43.95-py3-none-any.whl", hash = "sha256:0fda26d16c7c7bf7082c421390a817b696f98a43d3da7c43dbbb093f76662876", size = 15800616, upload-time = "2026-09-15T19:23:41.228Z" }, +] + +[[package]] +name = "bracex" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/39/9a4689914dd907915cee74733b95888fc1d8a21aad47a24a0a2deec73ac4/cachetools-7.1.8.tar.gz", hash = "sha256:1221d547a0b24b7f26fa891d40d488b5258beab9aebd8ed68c729be3af849c43", size = 40909, upload-time = "2026-08-31T19:02:53.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/3d/9487690d0e937854db587205c66bab3c3cf88d9f00ed380b74cb88cc94ee/cachetools-7.1.8-py3-none-any.whl", hash = "sha256:a81e3844acaa7355b6567f97bd67a94a14ec3a9bc2cbbdae45b9592cc036775b", size = 16842, upload-time = "2026-08-31T19:02:52.554Z" }, +] + +[[package]] +name = "caio" +version = "0.12.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/c9/ac301b7f86ccf6ad02ee78eb953ce8f75175754cc5e76d398e447af42e3e/caio-0.12.4.tar.gz", hash = "sha256:32d8e9f3e2099c8db29446679252766c9bcd806eb88b4fb60ad274f73df2a5e9", size = 81006, upload-time = "2026-09-07T06:52:39.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/35/7ddc8de48cd0142797db186c07e901005b966dc6e8a7852f0273d33e4840/caio-0.12.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:67b725641fea682a2e9b1d3b2a150d21f1a25383e3ec8351bd7677ff857fa982", size = 47927, upload-time = "2026-09-07T06:52:13.58Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b4/37556bde83253cf1ed071a754c8405d02b955febd021c400f0ffa6258d5f/caio-0.12.4-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4854a029e359e5ddfb5589ab66157a34af6a0dda0acdaa43f5e640d60182c1ea", size = 161402, upload-time = "2026-09-07T06:52:14.581Z" }, + { url = "https://files.pythonhosted.org/packages/7a/11/519e40a1da4d18515ab5ddb54e75cdd896532339010f2dcee7e798e4edab/caio-0.12.4-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:f063624a98a64bab387430c5eaea61ef3825a98399104a297b8da4741c15139f", size = 159311, upload-time = "2026-09-07T06:52:15.853Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/0db30de6c2561e41721d94ac627aa65db61a403545e67914814d8c2b5305/caio-0.12.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a926d562f8c06767774a91239770ebaa93c10a24074e21ea741db208df9cc1d", size = 158910, upload-time = "2026-09-07T06:52:17.27Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e0/5eb43a89aecdf9d6f929b0080e49d92ccdf3f88bbc637353f52843b483af/caio-0.12.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53605636e3b1eaeca368b475f1471ad9774736c6502a4b58b8b6b8d74b531770", size = 158973, upload-time = "2026-09-07T06:52:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/11/ab/12e4896a1e74ff9a65c84894acdfc3b70b8b49e63874a37e0dffcc31f396/caio-0.12.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:134d9d145d75f454de5ece9e87595bad433639b51061b693bafc369f689f8742", size = 47934, upload-time = "2026-09-07T06:52:19.644Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d4/3a96f5537e3fa4e3256f93244fe883a5a4879f2b9e2d59d50151bb417f37/caio-0.12.4-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:27ec5671ac05650abc7ac1fb12e95ad09417ae495ce9be565927786688edd6c5", size = 161559, upload-time = "2026-09-07T06:52:20.637Z" }, + { url = "https://files.pythonhosted.org/packages/05/64/f33b9aaf7bc9bad988211d8e42b4b85c25a1d5dcc0906798aebe49bd421b/caio-0.12.4-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:1e1d5570fd0ec75b1b2c2877c5545ed9f0738b5d23b000e2b6a8fc830dc8b310", size = 159453, upload-time = "2026-09-07T06:52:22.011Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/3fc5e143728d99f8af11453789d755937fbe795a6c5c9c9ea529c91df519/caio-0.12.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e22ce2e69d94e4c80e0c11c871e547b3c2d147f977632894cb2527ddb6e87a8d", size = 159028, upload-time = "2026-09-07T06:52:23.225Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8d/e7a165aa44bb2876bec96f7fb1995346d14e43d46237421382f8af4f37da/caio-0.12.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9dc0fd6f09ef72d18d3f43ca3d1140295222d925c583114ab9b1e8843a109a6e", size = 159088, upload-time = "2026-09-07T06:52:24.362Z" }, + { url = "https://files.pythonhosted.org/packages/6f/31/d31ed073a7f8e02d352b390ff556757ba82afbd747c68238b1bda638ce6f/caio-0.12.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:413565d77dfdf2dd841ac100571ef1cc710f9c56137312f3ec51356350b4f3a5", size = 41919, upload-time = "2026-09-07T06:52:25.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/07/ab0bc8a481126e6b4f6f56f3bb94e41568fc4604df15420616251b2e28da/caio-0.12.4-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:cd1d00ed1867a3b7a4cffb64b1bd8644de4b6f88cb25240cf82a9cc160ace975", size = 41131, upload-time = "2026-09-07T06:52:26.512Z" }, + { url = "https://files.pythonhosted.org/packages/24/3b/49382d9f7b3f65c76bb63a18a657833c4bfa695555b9663139950dec3c11/caio-0.12.4-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:4189104e5579553031340a677762398c130b81ddb7d7c9b69eebffddaecc4f58", size = 162063, upload-time = "2026-09-07T06:52:27.717Z" }, + { url = "https://files.pythonhosted.org/packages/14/7c/eaf41fb7e0c864c3a5ce4c259eba0f505633efe4f789fffb6d3f1eb7e9e1/caio-0.12.4-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:4f2e0b6d393dc0ea78cfd13a9a4321fc35fd6a2db802e5f64651317e859fc929", size = 159514, upload-time = "2026-09-07T06:52:28.914Z" }, + { url = "https://files.pythonhosted.org/packages/41/7e/4041947d8caa760b74a27c8082844c72974da7cbfb96737990b8c6749e53/caio-0.12.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:710ffe4a6f3d69dbfe98e9f856c24a544e4d639c7bd210f9f5503a26e62502f4", size = 159753, upload-time = "2026-09-07T06:52:30.081Z" }, + { url = "https://files.pythonhosted.org/packages/32/76/b6c524a51eca13b861e8f98c927b933fdf5766f0c7c9017a21d889a84e3d/caio-0.12.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddf8d946b795ebd7c75b331b6dcbab4808fdd2c9f16ca76012b4757512861133", size = 159167, upload-time = "2026-09-07T06:52:31.288Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/c39ed874d420bb2719d52932d763953c28371c536d6bf264de1febeb27d5/caio-0.12.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:17a41de51203fc4d787d8d577bd56f169167aa9822f14fada015504688791aa4", size = 41920, upload-time = "2026-09-07T06:52:32.432Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/58f4ba2ac9ba69bd30e5c89493d34aa483743f823f79a854f20771fb80ca/caio-0.12.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ee9de68d6ede7ed69e55a1c09073ce146d9f9470088796d55238c485a93fe203", size = 183327, upload-time = "2026-09-07T06:52:33.492Z" }, + { url = "https://files.pythonhosted.org/packages/25/81/6b86722be50975b80a636afebed5af1a6e6588afeff0ae1a0a7f8c262707/caio-0.12.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:1c1a387f8784a86a56a8912ca42e1ad67599f16406f497b5b655b461a1b89426", size = 179368, upload-time = "2026-09-07T06:52:34.66Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a3/19ab810f11d1688bb2009e669b2645410a89b7a35b608239ac4076dc1611/caio-0.12.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ceee64265a55b9aba9c38c1d01a159b99f1e36eb56938022018199d2e7fe1742", size = 181460, upload-time = "2026-09-07T06:52:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b2/e54e6e445d600e22b107e4005714cad0657bc7dc19d200817ab7a62ac722/caio-0.12.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:456a93868ff007cea65d916d96d192cda8b9992c6eee046cb0159040f9446ab5", size = 179230, upload-time = "2026-09-07T06:52:36.978Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e6/07100a694613344d22958fb6d803fdf914ee9395278fe58ad1b1b9c59e52/caio-0.12.4-py3-none-any.whl", hash = "sha256:7a5e231bbf81eaed269f99afa9ef46457f51f9407952a1795c0795b3a62c23c0", size = 25628, upload-time = "2026-09-07T06:52:38.147Z" }, +] + +[[package]] +name = "cbor2" +version = "6.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/14/b02446bacfe44351b1689c04937ade007588f44570431880a6937e525e6c/cbor2-6.1.4.tar.gz", hash = "sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9", size = 90840, upload-time = "2026-08-01T20:41:39.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/76/fb64293c19cafb860060310c57b768fd9cfb7cf592449660b756538cc116/cbor2-6.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b", size = 404608, upload-time = "2026-08-01T20:40:50.219Z" }, + { url = "https://files.pythonhosted.org/packages/96/ac/f58b3bafce7c86ada2ad8eaf189453136d2cf5bae526ea0540e1b9bc9d06/cbor2-6.1.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2", size = 449851, upload-time = "2026-08-01T20:40:51.725Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/10c6c126d59b07f2bd005094dd12a20afa46146f7e2673ed6f61a57641a7/cbor2-6.1.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220", size = 461193, upload-time = "2026-08-01T20:40:53.446Z" }, + { url = "https://files.pythonhosted.org/packages/15/e4/4445e6237088d1cca3b8536daeb90d6b4e23776de5609c9fa46773874757/cbor2-6.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f", size = 516937, upload-time = "2026-08-01T20:40:54.952Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/9c0959510f7a402e5995c81ccfd82cb9f314140dc0cce88c12836e5b93f1/cbor2-6.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242", size = 529229, upload-time = "2026-08-01T20:40:56.365Z" }, + { url = "https://files.pythonhosted.org/packages/91/8e/6811e4ee84203ac657f6f461a37c7c9ba0287bde80eb83c7971e9b3fe156/cbor2-6.1.4-cp312-cp312-win32.whl", hash = "sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e", size = 278810, upload-time = "2026-08-01T20:40:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/da/27/87440788fc0d9513534c3c699238e2a9ca6010f8cb72e9c203b7af20a9f6/cbor2-6.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6", size = 299971, upload-time = "2026-08-01T20:40:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/23/f9/77981e6e63092de19d7306a09a12b0eb3fd2907dc22c10dd5d389eb27faf/cbor2-6.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b", size = 290662, upload-time = "2026-08-01T20:41:00.89Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/0b20c88e76942ede86c98cdce138681690f95908c540c264fff847729cd4/cbor2-6.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4", size = 403677, upload-time = "2026-08-01T20:41:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/3d/93eed770864540c5c9ea0841008208e9db686b7335f42520705b7d6dc6b2/cbor2-6.1.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245", size = 449762, upload-time = "2026-08-01T20:41:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/e3/21/69e4d37f00319b3d37322355aedc83154b4d8b75dc9e9789c06e1fbd8a92/cbor2-6.1.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905", size = 460420, upload-time = "2026-08-01T20:41:05.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/26/2cfdd5ee826205a88a826bb38b7a572c676ec3efa29574be5cdbd04b4859/cbor2-6.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76", size = 516490, upload-time = "2026-08-01T20:41:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d687cd1c2c9f9a986e8552ad1fdbd22411cc86389b5705dba6ec6f7e3226/cbor2-6.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0", size = 528810, upload-time = "2026-08-01T20:41:09.144Z" }, + { url = "https://files.pythonhosted.org/packages/40/08/88cecf20b8825bdd991c47b317415c08ef9e7d5f05a1def9acd346edabde/cbor2-6.1.4-cp313-cp313-win32.whl", hash = "sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd", size = 278058, upload-time = "2026-08-01T20:41:10.48Z" }, + { url = "https://files.pythonhosted.org/packages/0e/67/ba140234a6415c16dcfbe0585ce12f905157b70e9cb1bb63a2b6d5721e70/cbor2-6.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f", size = 299315, upload-time = "2026-08-01T20:41:12.067Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/35d53ff4252a5a85656480d3a81d5a5af823979ccd0c5cac95196a7548a6/cbor2-6.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f", size = 289976, upload-time = "2026-08-01T20:41:13.63Z" }, + { url = "https://files.pythonhosted.org/packages/05/5d/c5374c76471ab41dff4420a276569a56352e83166374fba6f40fd0bde7ad/cbor2-6.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69", size = 407497, upload-time = "2026-08-01T20:41:15.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/f9/b9f12a5e24d5ae355e4c0f6d37330a2bbedad3331247a223a51c4cd39d5e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22", size = 452191, upload-time = "2026-08-01T20:41:16.705Z" }, + { url = "https://files.pythonhosted.org/packages/67/22/8224b01f95a6fe07b1a64082aea34d9f49068392b3de93f5f3a10c73c62e/cbor2-6.1.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c", size = 462383, upload-time = "2026-08-01T20:41:18.17Z" }, + { url = "https://files.pythonhosted.org/packages/92/52/437e4aa4f5df1fb41020d64b3d99a8239f0f99a3a75eb6ffa5cb66004b7f/cbor2-6.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae", size = 518700, upload-time = "2026-08-01T20:41:19.592Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/2f5ea5bfe0fd800b3739c7df8679bdffa9f7def6b2f2fee064ada1c63e85/cbor2-6.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2", size = 531243, upload-time = "2026-08-01T20:41:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c6/0beac64cb74cd3217f295f9bb0d64675e1809c683a31ea2a49ac9d4d1504/cbor2-6.1.4-cp314-cp314-win32.whl", hash = "sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18", size = 285248, upload-time = "2026-08-01T20:41:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7d/4afa096ddc94049f5a514690891b02a18319e146ceb14465ce30c8340a8b/cbor2-6.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6", size = 313044, upload-time = "2026-08-01T20:41:24.066Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b5/e614cee861772f6b5c4d926b066d2e7dbc11e220b50ba716ba91e430fb0f/cbor2-6.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f", size = 304088, upload-time = "2026-08-01T20:41:25.431Z" }, + { url = "https://files.pythonhosted.org/packages/9e/41/3b28184154f6cbf7e47c1b7fb4a7a291c54f27a6f3a0a2f64b078c6a13e1/cbor2-6.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f", size = 401042, upload-time = "2026-08-01T20:41:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/a8624023b84b41c43a150a89517c104aed0e467bd258866f13be4c3ac0c6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da", size = 445301, upload-time = "2026-08-01T20:41:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/07dd0ea957c1f48673d3947f97ee36826efd4a824053dd0ec4df2f0c89d6/cbor2-6.1.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54", size = 459816, upload-time = "2026-08-01T20:41:29.839Z" }, + { url = "https://files.pythonhosted.org/packages/23/8e/2015175132a27c1daed434f671ac6d9c1311461995df47f201307700e0da/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025", size = 511565, upload-time = "2026-08-01T20:41:31.939Z" }, + { url = "https://files.pythonhosted.org/packages/82/66/420991095d9473614b205d4c4e40b5d3b9f1ee4410eb3c48c1e902947837/cbor2-6.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9", size = 527709, upload-time = "2026-08-01T20:41:33.413Z" }, + { url = "https://files.pythonhosted.org/packages/cc/7c/73057e7a38488a816a0d40ff9e7cd9f418800894582e2e48fb2f47ce66a2/cbor2-6.1.4-cp314-cp314t-win32.whl", hash = "sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257", size = 281462, upload-time = "2026-08-01T20:41:35.127Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/d5db22837cb566de733b9d1c418cdf1912ccb1efc7b179e295430b1d81a2/cbor2-6.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c", size = 309165, upload-time = "2026-08-01T20:41:36.716Z" }, + { url = "https://files.pythonhosted.org/packages/29/5f/ff2c6da83553a692219a0a62a21b57a27ded4405200e50db758a17fbaf15/cbor2-6.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883", size = 298386, upload-time = "2026-08-01T20:41:38.392Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "connectrpc" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf-py" }, + { name = "pyqwest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/b5/63e14ab9d4d4cc58818562db4120a35fa7454e3035633da41bdc6b712abd/connectrpc-0.11.1.tar.gz", hash = "sha256:18277f7838847b4271ca38d40c7d2387b5a2ea6a29f240689c19e1ec84aaff66", size = 46222, upload-time = "2026-07-15T06:33:31.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/ac/aa647675812cd075f2cb9acb00d62f4f2cbcbc6736049a62342b7371ce5b/connectrpc-0.11.1-py3-none-any.whl", hash = "sha256:8a52e2e92a485fa9681c1101a79a5ebeb31807e3ea3d5aabd41f484a6398bc7b", size = 64991, upload-time = "2026-07-15T06:33:29.396Z" }, +] + +[[package]] +name = "croniter" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/ea/98665cd116af6d3c4e79c8dc91bbd9a13746cb3c7d72efbfdef5b720c43b/croniter-3.0.4.tar.gz", hash = "sha256:f9dcd4bdb6c97abedb6f09d6ed3495b13ede4d4544503fa580b6372a56a0c520", size = 54500, upload-time = "2024-10-25T12:22:33.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/f5/135d0e57e5bdd2f978388d77ee818f1ac5ac584eb48034362770001f4cad/croniter-3.0.4-py2.py3-none-any.whl", hash = "sha256:96e14cdd5dcb479dd48d7db14b53d8434b188dfb9210448bef6f65663524a6f0", size = 23220, upload-time = "2024-10-25T12:22:30.75Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "cua-train" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f8/9b11e27a2279bf0548a8833664d479e0b594d8d2964c66f30b412e421713/cua_train-0.1.2.tar.gz", hash = "sha256:9361a982ce9275748d3f8a7984272d96540093f0bc79ebd833248e410c0d4a7b", size = 6222, upload-time = "2026-06-24T21:45:59.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/de/15329953e45048c707321717c8a21d6e4de249f2ce83607350257eaaebe4/cua_train-0.1.2-py3-none-any.whl", hash = "sha256:1488c97ac1f0a5572527d678316b45fe2a9adc3b505e5d07789dd09a85b6a71a", size = 3112, upload-time = "2026-06-24T21:45:58.855Z" }, +] + +[[package]] +name = "cwsandbox" +version = "1.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/91/ee991e280cdf21fdd95ec91fee58f897350571709b9d44b1870fd9fda12a/cwsandbox-1.14.2.tar.gz", hash = "sha256:0da23b12a55910c63699824d7a8d13aeb4199c69d83daa14aa1ae1d311c6c3b4", size = 629616, upload-time = "2026-09-04T19:39:15.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/1d/c085d1ddb2cb19b542f401cf08de2a01968439331000e56329de47d695ba/cwsandbox-1.14.2-py3-none-any.whl", hash = "sha256:35a075c9d83894e2d9488a64bfb34c4481a7fb0e09ea474fde5515abf5ba21ff", size = 246763, upload-time = "2026-09-04T19:39:13.436Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/8f/6caec9cf291ba294a0d0669b0388e5676d8887d8b75a8c3ad0a2dc2eeed9/cyclopts-4.25.2.tar.gz", hash = "sha256:0776bc1fa796cd351646c345b7420279e58d6c2c4a8f5d5dd54dea85bdb2de8f", size = 202467, upload-time = "2026-09-08T16:58:04.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/0e/eadf712ca776fd96982c09348dd8f141ae6f96746a4eabc3e3fb54dea738/cyclopts-4.25.2-py3-none-any.whl", hash = "sha256:51b42513eea5e4ba6a08b68acf187b81f1191032cf77b9631086cfa2c88fa7a4", size = 242400, upload-time = "2026-09-08T16:58:02.696Z" }, +] + +[[package]] +name = "daytona" +version = "0.214.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "daytona-analytics-api-client" }, + { name = "daytona-analytics-api-client-async" }, + { name = "daytona-api-client" }, + { name = "daytona-api-client-async" }, + { name = "daytona-toolbox-api-client" }, + { name = "daytona-toolbox-api-client-async" }, + { name = "deprecated" }, + { name = "httpx" }, + { name = "httpx-ws" }, + { name = "obstore" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-aiohttp-client" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "python-socketio", extra = ["asyncio-client", "client"] }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/da/addfd180adeb42fdc78a48fd71892c55528c4f7f8276b4c31e781ae7fc06/daytona-0.214.0.tar.gz", hash = "sha256:5a500d4fb2c5a11e8a72867d4c48a600329bb80580e7264aad3b33db6edaeed9", size = 191295, upload-time = "2026-09-15T09:52:04.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/c0/d93728e28d46cc84f7825aa3fa8d8aad29085359aeedde513a0f08b098e4/daytona-0.214.0-py3-none-any.whl", hash = "sha256:975d0663bd0b7895624364b8be7dac268f9260149fe8bb5fb8375f7992d0e9e2", size = 228842, upload-time = "2026-09-15T09:52:05.462Z" }, +] + +[[package]] +name = "daytona-analytics-api-client" +version = "0.214.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/1c/fc21c0b3b489e014cf985c6915959c5b1a2e7bcaea1130ecd5c5cb319074/daytona_analytics_api_client-0.214.0.tar.gz", hash = "sha256:dec3795a9993aa453cd333f999d7769fcfd12a9576cb519c067d2af08f0ea8b8", size = 30062, upload-time = "2026-09-15T09:50:39.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/18/f2562063c62e329795eda6b4f72493298ef9207048663025729ce383d348/daytona_analytics_api_client-0.214.0-py3-none-any.whl", hash = "sha256:77f0400ad700117ad7b5d8d9c79f867ae6912b761f0f955b3db5d3ae99a355db", size = 45011, upload-time = "2026-09-15T09:50:41.888Z" }, +] + +[[package]] +name = "daytona-analytics-api-client-async" +version = "0.214.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/22/f8cd8130ce5305b2b3c423b09ad643fa629f0933de1ac552d65572d3f408/daytona_analytics_api_client_async-0.214.0.tar.gz", hash = "sha256:9eb9255d61df32e62b8ef8b3fdaf708cf36500d93afc025504b32e9feefbe43c", size = 30094, upload-time = "2026-09-15T09:50:46.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/04/32f0db47c6e8928c941e481bd5eb4376548e67ffcdc5058a14559f46eefa/daytona_analytics_api_client_async-0.214.0-py3-none-any.whl", hash = "sha256:4cd300851969e8c18c8ada58836d62bed2509406a452edb84f1b129b3addf7a4", size = 45284, upload-time = "2026-09-15T09:50:47.204Z" }, +] + +[[package]] +name = "daytona-api-client" +version = "0.214.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/1e/5ed18423112cbbd487ea73a8ec0e4a0632bb7bab3fc0e8c0037381b6f8e1/daytona_api_client-0.214.0.tar.gz", hash = "sha256:b444c058514b56bdf0057d505f1f402bbc427438f4572b58f391fd6b921cc262", size = 141787, upload-time = "2026-09-15T09:50:38.83Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/43/779f716a6c2b7397e43d75365420c8c4f09aca3816cea7e32ffa14973e60/daytona_api_client-0.214.0-py3-none-any.whl", hash = "sha256:3fb1d3cde2647720308ff195a53520c315db92c473313e38613f50122b331448", size = 356131, upload-time = "2026-09-15T09:50:40.488Z" }, +] + +[[package]] +name = "daytona-api-client-async" +version = "0.214.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/44/7824cf193b0bf72a6df78f4646fcee74056eb4b7a7134a7efefc77daafbf/daytona_api_client_async-0.214.0.tar.gz", hash = "sha256:dc07855516e7695d284a6506917852c749d938093344deb0fbc7b1827dc04578", size = 142253, upload-time = "2026-09-15T09:50:42.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/6b/eeb5fab42289ead2f9945a2798e9bfa1a282cc8ee2b9b4a384153acf4b29/daytona_api_client_async-0.214.0-py3-none-any.whl", hash = "sha256:3d448801c589236615ba841fe3ce3a3f1c332c52b860cb7ad19c4ccd79ec72de", size = 358963, upload-time = "2026-09-15T09:50:43.745Z" }, +] + +[[package]] +name = "daytona-toolbox-api-client" +version = "0.214.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/e2/4b1420158a7314c9c30ad608eab7d17238ee7d7cfeae27e5114e83215d2f/daytona_toolbox_api_client-0.214.0.tar.gz", hash = "sha256:015dee710c7318fb4de73ff15fbefcb7fbfa27155f57ce16c4af4649a78e8771", size = 88107, upload-time = "2026-09-15T09:50:38.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/e8/2ef7e19e99ae7bb9a21269c6e788688ace58c7576243f4f8eb4105785153/daytona_toolbox_api_client-0.214.0-py3-none-any.whl", hash = "sha256:a8355d0ab60e4f3f3c8fbf5df828c137210751c79db6c76c095e2abb3ccac401", size = 252417, upload-time = "2026-09-15T09:50:39.778Z" }, +] + +[[package]] +name = "daytona-toolbox-api-client-async" +version = "0.214.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/62/c222c11e7e9158621d7207f29267864bf1730a936afe7164fd866abb26ff/daytona_toolbox_api_client_async-0.214.0.tar.gz", hash = "sha256:4db445411c5a84b13863ee97037dfc7ed93178dfd03aa60b54b85db5d294898a", size = 82042, upload-time = "2026-09-15T09:50:43.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/54/267b1268dd05cd0b9262295ebcef9557971a68c0137d4b67baf1d0819d32/daytona_toolbox_api_client_async-0.214.0-py3-none-any.whl", hash = "sha256:7918944b59aad0905c61eeb5c887b6b620d938dfdd67886f822256b95f6816ea", size = 250906, upload-time = "2026-09-15T09:50:45.026Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +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" } +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" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + +[[package]] +name = "dirhash" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "scantree" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/70/49f93897f3a4f7ab5f20a854ebc91aad47854e9fb2cd169e3a4452fa3f5e/dirhash-0.5.0.tar.gz", hash = "sha256:e60760f0ab2e935d8cb088923ea2c6492398dca42cec785df778985fd4cd5386", size = 21377, upload-time = "2024-08-03T22:14:13.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/1f/c8bf92552b7f0a13b9f12b85e3de8df6d9814240e0f8ce8f37433df028b3/dirhash-0.5.0-py3-none-any.whl", hash = "sha256:523dfd6b058c64f45b31604376926c6e2bd2ea301d0df23095d4055674e38b09", size = 13119, upload-time = "2024-08-03T22:14:11.688Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "durationpy" +version = "0.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/5d/5f8571bd5dedc80863191621ac4be001f3f3dd8315d2ec078705dab7dec1/durationpy-0.11.tar.gz", hash = "sha256:181898e1ae282e288f0a2291829656bf1b6b3aadf30a97993b85db4943642905", size = 3582, upload-time = "2026-08-26T13:56:00.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/c4/ebdf7837bc4ef6fd98cfb013c28855bb358467bf86c1af011bbc21e21df0/durationpy-0.11-py3-none-any.whl", hash = "sha256:a739fe2b8972c250ff72f8e2c488d18cf25f7b852f49ee76048775d5171df30c", size = 4133, upload-time = "2026-08-26T13:55:59.456Z" }, +] + +[[package]] +name = "e2b" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "connectrpc" }, + { name = "dockerfile-parse" }, + { name = "h2" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf-py" }, + { name = "pyqwest" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/bd/998d515242703a38208567e10fdf5ab806ceb51000b31f5e789068ec3536/e2b-2.50.0.tar.gz", hash = "sha256:fdc23c6f1d8bf6625729989800870e7f9c8f01530bac4bee32fba81a3b2517cd", size = 227990, upload-time = "2026-09-16T09:15:42.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/a5/54a21cc53604df6c2df8f5c8843841245b17320490f4dd5548c08643adcb/e2b-2.50.0-py3-none-any.whl", hash = "sha256:d65b4ab656bcc88c472615e270f19126080a532f6de355e578d009bcd1d8e841", size = 382592, upload-time = "2026-09-16T09:15:40.964Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "fastmcp" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/dd/fd444d94ae7afdaf5b6dd168799d34023f576b405872d6a27d5686a9d1f4/fastmcp-3.4.7.tar.gz", hash = "sha256:43117aca886f5ee2f6a569bba91cef02b59c339aad04ba29950ff18d251c822a", size = 28808982, upload-time = "2026-08-10T21:17:55.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/14/6d950459cc831fa17fe2d1797926b6eb2d2f2af50f830e62d0c098cc1ec8/fastmcp-3.4.7-py3-none-any.whl", hash = "sha256:e4e7698cb4af5bc667b1901685261fa2f3526dc73d243a461fca42500c8dbe56", size = 8016, upload-time = "2026-08-10T21:17:51.391Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/ac/7924e803368d0758ee4d6b1259066550df78f58f0f9f8bfebd5a123e957d/fastmcp_slim-3.4.7.tar.gz", hash = "sha256:06b32a358320a7dc2b2ee040ba89ea55ddc20763dff2949f384f7974b13b5d8f", size = 594357, upload-time = "2026-08-10T21:17:28.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/97/e0e53642cd029a9a7635ae9c548f9f2cc995af5914e487b3df795664e4be/fastmcp_slim-3.4.7-py3-none-any.whl", hash = "sha256:6c931a0089705f3f2935428ef9b2bc74ad94140adc64aab84d116d103e694b3a", size = 769370, upload-time = "2026-08-10T21:17:27.227Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "joserfc" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pyperclip" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "starlette" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "filelock" +version = "3.32.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/df/31098c5aeb4d966b553641472bd55fcf5fdfac953549894b8a765ba44e91/filelock-3.32.7-py3-none-any.whl", hash = "sha256:65ff0d0190ea42038b32bda4b77834fb05be2cad4c5b9b01aa4dfb3614536e52", size = 100157, upload-time = "2026-09-16T00:24:19.543Z" }, +] + +[[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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/7a/7d79170c6ce6f12e109df2b3879d6b934010cf4f99aea8de8b7e5408c174/googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2", size = 306984, upload-time = "2026-09-03T22:30:45.133Z" }, +] + +[[package]] +name = "gradio" +version = "6.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "brotli" }, + { name = "fastapi" }, + { name = "gradio-client" }, + { name = "groovy" }, + { name = "hf-gradio" }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "numpy" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "pydub" }, + { name = "python-multipart" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "safehttpx" }, + { name = "semantic-version" }, + { name = "starlette" }, + { name = "tomlkit" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/1b/789b86449bf9fee5e4c97e3550c92f97586a9fc885882994c3ff5d2d8a91/gradio-6.27.0.tar.gz", hash = "sha256:b7212b021c2a7a2f6f1e31341783ba7ab2679bbd286e2956f7e6c701348daf92", size = 45025357, upload-time = "2026-09-11T03:02:02.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/4c/901183a7040fa02d2943ae75f16613424894bb5a5d8e12d427c8d78467df/gradio-6.27.0-py3-none-any.whl", hash = "sha256:6f4b9057c4a771283caa35a80dfdb3f599b5732108591fe9dcc32689cda17b68", size = 31334963, upload-time = "2026-09-11T03:01:58.869Z" }, +] + +[[package]] +name = "gradio-client" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec" }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/ac/4c3afc631a98ec05e06f7b572cc347ac4affc2880f5a1616f53dcf92c16c/gradio_client-2.7.0.tar.gz", hash = "sha256:212c88f7f6a212c973b4429ec450b35efba7805a34aee53f0892535ceee194ae", size = 62140, upload-time = "2026-09-11T03:02:12.131Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/5c/c99c923dc00a0fa74893bbb0c730b5a64c0e5c510f206b43e8628d0d60a4/gradio_client-2.7.0-py3-none-any.whl", hash = "sha256:1eccee1d2b387dc63b9b59124dd07e39cbe0ca7c90cc52ac4525074570b6365e", size = 63024, upload-time = "2026-09-11T03:02:10.776Z" }, +] + +[[package]] +name = "griffelib" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" }, +] + +[[package]] +name = "groovy" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/36/bbdede67400277bef33d3ec0e6a31750da972c469f75966b4930c753218f/groovy-0.1.2.tar.gz", hash = "sha256:25c1dc09b3f9d7e292458aa762c6beb96ea037071bf5e917fc81fb78d2231083", size = 17325, upload-time = "2025-02-28T20:24:56.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/27/3d6dcadc8a3214d8522c1e7f6a19554e33659be44546d44a2f7572ac7d2a/groovy-0.1.2-py3-none-any.whl", hash = "sha256:7f7975bab18c729a257a8b1ae9dcd70b7cafb1720481beae47719af57c35fa64", size = 14090, upload-time = "2025-02-28T20:24:55.152Z" }, +] + +[[package]] +name = "grpcio" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/c1/4c9a2e0e6b0aaf02781404cad2f79211f989f2c827cf672a4a48d1604d3e/grpcio-1.84.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:b5c6f20d657ae09ae4e30d9d3a21edd13f1219d58cc6f999b9d1bb63be9c1baa", size = 6415756, upload-time = "2026-09-14T06:57:39.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/131e7007bdee9acb77a8dbe8a16fa9fef75f88c1695242d8ee0993ac2d3d/grpcio-1.84.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:406583b4e8fb2282ebd392e12b963e601c1f82e07125a8c2cb5b144e7e024796", size = 12339195, upload-time = "2026-09-14T06:57:42.373Z" }, + { url = "https://files.pythonhosted.org/packages/db/d1/a7b7cda98fcab9b3d2916204a872d87371158a7a34e41768f524584fb64d/grpcio-1.84.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fbdbcd06986ede3ce584083b1dc2afe6808e8943e5cf50ad11183c03aceda25a", size = 6984468, upload-time = "2026-09-14T06:57:45.035Z" }, + { url = "https://files.pythonhosted.org/packages/19/81/c5be83e3ac9416f73c4c51fe1ea9c41a0c42fc3509e3505faa46f5046abe/grpcio-1.84.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:23e6e8e8a75cff88e0a793bfd3becea03a13e2763ae90c1ff573bc19ca5b429a", size = 7749432, upload-time = "2026-09-14T06:57:47.395Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bf/258cd7c0a7ed92745dc93c31666d462d05b702807a689744bd49fb833bde/grpcio-1.84.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b44f0a0fc7bc6677d38cc80bca1a32814ce6c8f200fb8b3c1a61c9d77eaefbf3", size = 7156115, upload-time = "2026-09-14T06:57:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4b/7f829418dbfcf91b875e55e2973f1059a95decb4f081313416317ef04ec1/grpcio-1.84.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:210e4c32f907045eb8158273e60c6ab69a3947697df6245dbda381f26c59485b", size = 7708010, upload-time = "2026-09-14T06:57:52.496Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/9932e2fec6a04205f8bf3f8f4d2020479dcdac88feb6f93822ed31bf0eba/grpcio-1.84.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a71d24f40b0cc6798feaa978c7411dc1135b7018e9fc0442db611c139bf58344", size = 8759980, upload-time = "2026-09-14T06:57:55.312Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5c/b67407c6dbc480dfc0715f6eccdb1061e7c88d85f9a330a241d357a538c5/grpcio-1.84.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f6c972474ce691aca74e58d17625450cef153dc4760364cadeb167983ea6d589", size = 8124904, upload-time = "2026-09-14T06:57:58.569Z" }, + { url = "https://files.pythonhosted.org/packages/02/37/2bfdae2df8dfcfc0df619b628e0c7153ce703adae827243f44720322ccc1/grpcio-1.84.0-cp312-cp312-win32.whl", hash = "sha256:0d532ade4486dad9b302ffa4d4683d67561051c26d17c4023322845e9fa10140", size = 4478915, upload-time = "2026-09-14T06:58:00.714Z" }, + { url = "https://files.pythonhosted.org/packages/85/2c/309268b7b39f6deb2342f634841e105623a0b67982e8b10ec516782ff1c6/grpcio-1.84.0-cp312-cp312-win_amd64.whl", hash = "sha256:49717e857899f4136d7657bf5aded61ac479110a075438290923a4d86af7cd02", size = 5253534, upload-time = "2026-09-14T06:58:03.336Z" }, + { url = "https://files.pythonhosted.org/packages/5d/51/40f99701adb01d4e5316a2aaf13838da1a24d5c879cd8c95156d7c364454/grpcio-1.84.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:209414080da8c20af94df1395b635da52dd57b5edc9e917e1deca0dc1c4bb55e", size = 6427619, upload-time = "2026-09-14T06:58:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4b/ed8e22a1237e6b2be6ef4f221d074a5b0e0dd8a0da8c944c04aea731f0eb/grpcio-1.84.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e41c3993eee896c617dbd8a505085d28b6e84a0445ed9a1f40f95808473cf678", size = 12336549, upload-time = "2026-09-14T06:58:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/d3/50/00165b05cd73f45996748ea67ce9e55d08936f2fea94a7fd8541cc2d0e54/grpcio-1.84.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fff5ef3fe1bba7d6147e5f19e01e5e122ac2c076486887ddcb8d42e663400fbe", size = 6989458, upload-time = "2026-09-14T06:58:11.884Z" }, + { url = "https://files.pythonhosted.org/packages/26/38/d0486230e684d916f97429a53041db88410e662a38f2a8d09e2d90375840/grpcio-1.84.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8c62888c3e49debf37ad9773e3c02f77b0c1e811f8fb0962f2b6c3bbab5b97a", size = 7757778, upload-time = "2026-09-14T06:58:14.849Z" }, + { url = "https://files.pythonhosted.org/packages/da/56/548a643decb059ca244499c675ae2c13a15f523ba94592c2774bd80a13c1/grpcio-1.84.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:986e9751d416d7a6eaa2fecdac38da63153d63a4b340ba7d624889c490451500", size = 7159572, upload-time = "2026-09-14T06:58:17.87Z" }, + { url = "https://files.pythonhosted.org/packages/db/f5/42caac81a79ec680f1f7a8eaf7ca90d2f93936ce0c3a073141ba96757f77/grpcio-1.84.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5933a052946873d01a42119a05420d669bdca436aeba2d1851988ccb12b421c0", size = 7710547, upload-time = "2026-09-14T06:58:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/57/a4/828ad990b2410fee0a55cc73aa1bf98eb5b911c54847374ef4f24b9e877b/grpcio-1.84.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e094dd21f077af8194923fc263cad872eaa1802bb0156fd7e5ae18e99cd86715", size = 8761519, upload-time = "2026-09-14T06:58:23.875Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/1f91af098919eaf5d80d5a61126ad9fae074e5190c25a3014ce1d8d0d890/grpcio-1.84.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08735e3d08d24ab3132cf87e2e5dea8746cabcc7d676c2b0b7362f195feef9d9", size = 8121424, upload-time = "2026-09-14T06:58:27.006Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8f/77fd4a7a913b636785479922349c4cb98d94d05d15652e556b3ca0df6663/grpcio-1.84.0-cp313-cp313-win32.whl", hash = "sha256:70bb4ce8be0c5606bec259cbd7152374470396413b7863a658a08c849e6b29ff", size = 4477974, upload-time = "2026-09-14T06:58:29.528Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/1fa59ddbfc8898e5518d1447e46f771f387f0ed6132ad531395338e51a5c/grpcio-1.84.0-cp313-cp313-win_amd64.whl", hash = "sha256:b61692f0069b3eee2fc8a3a1b7f6c044df9e03fede6ce69b3ca832e1c39f26c5", size = 5255326, upload-time = "2026-09-14T06:58:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/26/6f/e25ca89ca5b0b7b95464c907a5c21a77c0ac8c4ee1dca164c4dd8f153ddb/grpcio-1.84.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:026d757df86c5b7a41de8200b9a2cda454aaa5004cb0c7e3374c66eb82f61499", size = 6428207, upload-time = "2026-09-14T06:58:34.401Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b4/6b76b429f3f9b901cdbc306c81364d708bc957f847a05cbd1046cd2d05d8/grpcio-1.84.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3de427b05f244ba2c2a9bdc67e7a6731c8340811524ecc4435466549f8af1d17", size = 12342420, upload-time = "2026-09-14T06:58:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/af/64/ac86d638ba7f73bee0dccb608ba551d4f63adf75151f00d2c43e46d3979e/grpcio-1.84.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e90e3bdf7b5eac005fef631adae9cafde16f922def207b80a7c46b253c18ad20", size = 6998396, upload-time = "2026-09-14T06:58:40.535Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/fa12e9ec9d7ebf8cc3e81428fa9e1ca0d30d22d546ce2baa4c64bc917cbc/grpcio-1.84.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e88d304f094f4937bc27ec6a435e218a084168f11ec630c8d5d39b431d08d81d", size = 7757538, upload-time = "2026-09-14T06:58:43.297Z" }, + { url = "https://files.pythonhosted.org/packages/21/d7/94240c7fae121ff1f116dcf04a3b7ee0216a06832c704310363f72638d4c/grpcio-1.84.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:57dc36a5ab0e676f5f6e171de2917fd0aef73f32a9aaf23956bfe19997a30bd1", size = 7161480, upload-time = "2026-09-14T06:58:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/c9/7033e95d4b344969818b09185721c7608b47fc2498d97b5e4eec4995dbf3/grpcio-1.84.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5deda5b4bf62769eb98c119cca43d40e1231e34846b19db5cdea821d446a2253", size = 7720191, upload-time = "2026-09-14T06:58:48.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/22/b45df2deba81d55069076859480bae7109c9eec02bce5515c799530cc2aa/grpcio-1.84.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9bab4cf571653a8afffb83ce21aa27b51dfe629b526b7b6adec35491fe1fc2ea", size = 8762792, upload-time = "2026-09-14T06:58:51.068Z" }, + { url = "https://files.pythonhosted.org/packages/de/c4/3e1c3d6155c16b8737cc31d5b477d6cf1fc7cdd10d58320cf0ec9b446f42/grpcio-1.84.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5559b492007dc09b4de9b95dab05f0b5e53547aad230cf07e46c7dd017a3be5", size = 8123299, upload-time = "2026-09-14T06:58:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/f4864de5b815e5ba18858771f99381a398fac14117f89ef5291ed43d3c4e/grpcio-1.84.0-cp314-cp314-win32.whl", hash = "sha256:2c024da73b296f040b8360e60bd73a659b230093684a438da0e1260f34cc724e", size = 4562560, upload-time = "2026-09-14T06:58:56.894Z" }, + { url = "https://files.pythonhosted.org/packages/44/03/640811d4d8c84f5e603995c5a9bab725223aa472cad9ca4286c3bbf1c3e3/grpcio-1.84.0-cp314-cp314-win_amd64.whl", hash = "sha256:800b7e00d92553313c0463c200087930aa78678ec1d528193aeb50906f55989b", size = 5394092, upload-time = "2026-09-14T06:58:59.61Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1a/9e3d2c9f005f680f03308fa894b1db91d4ab3f0fe65ff630c69561e91e95/grpcio-1.84.0-cp315-cp315-linux_armv7l.whl", hash = "sha256:47ecf0d9b81d981f07b61bd89eced9d2582f5eaacc3aaa36ad27f81aef70a27f", size = 6428252, upload-time = "2026-09-14T06:59:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/77/34/0bc9f52ebf091311651eeab3a452fb557985604a3088cb5406f4d6df85d3/grpcio-1.84.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:61386101ecaa096b694d0dd278caf99a56aeec78440cc17e918eef0b50f2d567", size = 12359488, upload-time = "2026-09-14T06:59:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/c31052712f241cb6ecae9c226fabd519b7f8c64a7a40bac27e9ca0405b78/grpcio-1.84.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6d178ba6dc8e82976c184b65fddde172d054c17237993a3e083efe4f134d55b", size = 7019339, upload-time = "2026-09-14T06:59:08.76Z" }, + { url = "https://files.pythonhosted.org/packages/55/b9/b9b33ea4f1eb4cad28833cade604febf357385b5ebb0c9c7562d020e167a/grpcio-1.84.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:15bb76489e337fc492685c9758e2fd4d4ab516b901ad830dc5a91987decf00be", size = 7107974, upload-time = "2026-09-14T06:59:11.568Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9e/799d4c45db91bbdcd8c54b3982932dbcf3d059f7ce67dca3e8540faa1ece/grpcio-1.84.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82da34ae4f639c73ac46e521e00c0a49bf86f717b9fb1f405f133e98731e38dc", size = 7200036, upload-time = "2026-09-14T06:59:14.401Z" }, + { url = "https://files.pythonhosted.org/packages/45/dc/dcfdd13ada41aff9098f0c2c6f260eb7debbc88b84b7e5fcbd085165427d/grpcio-1.84.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b73836ba0e16fcbb57c31cf6cbc2907c8d8c790b83679df454b74bd15e0be04", size = 7742281, upload-time = "2026-09-14T06:59:17.348Z" }, + { url = "https://files.pythonhosted.org/packages/55/31/75eab2ec77b80804bc5e21cec99b57598e726fca6484cd3e8920a97639d5/grpcio-1.84.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:42959bd50dd660ffc3f2a9bec15a6da4f9aaa0dda555d59ff2d2e80b908456a8", size = 8113629, upload-time = "2026-09-14T06:59:20.584Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/fdcf6bdc1df9ca11679a1187bef8e6b81df31a2baae69497e17344f05ea3/grpcio-1.84.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:659728f20fc7a0933ed7b1945435e31014b97ab8a5a7edcbaa70da4794aeb191", size = 8152972, upload-time = "2026-09-14T06:59:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cf/6720e720bfa80fcb1ace873f66724eb3c8b03bba2fa078a30c12cab3212e/grpcio-1.84.0-cp315-cp315-win32.whl", hash = "sha256:edb6f87fc60ff438557291501b3e16c7a77c3b01a52d782cf276dccc7c5dd89c", size = 4561981, upload-time = "2026-09-14T06:59:27.275Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/69d8a709df225bc2e06e028e9465166b174c24b3da07cc72d9a5ddc63194/grpcio-1.84.0-cp315-cp315-win_amd64.whl", hash = "sha256:4119efa6519871719ad81f33bc95ab87857dcb1c5801f30a6e592f2c41164169", size = 5394757, upload-time = "2026-09-14T06:59:30.118Z" }, +] + +[[package]] +name = "grpclib" +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h2" }, + { name = "multidict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/28/5a2c299ec82a876a252c5919aa895a6f1d1d35c96417c5ce4a4660dc3a80/grpclib-0.4.9.tar.gz", hash = "sha256:cc589c330fa81004c6400a52a566407574498cb5b055fa927013361e21466c46", size = 84798, upload-time = "2025-12-14T22:23:14.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/90/b0cbbd9efcc82816c58f31a34963071aa19fb792a212a5d9caf8e0fc3097/grpclib-0.4.9-py3-none-any.whl", hash = "sha256:7762ec1c8ed94dfad597475152dd35cbd11aecaaca2f243e29702435ca24cf0e", size = 77063, upload-time = "2025-12-14T22:23:13.224Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "harbor" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dirhash" }, + { name = "fastapi" }, + { name = "filelock" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "litellm" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "shortuuid" }, + { name = "supabase" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typer" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/3a/b05d0e8752d5d531e56fbb2955f0e656c0a9b5fbcd202dbad7313b29d4df/harbor-0.23.0.tar.gz", hash = "sha256:a8b87e98db59877228bb36d2c24757cc422a3144ffaf31a1f88955c65c911e4d", size = 1907464, upload-time = "2026-09-12T04:55:11.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/c7/607ff037dff1f40d1f941b9854742d66fd43630274fd4e7b8de8480dad34/harbor-0.23.0-py3-none-any.whl", hash = "sha256:8747400dbb2a5e2298e1338e17e88eba38433c0433fd700f34d1a9021bba5c37", size = 2142100, upload-time = "2026-09-12T04:55:10.014Z" }, +] + +[package.optional-dependencies] +beam = [ + { name = "beam-client" }, + { name = "dockerfile-parse" }, +] +blaxel = [ + { name = "blaxel" }, + { name = "dockerfile-parse" }, +] +cua = [ + { name = "cua-train" }, +] +cwsandbox = [ + { name = "cwsandbox" }, +] +daytona = [ + { name = "daytona" }, +] +e2b = [ + { name = "dockerfile-parse" }, + { name = "e2b" }, +] +ec2 = [ + { name = "boto3" }, +] +gke = [ + { name = "kubernetes" }, +] +islo = [ + { name = "dockerfile-parse" }, + { name = "islo" }, +] +modal = [ + { name = "dockerfile-parse" }, + { name = "modal" }, +] +novita = [ + { name = "dockerfile-parse" }, + { name = "novita-sandbox" }, +] +opensandbox = [ + { name = "opensandbox" }, +] +runloop = [ + { name = "dockerfile-parse" }, + { name = "runloop-api-client" }, +] +use-computer = [ + { name = "use-computer" }, +] + +[[package]] +name = "hf-gradio" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gradio-client" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/86/c9694b7cfada5780e75769e60dc161a161f4dd7fc91b61db5e3a3338bef9/hf_gradio-0.4.1.tar.gz", hash = "sha256:a017d942618f0d495a58ee4563047fa04bef614c00e0cb789a9a6d0633cffa7b", size = 6560, upload-time = "2026-04-22T14:01:32.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/2d/afff2ee87e75d8eb85c92bb8cf0e15b05c23c2ebd8fd8dec781d8601ed7f/hf_gradio-0.4.1-py3-none-any.whl", hash = "sha256:76b8cb8be6abe62d74c1ad2d35b42f0629db89aa9e1a8d033cecfe7c856eeab3", size = 4482, upload-time = "2026-04-17T19:53:31.827Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +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" } +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" }, +] + +[[package]] +name = "httpx-ws" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/cd/ca91a07ae446451f7476bf3fcc909e98cb942ff032ebfda0e3fe449aca7b/httpx_ws-0.9.0.tar.gz", hash = "sha256:797373326f70eec1ae96f6e43ae9f12002fd7d73aee139a4985eaab964338a08", size = 107105, upload-time = "2026-03-28T14:11:10.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f8/a6bc80313a9e93c888fa10534dfce2ad76ff86911b6f485777ce6de6a073/httpx_ws-0.9.0-py3-none-any.whl", hash = "sha256:71640d2fb1bf9a225775015b33cd755cfd4c5f7e21c885192fe3adc4c387b248", size = 15759, upload-time = "2026-03-28T14:11:11.887Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/f0/61159db90b5cd275d55516fe27920828e7d3be4053fdbdb27c3f70e5f1ef/huggingface_hub-1.31.0.tar.gz", hash = "sha256:f8e9e710a210613fa5d0f26bba6da05ef4aef9fba5a0f23f508f5ac4d08b6f90", size = 968039, upload-time = "2026-09-10T10:27:22.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/7f/3f886a625043b77312b80da2f2bf00b5ecbf5a73061af1aa0259cd258c9d/huggingface_hub-1.31.0-py3-none-any.whl", hash = "sha256:9dbb6a503cbe2494ea666695207e7262d410659e09134059deb83e5480864667", size = 798313, upload-time = "2026-09-10T10:27:20.798Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/72/c600ae4f68c28fc19f9c31b9403053e5dbb8cace2e6842c7b7c3e4d42fe9/importlib_metadata-8.9.0.tar.gz", hash = "sha256:58850626cef4bd2df100378b0f2aea9724a7b92f10770d547725b047078f99ee", size = 56140, upload-time = "2026-03-20T16:56:26.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/f9/97f2ca8bb3ec6e4b1d64f983ebe98b9a192faddff67fac3d6303a537e670/importlib_metadata-8.9.0-py3-none-any.whl", hash = "sha256:e0f761b6ea91ced3b0844c14c9d955224d538105921f8e6754c00f6ca79fba7f", size = 27220, upload-time = "2026-03-20T16:56:25.07Z" }, +] + +[[package]] +name = "islo" +version = "0.3.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/87/7678bf4559e775c58e80df263faff46b9f1cef626540bc852dbb8157ff8f/islo-0.3.19.tar.gz", hash = "sha256:b3f9fa7b996551eb6e77b6c0202f3af0c1d21935eefd4933b0232b271a464612", size = 155588, upload-time = "2026-08-27T12:48:20.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/b5/a8ecdea027ccc20f5ab44042c8629cd22588ca7c33cc199c5f103f92ead4/islo-0.3.19-py3-none-any.whl", hash = "sha256:ab7e9bab5678e486e0bdfa8ed25e40fe2eef95c5748c3d666b5c6b1226354126", size = 372082, upload-time = "2026-08-27T12:48:18.582Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +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" } +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" }, +] + +[[package]] +name = "jiter" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "joserfc" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { 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" } +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" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +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" } +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" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "kubernetes" +version = "36.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "durationpy" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/57/b07b96353f902aa1bdbe00e878e3a12a137977d03a962479785576aa8ec9/kubernetes-36.0.3.tar.gz", hash = "sha256:36993ed25ce59b789c9341473a228fcf268504a2fec7c2b2b1531d73072e5ce7", size = 2337528, upload-time = "2026-07-13T20:38:12.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/30/a96d47df739689ac0001ade0afefc16e3b477fc2fb426b568515fdc8afce/kubernetes-36.0.3-py2.py3-none-any.whl", hash = "sha256:8fde9241c4b298e6374a069dcf728359b4e72c2fb29489a975ba4e1c047cf10f", size = 4618066, upload-time = "2026-07-13T20:38:10.172Z" }, +] + +[[package]] +name = "litellm" +version = "1.101.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "boto3" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/c0/08a31c8c5f7fce96e98e2a7f818f446d0468da484228a7450cd2b6f71dcf/litellm-1.101.0.tar.gz", hash = "sha256:734ab2b8cad6a3b582d52d9c9c5fcab759eb382b93935ef808fda0e16d822ac3", size = 17447699, upload-time = "2026-09-14T23:15:36.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/cf/6b3a687ab0ab4caacc35faaf695f6862654e21e2a5dc703fa9e1df6d7069/litellm-1.101.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7cc623a224c6f11a04367a682b095a1e08e5f6da75c990a650910db5f9777819", size = 23777389, upload-time = "2026-09-14T23:15:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/42/f6/d5bc3aa1944244fd186e65ca16e62158f0f474b40f99a89e568a5d441739/litellm-1.101.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d4064024151ff2877e542b56c6bb6a39e0c3e6651abf4639af9346a678586e52", size = 23434831, upload-time = "2026-09-14T23:15:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/ba/12/755e4d497b975911449102b8b74d3d4f770137c4467e3e8fe304adf22d66/litellm-1.101.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:cdf3351e394206e785bf339f1a39d9358ddf4e4f38129750bfdb5e4f5ceab8ef", size = 23568059, upload-time = "2026-09-14T23:15:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0e/561d7a314a08940688814be590544907fdfde9225dce35badbd79fc9ad4c/litellm-1.101.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:140ee0997324b8fd2405f7f8c26f42c0f364996dc4030187344a90cf27916667", size = 23944856, upload-time = "2026-09-14T23:15:26.417Z" }, + { url = "https://files.pythonhosted.org/packages/f1/df/d640eb6cf4a304be4ec289b72c1b1d9bd66c73e90167211d3a09ac37b2d8/litellm-1.101.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ad013161074676fc91b91d828f696300132cf3936d3f386c4701a41710d70aa3", size = 23643397, upload-time = "2026-09-14T23:15:28.664Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b6/9d69e95de9465cd23651c6623518d5e711d8776a0e58e6ea62066c9d9470/litellm-1.101.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d88053148c5c6e016e495273eeaf8f3a29779b60d18977ec42e2a6bbd2d8eb", size = 24042718, upload-time = "2026-09-14T23:15:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b5/0a54001992c9a8d991f6ddd7532e3c4cb7dbdcc94220a37586513aab6f89/litellm-1.101.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5601ae404b0f1e38ce1e65c75f880254485942d9c824453e3926f2323457fd5", size = 23844988, upload-time = "2026-09-14T23:15:33.632Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "mcp" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "modal" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cbor2" }, + { name = "certifi" }, + { name = "click" }, + { name = "grpclib" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "synchronicity" }, + { name = "toml" }, + { name = "types-certifi" }, + { name = "types-toml" }, + { name = "typing-extensions" }, + { name = "watchfiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a5/9e322043716e511b7f6c9120804f33b92e21a818bf4709b2d98b3c76d665/modal-1.5.5.tar.gz", hash = "sha256:30df363ed1898cc3d91a09ff3f95c38ab043f6b6294011b01085312c6a0ac777", size = 870356, upload-time = "2026-08-28T19:51:34.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/19/b3dca8baec119126058b12ca620e44022e4d08a091956668bd04180c89a7/modal-1.5.5-py3-none-any.whl", hash = "sha256:8d10d3ee09818aaba1973b73ce2521ab8961b63a29b5b52e3ff0d25e7a74808e", size = 985163, upload-time = "2026-08-28T19:51:32.404Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "multidict" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/95/989c1b5ca17b72128661530cd6e351a0a83cda9a4d6c036e9ed976c18931/multidict-6.8.0.tar.gz", hash = "sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37", size = 122412, upload-time = "2026-09-09T13:57:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/83/a4621577679149ea001806f5963f3fc687c391c1bd5217157be2278863f5/multidict-6.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836", size = 84146, upload-time = "2026-09-09T13:53:49.163Z" }, + { url = "https://files.pythonhosted.org/packages/09/00/236b063f3e606055a3a9ba8faa5d40e6c688b059a58056b055f213476f46/multidict-6.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b", size = 51049, upload-time = "2026-09-09T13:53:50.46Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/954b139bfa969855f2d4cb5ae7b7d44dd7106f754305b6e21a9068213aa7/multidict-6.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7", size = 49362, upload-time = "2026-09-09T13:53:51.878Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/8774f5b3f6d5266ecd1117876e04b405f0f1ce19aa750b35a826efe6cfe4/multidict-6.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5", size = 278619, upload-time = "2026-09-09T13:53:53.44Z" }, + { url = "https://files.pythonhosted.org/packages/db/47/736080fec911ed9f2dd57ccab5a8145e4f17c4987de0bfc27bee20e4d170/multidict-6.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a", size = 283771, upload-time = "2026-09-09T13:53:55.048Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d5/b7f41f59b0583f092602308a5e7c16ec5efd00d60214b22511e89a38dd19/multidict-6.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40", size = 262108, upload-time = "2026-09-09T13:53:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/a52dc06c6e2598672308e3d392fd85b837b23c25dda459bedaea84985080/multidict-6.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d", size = 289899, upload-time = "2026-09-09T13:53:58.415Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/00cda7983f37d119b86f1f89d5b4cf771ecb6d0fedeb9a0971758d6d6d4a/multidict-6.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874", size = 293025, upload-time = "2026-09-09T13:53:59.973Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c7/4544cc02e45bbfac4d8788b05379bb360021fd8c53fa74b0f624126ac188/multidict-6.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b", size = 287410, upload-time = "2026-09-09T13:54:01.652Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/7abed90b8eba381842235bfa6f4d730204fd7deb374fc87e3ec9b2c2b4ac/multidict-6.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c", size = 255878, upload-time = "2026-09-09T13:54:03.366Z" }, + { url = "https://files.pythonhosted.org/packages/25/3e/73fae10e15fc4d711975337caff7e494c87de5d0189afe3518b21b945326/multidict-6.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081", size = 277831, upload-time = "2026-09-09T13:54:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/c5/cf/01cfc81492933331147004861bdff201d8adeba8485ecd8f490e755fe7e8/multidict-6.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f", size = 275096, upload-time = "2026-09-09T13:54:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/de/59/e9a3773b17297fa1e38fd4b3c6f5f2f458380796be62eca7d0d77c250618/multidict-6.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b", size = 279803, upload-time = "2026-09-09T13:54:08.389Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/2d712a2605b3971908e3b4f5eb6f98c353d9991e106f684d0e08ae581814/multidict-6.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742", size = 284595, upload-time = "2026-09-09T13:54:10.17Z" }, + { url = "https://files.pythonhosted.org/packages/58/6c/21aded8586e552b29892268c576e5745d1a894c5451c9866ca3c06b7ec50/multidict-6.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39", size = 252641, upload-time = "2026-09-09T13:54:11.811Z" }, + { url = "https://files.pythonhosted.org/packages/2a/70/56a415ae0a45e5eae2ec817d46aeb72a1ae777863621c85f1f39d329275b/multidict-6.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0", size = 283369, upload-time = "2026-09-09T13:54:13.59Z" }, + { url = "https://files.pythonhosted.org/packages/08/7e/7b7cd611fd94bf2f6bd16244c50495867ba394d5baaf8e6e487d39494ab3/multidict-6.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb", size = 281653, upload-time = "2026-09-09T13:54:15.174Z" }, + { url = "https://files.pythonhosted.org/packages/33/4a/b19a5892ef2ef6c68ae278b4f1504b82e01037baedd92c55d37e55ecad00/multidict-6.8.0-cp312-cp312-win32.whl", hash = "sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90", size = 47936, upload-time = "2026-09-09T13:54:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/29/00/1952f9f282aa71e7c3db3a6b47afb689d0ddf283dbded7e6326a91d421c9/multidict-6.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630", size = 51723, upload-time = "2026-09-09T13:54:18.05Z" }, + { url = "https://files.pythonhosted.org/packages/49/b5/c9d57dbafe25b8f3460ce2961c968539a81ff7a70160c44dcfd4255cbcd1/multidict-6.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395", size = 48492, upload-time = "2026-09-09T13:54:19.42Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/d7112c2dd7db02677097be72fb65542f51a5aa73cb472b87ec211ba9e0dd/multidict-6.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f", size = 54197, upload-time = "2026-09-09T13:54:20.814Z" }, + { url = "https://files.pythonhosted.org/packages/ae/24/876015abbcb4a179d946579eb77b778eb5a948fc8381bc7928ba895bc051/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943", size = 47787, upload-time = "2026-09-09T13:54:22.51Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/ceb7d25f8a567599db2eb19b08cac58d67ff553cff42dcadbea9aba56a20/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9", size = 48815, upload-time = "2026-09-09T13:54:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/18/e3/e1c6e9c3818c34b782f23ce5fdba3eaa34ec6750dc53078dfac80fa59be7/multidict-6.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916", size = 83484, upload-time = "2026-09-09T13:54:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a0/c23f78a4badee9a5b3e760495c661c62a92c340a1dfd00f829cd16e256bb/multidict-6.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435", size = 50763, upload-time = "2026-09-09T13:54:27.135Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/db552d402a3f6b650f5d3ae11b82b93833836aebb51bcda22d8691121129/multidict-6.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da", size = 49029, upload-time = "2026-09-09T13:54:28.483Z" }, + { url = "https://files.pythonhosted.org/packages/01/b4/546853fba19dcef77cdf91fc173faf0b02284a49106cf250511166b4ec5c/multidict-6.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8", size = 278863, upload-time = "2026-09-09T13:54:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3f/4b52dac7db547936eb762123ac1d99df23f92fdb358bae600e322f611247/multidict-6.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33", size = 283915, upload-time = "2026-09-09T13:54:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6e/c0dfbf170e49a91bcb9ce850d51cb98357f3033c5227529200ca7625853e/multidict-6.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e", size = 260704, upload-time = "2026-09-09T13:54:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/56973a060ab8dfc2e80bb6797682f6577aff7123cdb1de1a568670ae3499/multidict-6.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f", size = 290243, upload-time = "2026-09-09T13:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d5/67/69112989f131bdea4a87b74e82cb0a2daf37880cd92b0e6f0420020adceb/multidict-6.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735", size = 291131, upload-time = "2026-09-09T13:54:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/75/9435f68b0cfc442d4917de85c26f2b2e1292630883414a25576083fa2469/multidict-6.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384", size = 287551, upload-time = "2026-09-09T13:54:38.835Z" }, + { url = "https://files.pythonhosted.org/packages/13/08/2ee4838081d6587849611aa7ec722c4cb2469e912fd0eaee980e7bac064c/multidict-6.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18", size = 254591, upload-time = "2026-09-09T13:54:40.806Z" }, + { url = "https://files.pythonhosted.org/packages/94/f1/05673b51191f77f4198b8e4b35f16ea71c0300c72ca8aa027a66a61b6edc/multidict-6.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238", size = 278204, upload-time = "2026-09-09T13:54:42.672Z" }, + { url = "https://files.pythonhosted.org/packages/45/4f/b6cf74322b3fbd3e011a1e903730191922291a7779f6d404114c2189b806/multidict-6.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e", size = 275600, upload-time = "2026-09-09T13:54:44.348Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ab/958bbb04377159ff03c7314cd9d8a48dd6fc4f78c840589c22ab155ee9c7/multidict-6.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e", size = 279793, upload-time = "2026-09-09T13:54:46.086Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/706605ab0dfc4179748ee7949829e63c6f14ae28667aceeefaf2c701807f/multidict-6.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c", size = 284751, upload-time = "2026-09-09T13:54:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a5/567e36c013ad023546de633079c6b22101dd43226b193cba00e6399703be/multidict-6.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc", size = 250812, upload-time = "2026-09-09T13:54:49.509Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/6b0b64aa0cd346b07831dabaa6ccda0e73014c5df044b68baa763f0f0552/multidict-6.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc", size = 281606, upload-time = "2026-09-09T13:54:51.288Z" }, + { url = "https://files.pythonhosted.org/packages/31/8c/b846b6796f26d496efb07fedef2b69f6de533da32a56f12d236722a96157/multidict-6.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5", size = 281733, upload-time = "2026-09-09T13:54:53.05Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/bf14a39d4af5697fd9404baaf70a0aeeb82d258b95de5cb16b1a7f98ae6f/multidict-6.8.0-cp313-cp313-win32.whl", hash = "sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20", size = 47738, upload-time = "2026-09-09T13:54:54.676Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/598511a5741a3cb374971b3b02eda8a09896118ba528a54795f7e7e8bfb4/multidict-6.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706", size = 51609, upload-time = "2026-09-09T13:54:56.38Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b7/6f5c1bd4ffe42d4a6db0f2f65491d4088e9c25c990358fb31a614621d664/multidict-6.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316", size = 48280, upload-time = "2026-09-09T13:54:58.03Z" }, + { url = "https://files.pythonhosted.org/packages/ab/85/153341590e233a967c1d6791a83402d01693dec0f4c1f695606ef16c7ed2/multidict-6.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc", size = 53758, upload-time = "2026-09-09T13:54:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ff/44f72d516ece0398683ef52061797d83a74b16b8c1e4587408e97959d783/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab", size = 47495, upload-time = "2026-09-09T13:55:01.382Z" }, + { url = "https://files.pythonhosted.org/packages/50/5f/6e118f761b024dd35d26c2fe7ba41572bb0e8ac5f8cfccbbcbc2ff76da4e/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d", size = 48540, upload-time = "2026-09-09T13:55:02.989Z" }, + { url = "https://files.pythonhosted.org/packages/e8/4b/3eed744491b32f0e318e7db89dc06858732362f706e8d045fa9ab51a343a/multidict-6.8.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38", size = 83130, upload-time = "2026-09-09T13:55:04.554Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/95c2c0ddcccb9a41ffbaa5df8ea059a8ff81916b7617a8847ecd89ed8061/multidict-6.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11", size = 50574, upload-time = "2026-09-09T13:55:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b7/f4f4989594f99bc121ad9277090c4e49819b08ab1a96e132b628a9e10b7d/multidict-6.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d", size = 48786, upload-time = "2026-09-09T13:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/b2/86/f1d86a0222f31fb3df8eef3d6c9abf7e8d65d49edd8d0d7e7afaf23d23cc/multidict-6.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc", size = 276670, upload-time = "2026-09-09T13:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/03/50/6945c50f86a978b2bcace9ca344165ff80883be47d984489bbba8fa0ab20/multidict-6.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef", size = 279339, upload-time = "2026-09-09T13:55:11.685Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2c/e649889ba23fd1f4442a85427b99d9e6261226b2ac31914aa7f5b241d947/multidict-6.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602", size = 252549, upload-time = "2026-09-09T13:55:13.527Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f8/1023b66e011b1395fb160dabb0f0608ef67e569f0bdb2c1d5ac9b2f2adc6/multidict-6.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c", size = 286203, upload-time = "2026-09-09T13:55:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6c/48aea545cbda6d0444848ec23d988c13b86538a00a1b7d3868cc2382ff94/multidict-6.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a", size = 285039, upload-time = "2026-09-09T13:55:16.928Z" }, + { url = "https://files.pythonhosted.org/packages/68/2a/066123b17291671bf67d2a5c65ee81a48de53913bd1b1578791519eacdb0/multidict-6.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7", size = 281075, upload-time = "2026-09-09T13:55:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/47/20/4f0b2c485da2e8a659cc677717a3745872918c9c85064491a1ef75d7a3bf/multidict-6.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af", size = 250431, upload-time = "2026-09-09T13:55:21.07Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c7/b9a288901577aa0b82c33c64d52246c88076d260ad7b6c16b021ca0f8e99/multidict-6.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee", size = 273891, upload-time = "2026-09-09T13:55:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/da/51/0ba50cab2cfd067988de2abb73f23076ac727fe18d03f1368a59def64727/multidict-6.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364", size = 265262, upload-time = "2026-09-09T13:55:24.77Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d6/e5be1117dbca6eb9ce231142b7e20599418bb3500147db51bf844ce8afcb/multidict-6.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c", size = 278033, upload-time = "2026-09-09T13:55:26.67Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/cad0afaec3caa56ea2c1ceed43c164d62ad3e83e950daf0d0c87bcf9dca7/multidict-6.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd", size = 281717, upload-time = "2026-09-09T13:55:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d2/025702df0b69b856db70a4d66f77622f51c3d99771ec9a07f3ca80f7e098/multidict-6.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891", size = 247124, upload-time = "2026-09-09T13:55:30.497Z" }, + { url = "https://files.pythonhosted.org/packages/b4/96/9dddca563f06a921956389c0bc9b894355b98b0bdf62299e2560c50afb6d/multidict-6.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d", size = 275954, upload-time = "2026-09-09T13:55:32.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/8b2f1f2a774a955665f268340a2b59db7020c5f12baac02ae9ef1b1660cf/multidict-6.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb", size = 275508, upload-time = "2026-09-09T13:55:34.368Z" }, + { url = "https://files.pythonhosted.org/packages/b6/1a/e2cabdfc0880a61a99d2b8bc361035036fb5a2c6af31ea3fa054ba1065c5/multidict-6.8.0-cp314-cp314-win32.whl", hash = "sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52", size = 46938, upload-time = "2026-09-09T13:55:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/b9/7c/11234bcba62c22a58f2ba168499cfe3531f49de3edd5090d04a8c6cdc936/multidict-6.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a", size = 50291, upload-time = "2026-09-09T13:55:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/ab/61/793668439df924752a8137d6db0de97ed1add494779b01e4764dfc60571b/multidict-6.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f", size = 47622, upload-time = "2026-09-09T13:55:39.335Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/3c091b929e6b5b2f6e0eba2232178e76d4503c8b96b92dfc281ff1d823be/multidict-6.8.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04", size = 88789, upload-time = "2026-09-09T13:55:41.086Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d7/3df83fab22dd64615db71e3b3cc1346b581d1459719637ce52144f9f6558/multidict-6.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab", size = 53399, upload-time = "2026-09-09T13:55:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/2d/78/41bd04c04b0aed16540c4856c9e012afc1c254298da154398308df05e26a/multidict-6.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9", size = 51597, upload-time = "2026-09-09T13:55:44.569Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6b/7bc4cdddf624e1e7e0231734b1331729ea46df10d7c8fd3fce79756e7d0e/multidict-6.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e", size = 264391, upload-time = "2026-09-09T13:55:46.548Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/d2a946e5938771e92c39354563e535ef6bc6dfe399dd4307c6df8dfea183/multidict-6.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58", size = 264680, upload-time = "2026-09-09T13:55:49.915Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/3f9e981c42e7eb9329918523f0f9362ceb0ac3ee0ee1165c28f674249d75/multidict-6.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91", size = 235420, upload-time = "2026-09-09T13:55:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e1/a3a33a039fb6d381800ae5d1d587b697b8c27fcdfe48819420f08703acba/multidict-6.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4", size = 270309, upload-time = "2026-09-09T13:55:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/95/5d/8b06724a957f2e480f159b9550988a67810fbe9555a09c5f6a2a4b829607/multidict-6.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad", size = 275169, upload-time = "2026-09-09T13:55:55.948Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/8f3dfe2ffa5d0df2a95f71e63c2f11fe3b5e1771f26ef73bb1af84de83f8/multidict-6.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385", size = 264900, upload-time = "2026-09-09T13:55:57.803Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/d5829fc00a055d6ab445e0876346ee9cdee670766cd4190dc0a496188c0f/multidict-6.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4", size = 242486, upload-time = "2026-09-09T13:56:00.002Z" }, + { url = "https://files.pythonhosted.org/packages/b7/58/e8d7874038e31e0533182d1c3c5331a856b9c849a71bb26a21850e8c91e1/multidict-6.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff", size = 259916, upload-time = "2026-09-09T13:56:01.802Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/1b56a7401acda20efc016440f4fad3bef66c4aee54ca080ec143881ebb0d/multidict-6.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6", size = 251209, upload-time = "2026-09-09T13:56:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5b/68d67a9e302b0645a747ba910c30eb41f2834fcdc1d85f53eae2dfceee0a/multidict-6.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110", size = 264505, upload-time = "2026-09-09T13:56:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/18/13/4dc304ba2c5f5307b474ab2ce1ed1f6b02b0b4e233c182e3981ed436c2e3/multidict-6.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b", size = 264916, upload-time = "2026-09-09T13:56:09.079Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0f/7b1f729d18369915009185201be5d0b8df0e525340fe6a600d2f8441d6cf/multidict-6.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0", size = 236839, upload-time = "2026-09-09T13:56:11.273Z" }, + { url = "https://files.pythonhosted.org/packages/22/d1/eba1b88b18b7019d9136303fe77909257c40fabde5aaf138a4d900b6ce3c/multidict-6.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78", size = 265307, upload-time = "2026-09-09T13:56:13.379Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a6/6c1e4106faa27118ac612f4d664eaf909de252634785286262a627108e58/multidict-6.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b", size = 259041, upload-time = "2026-09-09T13:56:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bc/ecfb8b6faa8e158a71b03bdf7f947f30e0bc5d899cc357573a76ab7bb1e5/multidict-6.8.0-cp314-cp314t-win32.whl", hash = "sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2", size = 50628, upload-time = "2026-09-09T13:56:17.837Z" }, + { url = "https://files.pythonhosted.org/packages/30/7f/e27fb699b70ad24dbd02ddee604658acb36f907c03c045baffe4ea774501/multidict-6.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26", size = 55592, upload-time = "2026-09-09T13:56:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7a/76de70b2f6733696803f1ee56abe44a3757a52777383032c7373d3fea0f4/multidict-6.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb", size = 50300, upload-time = "2026-09-09T13:56:21.516Z" }, + { url = "https://files.pythonhosted.org/packages/ce/32/4de7320ae032dc768090d11f708d2d386df3db04cb6b8b0db0230cfc66c3/multidict-6.8.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3", size = 53761, upload-time = "2026-09-09T13:56:23.192Z" }, + { url = "https://files.pythonhosted.org/packages/5c/45/ecb641309dc2cdc6040f18e22c68eb5e94398f9404c4365d810f4292e053/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25", size = 47505, upload-time = "2026-09-09T13:56:24.902Z" }, + { url = "https://files.pythonhosted.org/packages/eb/68/87d6161b9fef11943e0b894203da3fff561933ca3c9b2952b6e7100e9c9f/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c", size = 48549, upload-time = "2026-09-09T13:56:26.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/d852d2276407640cdbd29fe11cac6e93f70f59542cba174ef9d146738946/multidict-6.8.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23", size = 83157, upload-time = "2026-09-09T13:56:28.227Z" }, + { url = "https://files.pythonhosted.org/packages/14/e3/16fe7ffa6090591d83cf6bc2486e77ce891705fb6d0191823140928311b5/multidict-6.8.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15", size = 50578, upload-time = "2026-09-09T13:56:30Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0c/e38e41c1087a599f86ff58a01f358abf7c4db3c26a3e90eebb3e02193ef1/multidict-6.8.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7", size = 48815, upload-time = "2026-09-09T13:56:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f0/eb691f42af8e7775992f57904ec75dc356fc7cdc896e5f30879decdd26f2/multidict-6.8.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba", size = 274804, upload-time = "2026-09-09T13:56:36.741Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/28472ccfeb43c00a043c0385ca4294da21a5957859fb7860e2ebdb3e3011/multidict-6.8.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e", size = 279693, upload-time = "2026-09-09T13:56:38.531Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a1/2b4fe73e5fecff807b47650a155c391a103136428cb21d6ba8e39c5912b5/multidict-6.8.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b", size = 254969, upload-time = "2026-09-09T13:56:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/44/e0/c97d1822783dfe52e02fd150fa3f02eb22410211a9e2615f71541803ed4b/multidict-6.8.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31", size = 286392, upload-time = "2026-09-09T13:56:42.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d9/772f1339e1d051236bcc137b0eac2b4aaaa0bbb56aaf924e9aaba901d9c1/multidict-6.8.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d", size = 285348, upload-time = "2026-09-09T13:56:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/cd045747e4680362e02955a82c468e95b5e4d319e3a79574b3fb677de568/multidict-6.8.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3", size = 282721, upload-time = "2026-09-09T13:56:46.088Z" }, + { url = "https://files.pythonhosted.org/packages/35/14/0802d9a3aae4ef21eaa39adbd729a380fa095932105e1424e417b53e783f/multidict-6.8.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc", size = 253168, upload-time = "2026-09-09T13:56:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/b1/64/3f92298bab8fbe1332e708863fb55b66e755be6f416b3459720d48b33af9/multidict-6.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f", size = 274209, upload-time = "2026-09-09T13:56:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/08/c2/2001ac0eac1a8b7390a5902d7115f66d4f256268057a502200b6ab12dad7/multidict-6.8.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c", size = 268044, upload-time = "2026-09-09T13:56:52.033Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/78a9e26c85f89abd562a67f7fcbaef9007fd5c37bb9efac19f1cf604e7c2/multidict-6.8.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8", size = 274806, upload-time = "2026-09-09T13:56:53.975Z" }, + { url = "https://files.pythonhosted.org/packages/3d/71/713bd445421b21531234c1f3630b768192cb9d80c8b1c5b05c5b505ff4c0/multidict-6.8.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368", size = 281890, upload-time = "2026-09-09T13:56:55.848Z" }, + { url = "https://files.pythonhosted.org/packages/de/a5/1387c538663e2dc8c27bbc7cd6955cb66de0f55c780cf7cd0fc06a1a16ca/multidict-6.8.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14", size = 249749, upload-time = "2026-09-09T13:56:58.01Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/104296c9d70896b9759ce0812aa4899fab76d8b16bb32dcc5a78ab547c89/multidict-6.8.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8", size = 276138, upload-time = "2026-09-09T13:57:03.591Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0a/f2a0c2658e9d7ff5964ec2820a02054558636fafd663230ddc8310b8ed39/multidict-6.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2", size = 277077, upload-time = "2026-09-09T13:57:06.024Z" }, + { url = "https://files.pythonhosted.org/packages/98/50/bc46566caffba5c1c4a510519156371edf7c4ecd35c9ef917d0c1803487d/multidict-6.8.0-cp315-cp315-win32.whl", hash = "sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e", size = 46930, upload-time = "2026-09-09T13:57:08.009Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1a/cafb31049ecc1a6ce52bcc69fa436cca239adc057b1718a0c49044848663/multidict-6.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8", size = 50294, upload-time = "2026-09-09T13:57:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/6b/51/00e037da14cd1d894b123e0bbe62de5c561679a6ab23ab1c009f2965dcda/multidict-6.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f", size = 47626, upload-time = "2026-09-09T13:57:11.738Z" }, + { url = "https://files.pythonhosted.org/packages/52/f7/aeb947982197e8b4f5c4da3961ee473ea5a050b94a6ff3b88baf64621401/multidict-6.8.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08", size = 88801, upload-time = "2026-09-09T13:57:13.957Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/593948c016c3f850e3cd56a4e0144151eb409d2b8690f0c0ce7f7d33dbea/multidict-6.8.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944", size = 53376, upload-time = "2026-09-09T13:57:15.94Z" }, + { url = "https://files.pythonhosted.org/packages/58/b9/097a05bca533027c0477b6a90bf927dbbb4b23cc9090bbb37a2e972af8d5/multidict-6.8.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84", size = 51629, upload-time = "2026-09-09T13:57:17.685Z" }, + { url = "https://files.pythonhosted.org/packages/fe/07/938ed21967f12380d0b8861645fb65a942f3669e31d5163ed94d23103b61/multidict-6.8.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3", size = 261967, upload-time = "2026-09-09T13:57:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/89/e8/e66bf843fd29c01712dde9edeb9f4ad0ffab06ab4ada4b721ad7bc73b3d5/multidict-6.8.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5", size = 265923, upload-time = "2026-09-09T13:57:21.784Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f8/e9be849b225af28a8eee2c6bfea23594a777c753fe97e2ff7e2180c8935a/multidict-6.8.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62", size = 239380, upload-time = "2026-09-09T13:57:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/4dbad08f5081978c591afae9e836ec9ddae90e9e76be6d6ce10757483dc4/multidict-6.8.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20", size = 271591, upload-time = "2026-09-09T13:57:26.611Z" }, + { url = "https://files.pythonhosted.org/packages/92/3f/e9c97222d7e104e54e556f118ec7d091ab41a0c10c630f2b97e5b43f5404/multidict-6.8.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0", size = 276091, upload-time = "2026-09-09T13:57:28.997Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/728e7ce05ac9c0303554e7162e74d91fe49e65bad7dfbb377f783dd32c0a/multidict-6.8.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556", size = 266493, upload-time = "2026-09-09T13:57:31.256Z" }, + { url = "https://files.pythonhosted.org/packages/8f/74/7c658d2769863af16fb7d7c6be50b29659892a06a632858863eee3a31842/multidict-6.8.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a", size = 245302, upload-time = "2026-09-09T13:57:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/2c/d4350a20a0e8c66a447d694e8713438262665203fe826c3f4e385f052b72/multidict-6.8.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4", size = 261016, upload-time = "2026-09-09T13:57:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/83df999c8beb72a012cfac42f2b833c4a48f8e836fd4407747b355a2430e/multidict-6.8.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39", size = 255021, upload-time = "2026-09-09T13:57:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/fb/13/f2c0a2dac6d91f74aa124f3e9f07ec497ceae5ed2df2753d249601cd7262/multidict-6.8.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e", size = 263066, upload-time = "2026-09-09T13:57:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/59/1d/730008d4639ace731bbb1399e1ac13cbdf506f7d6fb861d75044ffb3994d/multidict-6.8.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1", size = 266510, upload-time = "2026-09-09T13:57:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/bec67a5d206dc5748e50c93f6f71deec14305c3657cfe250c3887caf7839/multidict-6.8.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f", size = 239423, upload-time = "2026-09-09T13:57:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/9e/db/5f153fe51fbac7d80f3bb8bd6fab8db8b6cd061e7a11371676dfed3712bc/multidict-6.8.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882", size = 266902, upload-time = "2026-09-09T13:57:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/0f/9efca48a351551de4dc0c183f523109dbe87c645a4732d5f1c70b4880dca/multidict-6.8.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101", size = 260887, upload-time = "2026-09-09T13:57:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/df/d8/bb879a62e0809448e53f6237e71670066ecf3bbc5896a7a6705b6628d86a/multidict-6.8.0-cp315-cp315t-win32.whl", hash = "sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea", size = 50533, upload-time = "2026-09-09T13:57:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/fe/62/3e5308d8871636e4b9620e4b3acfcf2b5caf79b19d317690ec13f7fc8b57/multidict-6.8.0-cp315-cp315t-win_amd64.whl", hash = "sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d", size = 55572, upload-time = "2026-09-09T13:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cc/d3c10e10ee3bb7a7b4abbb3157306b2ce7e0018c9c2d16b32b468739d2b7/multidict-6.8.0-cp315-cp315t-win_arm64.whl", hash = "sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4", size = 50322, upload-time = "2026-09-09T13:57:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ee/be4e1a4b7a2b27f4fb6936510d4bebcb41b0562c946930ad26916e069cf9/multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e", size = 16297, upload-time = "2026-09-09T13:57:56.106Z" }, +] + +[[package]] +name = "novita-sandbox" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/63/79f1254e806215ae2c3a6db403fc6ca803c05f170d27ca1fa64bc6a827e8/novita_sandbox-2.1.1.tar.gz", hash = "sha256:cf9ab8c9fe35953ec00324f68b03ad8dd8a5a0475235c1857c1eb96e784e2b98", size = 529938, upload-time = "2026-09-08T06:27:50.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/2b/962cfc5d398711b52863ca8d14ece59bb70fa615a04cbd1880361b76c036/novita_sandbox-2.1.1-py3-none-any.whl", hash = "sha256:36123f0c0c040bdcd0f3ab814692e088d7ce79a9dcfe9564a9a860caebeaaa5c", size = 660702, upload-time = "2026-09-08T06:27:49.059Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "obstore" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/27/aa7549157a4a681157e315534ba5ab8f167f77662166e792a6e836938f46/obstore-0.11.1.tar.gz", hash = "sha256:a5afe8b99e3b20cdc9133be7a1b381259acf0d470029f6b2fc79c3f9947ad436", size = 130828, upload-time = "2026-08-21T23:57:27.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/38/438f85772bfbcd2985845a5d00d1c910e98b15f587dae6cb1e435865eba9/obstore-0.11.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d5c50b755d781efebe4f9c70ee6d00858e44d95f0229b8b5174358f861d9bd7c", size = 5400146, upload-time = "2026-08-21T23:56:29.168Z" }, + { url = "https://files.pythonhosted.org/packages/e7/62/edd2613649cb6b4400f5d125223ee094d2da1c4c93636453a5360c61b865/obstore-0.11.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:093152daa5c32b70a032f231bbc6a7eff76dda4285eed5a81d272b4485032dab", size = 4596203, upload-time = "2026-08-21T23:56:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/c1/52/09edd48251a26a65f786bf2def93994ffce501f3aea6724474d8de0a7f4f/obstore-0.11.1-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b97ee10456e65f166c030b5fbde8380ca508621e23b84efd77aad83afe99315a", size = 5003021, upload-time = "2026-08-21T23:56:32.085Z" }, + { url = "https://files.pythonhosted.org/packages/59/8f/89a28c75d46bbb0552d34a5e84c5bc512526d45f935321f00af2e6275a99/obstore-0.11.1-cp311-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48983a143de69b11de49212caa79b5c39acbff7f592f9e85d156a0133a0e5796", size = 5240376, upload-time = "2026-08-21T23:56:33.658Z" }, + { url = "https://files.pythonhosted.org/packages/50/c3/b4620899003e2472bec7baf5d2bce12cf6f6f11d02d8f17e03e093717a53/obstore-0.11.1-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9cb988e03ff963914cf2176aee293eaaff7dcf4efcb905ce6834264b4b0884", size = 5444946, upload-time = "2026-08-21T23:56:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/bd/de/687685ab39ae4a70cc13ac252febfe62a387836a55e046f467c9fb231880/obstore-0.11.1-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97850f68c8417f7167549bdb705c362c825ed470298c6b04faf52258c5e9234e", size = 5304065, upload-time = "2026-08-21T23:56:36.535Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/b962ad52031b5bfdd249923db6151ff2b665b08c017611db96838455f337/obstore-0.11.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a9f3d66dbf3c073dd6b6033c0787ef14edf78d97bff95595d6f6979692da264", size = 5556770, upload-time = "2026-08-21T23:56:38.377Z" }, + { url = "https://files.pythonhosted.org/packages/e0/04/41a0f2917fcaa1e66b754fc98c92441d5a252ff9c619941ac086f9bdb4f6/obstore-0.11.1-cp311-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:2ba3bceec4263b3a70eea873abedb82e03da9599f2326e80d6505cab0c10d401", size = 5337765, upload-time = "2026-08-21T23:56:40.279Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/0b618efe59ee4b58f16d2b9246674a70c390bebf665cd284b439bade9bd3/obstore-0.11.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e956fa8a953b7eb8580658d68cdf37e410356032c17697a06e44d0e8c4084a0f", size = 5544012, upload-time = "2026-08-21T23:56:41.754Z" }, + { url = "https://files.pythonhosted.org/packages/57/3b/1018f6da240f3529d1cb9a836c79a91ce45349ba3cffcd6baa73296c7e6c/obstore-0.11.1-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:159a50d0f4cc53afe6f5c695313bcaf6e92ac81d7847692c8279153483272bfa", size = 5229851, upload-time = "2026-08-21T23:56:43.367Z" }, + { url = "https://files.pythonhosted.org/packages/35/2c/c2fe082816b255159373b15be3f28b2515d0eb954e248e2f65449c456a14/obstore-0.11.1-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:128da07f3a1b9c70159e2b2be9e27f458a9d531d57b1856dd7c4ddb73b4c9937", size = 5361812, upload-time = "2026-08-21T23:56:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/43/9c/656eb5cce818e7e3985d27f4bd403feb0b6e620494775a55a0943c8d2fff/obstore-0.11.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67b8acfbbab960c2cb14c34294a96556caa67fcff15ee77c716d5c1bd1558606", size = 5790855, upload-time = "2026-08-21T23:56:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5c/94ece1c902ff5cdbe1140997d1f720221a0d5defa902fdd93715297604f5/obstore-0.11.1-cp311-abi3-win_amd64.whl", hash = "sha256:e23ea15cebe5f5be5d11005043d7b5ff56848e39499681ef52f8227bd385bd51", size = 5308846, upload-time = "2026-08-21T23:56:47.774Z" }, + { url = "https://files.pythonhosted.org/packages/c0/af/235730a429e55777f15f9d7fef118a6ecf02f430ed4588deb4b7e13964aa/obstore-0.11.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:73284fc8a9804596baf4d80b55c0e71a6007487bfa28d6924acd85264d5be81f", size = 5424585, upload-time = "2026-08-21T23:56:49.532Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/432ea70c061fc9401fe399391ed4f04dead4a4cf33ea7c9564ce0e299d24/obstore-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8a3f93310422b153629af929d9e88d1fc826d5e32a456de4d3a1926a770ae09c", size = 4576791, upload-time = "2026-08-21T23:56:51.014Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/57dbdfa4ba5339b80fc755359bb232bdba7f82e92df6603ef383396b5f16/obstore-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d666690f0a53ae3df4be2c18af6820369a74c9c5b8960e8760f0f8c9a8f15b36", size = 4992141, upload-time = "2026-08-21T23:56:52.46Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/d25a4c129250364b0ca927fbfca5146744fb3bb4de60ae9b302300cbdfef/obstore-0.11.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0844ab75c8413c0af2d0fd2d2f6aa42d166809b6ac4be478151cd946ce7e5d69", size = 5217707, upload-time = "2026-08-21T23:56:53.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/0a925c886639d6edce37aafb6eba0c86820cbc9dda42e91ddd784dbe059d/obstore-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:307b5f9d64a7c00cd13371e376c05e3914216bd0818a19ff2ae05bc0135e01dc", size = 5429440, upload-time = "2026-08-21T23:56:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c0/38d188a00b9f2de30df26802dd5bc2ecaefdabb882fde786cff800ccf50f/obstore-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f89647953b4ea50bab3f66591f7f462cd454a5d2fbbeefd885716a2c54e13ce", size = 5308903, upload-time = "2026-08-21T23:56:56.924Z" }, + { url = "https://files.pythonhosted.org/packages/60/d3/815e5a7ca59f901b6d24fb01ad3af46de37dfdfd2971c019f914bdb7f65e/obstore-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50de3116af69b6f1669cb443cc200ccdc1523a790a41f00854331b48f716cf8f", size = 5547007, upload-time = "2026-08-21T23:56:58.508Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a1/ec4d2291b7eb15e7ddccb2f0646ce560b8a539df4b0ab94d916e7a15a61c/obstore-0.11.1-cp314-cp314t-manylinux_2_24_aarch64.whl", hash = "sha256:eae71e1c5944ade976ce8cd1e780bed9b5c31d6d4f89cb7263e8dcff78f6cd8e", size = 5330062, upload-time = "2026-08-21T23:56:59.954Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/a4a1301f2474f6569bbc0f2d337108ff2225d0b3d285894ed9a6dc953438/obstore-0.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff634b5edbbf76c56ae81397aa41500a66ad0aa48b1856dcc0cf31b159172dc0", size = 5539870, upload-time = "2026-08-21T23:57:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8f/7dbbf935a07ff5375c33916307b77aef9d39bded35081530150822898010/obstore-0.11.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbe509350d66249fc9e65ece4e7b1855d650f18e477721887128452b95c44b24", size = 5222673, upload-time = "2026-08-21T23:57:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/d5/89/4932b3dd7963a3a64fd532c30ccc836f0f4ede1a45bea9f12295299398e2/obstore-0.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:46122e585f48ab3f2e4fed51401a4e866279a3a3d1aee2372d598ab85ba2c539", size = 5335696, upload-time = "2026-08-21T23:57:04.439Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/f3cf31ac4ebfa648dbd2c69774579fd5da4cf4b05fa1192405dafb6aeb38/obstore-0.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7275e75228059b2b30772b2ba3e41562a4a92ebdd3a96619d300f98ef152119f", size = 5783641, upload-time = "2026-08-21T23:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/51/31/b352db3450e700c2c51861d139fc92c3284f7e578ca306408b054f6b4a35/obstore-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:13bb0b6a40931ab2da93f93ad843865d483d95acec1bd586df79313daa5a50af", size = 5290060, upload-time = "2026-08-21T23:57:07.632Z" }, +] + +[[package]] +name = "openai" +version = "2.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-08-11T18:46:59.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-11T18:46:56.684Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "openenv" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "fastmcp" }, + { name = "gradio" }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/f1/68bc2696dbb3014dcbc7e1a8ed24242aee195f5a16a8119bdb0a32080231/openenv-0.4.2.tar.gz", hash = "sha256:92d45d39b210a7cec438e5fa6187889051dc34331ba152cab61ffd6d2e3f27ea", size = 205526, upload-time = "2026-09-09T08:39:13.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ca/5e02a3ef2b3a40616b222d4bd9c5dc48acfad85d8da6933d90e903044e16/openenv-0.4.2-py3-none-any.whl", hash = "sha256:9b2575e1521f1ad45dfbc474ee6399b1d6e9768bfe43d793f6579cc5a71c038f", size = 239602, upload-time = "2026-09-09T08:39:11.946Z" }, +] + +[[package]] +name = "openenv-harbor-env" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "gradio" }, + { name = "harbor", extra = ["beam", "blaxel", "cua", "cwsandbox", "daytona", "e2b", "ec2", "gke", "islo", "modal", "novita", "opensandbox", "runloop", "use-computer"] }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "openenv" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.104" }, + { name = "gradio", specifier = ">=5" }, + { name = "harbor", extras = ["e2b", "modal", "daytona", "gke", "ec2", "runloop", "novita", "blaxel", "beam", "islo", "opensandbox", "cwsandbox", "use-computer", "cua"], specifier = ">=0.22.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "huggingface-hub", specifier = ">=1.12" }, + { name = "openenv" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.24" }, +] + +[[package]] +name = "opensandbox" +version = "0.1.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/15/3db0638f9a4f2ace097e8ebfc9a592eff5a864f36933ee0579fce88350b5/opensandbox-0.1.16.tar.gz", hash = "sha256:b9253a58eb9f01bff522fccf317746b464805893c56a6ad447e0f1cc7f37276c", size = 253711, upload-time = "2026-08-26T04:07:41.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/a3/5431e516c1ed4027c4b011b1969b46b7ec6754c47e2b5a1427a99f8827a6/opensandbox-0.1.16-py3-none-any.whl", hash = "sha256:2e3b7e18e52f69f9696cb563713e2581e10a727d4dc83edc0ec27b1c725c3ce0", size = 583522, upload-time = "2026-08-26T04:07:39.503Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b", size = 42689, upload-time = "2026-07-16T15:25:50.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-aiohttp-client" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/33/3ff7230b035e8b696db6be54f5c52dfa409829d634d91431c076ad789820/opentelemetry_instrumentation_aiohttp_client-0.65b0.tar.gz", hash = "sha256:85906a2806ee5641756b5c33274e9aa75c3cc2441e3b830aa5804cf0e1fa9dd1", size = 19042, upload-time = "2026-07-16T15:25:51.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/f9/5c8459224f175829601cabbcffaeab0f9041903c086e4a0368f9971093a5/opentelemetry_instrumentation_aiohttp_client-0.65b0-py3-none-any.whl", hash = "sha256:3a060efa53fa44d02ba7372a7ed2b42cdfa6be6df81b089845067ad840e25729", size = 13677, upload-time = "2026-07-16T15:24:53.361Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/a9/d7525a59fdd240e69b5af4a6338e78fafa1b4203394122cbd6701fb5f84a/opentelemetry_util_http-0.65b0.tar.gz", hash = "sha256:84f82d826978bba416ab453460ff6a7391cdc3534c93a786595e4068680016b7", size = 11243, upload-time = "2026-07-16T15:26:27.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/3f/ab8d29df207ce5f470a07fa96ebb48af4e95b7fab7e7635311b9a32f2fab/opentelemetry_util_http-0.65b0-py3-none-any.whl", hash = "sha256:7553b606f963097cb190536dc30556cce85090692e471a422fff30ca29b04348", size = 8245, upload-time = "2026-07-16T15:25:46.482Z" }, +] + +[[package]] +name = "orjson" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" }, + { url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" }, + { url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" }, + { url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" }, + { url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" }, + { url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" }, + { url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" }, + { url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[package]] +name = "paramiko" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/15/ad6ce226e8138315f2451c2aeea985bf35ee910afb477bae7477dc3a8f3b/paramiko-3.5.1.tar.gz", hash = "sha256:b2c665bc45b2b215bd7d7f039901b14b067da00f3a11e6640995fd58f2664822", size = 1566110, upload-time = "2025-02-04T02:37:59.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/f8/c7bd0ef12954a81a1d3cea60a13946bd9a49a0036a5927770c461eade7ae/paramiko-3.5.1-py3-none-any.whl", hash = "sha256:43b9a0501fc2b5e70680388d9346cf252cfb7d00b0667c39e80eb43a408b8f61", size = 227298, upload-time = "2025-02-04T02:37:57.672Z" }, +] + +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" }, +] + +[[package]] +name = "postgrest" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/22/88c470d8838d2678a44e0172d061630b8837cba3fb7fb492e28f6578c309/postgrest-2.31.0.tar.gz", hash = "sha256:2f395d84b2ee34fc57622ff2f711df603e2ede625f98e5015240741888f7bd0c", size = 14419, upload-time = "2026-06-04T13:37:20.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/3e/41909586cb148db0259e0208310067afda4cc097f4c7a779e829c1e68c46/postgrest-2.31.0-py3-none-any.whl", hash = "sha256:c2fd47c94e13ee8335111c4f03c9a24ea9766ce9d35fc3cd7330057c9e7ea0c3", size = 23098, upload-time = "2026-06-04T13:37:19.452Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, + { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" }, + { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "protobuf-py" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf-py-ext", marker = "(platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/ed/02fd902d9c51b7ff53dfc9a745eb11490722edfd30073af889e171f07b8e/protobuf_py-0.1.1.tar.gz", hash = "sha256:6bd08ac4d8f1661965bbe2685429d79043704cdd1ee720a7a89617331742240b", size = 133525, upload-time = "2026-06-24T19:02:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/00/1b3775aca1c70e3007e06ef5996f6bb9b3a32341eb0cce3ffb6effad8dec/protobuf_py-0.1.1-py3-none-any.whl", hash = "sha256:efc4f50f275ed6dae10a1f30bb81ad1a75368557b3ff22a532b7a472050368f1", size = 181656, upload-time = "2026-06-24T19:01:29.556Z" }, +] + +[[package]] +name = "protobuf-py-ext" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/05/6dc9ccff1e8159eb9a144e6d3c4acfd2211cd4fcd20c34fe9155d17d6a7f/protobuf_py_ext-0.1.1.tar.gz", hash = "sha256:e85bfdfdb3ed50634db8ccc7429dd9286520109489c735463971a418707b4fef", size = 31912, upload-time = "2026-06-24T19:02:16.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/70/2b5d62a60a2e0d88ecde1ae98db3132bcd2672fb39c8d581b82b34ac0bd8/protobuf_py_ext-0.1.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:310039e03cb15181781a0b78017419f6d4ee302e988c3c70b87f1facdf05532d", size = 306008, upload-time = "2026-06-24T19:01:31.399Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d6/73c06bb4cac2e04c0adc154965fe8b6520224bd737fd7e73bba405056321/protobuf_py_ext-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ec8348d1ba045f79b2fedd14e40cca36f2a41b52f2c4fdf55a60c58add2353", size = 314769, upload-time = "2026-06-24T19:01:32.89Z" }, + { url = "https://files.pythonhosted.org/packages/18/c5/e4e6bc6096b66d1c82639a1b501147f16ee65d32567304b987d002e2c666/protobuf_py_ext-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:182798e4861aba72d05855bd06febe4926aa7265e6f444a1b8af5252beee4f7e", size = 328122, upload-time = "2026-06-24T19:01:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/1d792a40d0f0a0f914f1dfa8bb5e9573ca0ecd5fe5cecf80d77193212abd/protobuf_py_ext-0.1.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9eb25c3a329c0551cc86b209a5e5d8ecb8d834b9924a3aa019377853a703b6d3", size = 492338, upload-time = "2026-06-24T19:01:36.103Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/5ad329223c905e5c530cae38ae24cde3584d8ab7e09457f2a01afce80e60/protobuf_py_ext-0.1.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aaecbde82bef10c7c40578cbb61b7a19896bf7fa450972050a3bb302acb7d5d6", size = 541578, upload-time = "2026-06-24T19:01:37.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c7/01bd8a8bfe2df4b07aafac12ccfb7b7ebe2303f65296a9e02fcafa28ff3e/protobuf_py_ext-0.1.1-cp310-abi3-win_amd64.whl", hash = "sha256:6b0c615c48e95acc53cf33e9310eeaff8b30d2d7555bf93e7bca8fb4f40e9a5c", size = 251319, upload-time = "2026-06-24T19:01:38.946Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/914065538b5db54b6a920b5af38c1ef142252ea1eea711820435fa259fac/protobuf_py_ext-0.1.1-cp310-abi3-win_arm64.whl", hash = "sha256:72956cd0af5dee24b41c6f5ba5e42622d17e6d555002b5efc1634e27a1446de2", size = 241635, upload-time = "2026-06-24T19:01:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/0b5d73cab815fd50615cb87a02e04e8332f9e27380c890aba1459cf7e919/protobuf_py_ext-0.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6f0cd58620f415a3534d195358338f4999a774e12510f91c592b38d13d37388", size = 304903, upload-time = "2026-06-24T19:01:41.796Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/14c120b9ac36a0e11bb05e482fa6ae322de583a8e1068e4859035c289947/protobuf_py_ext-0.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21572764f625d829604fc4d83635f533f35775f11c6da142345b3f3c8d64bd09", size = 316148, upload-time = "2026-06-24T19:01:43.247Z" }, + { url = "https://files.pythonhosted.org/packages/35/54/7c00a1a9783c3ba74f6b2f5d2f049f09037bbd489c465c09a34f32fb611b/protobuf_py_ext-0.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7f14f00c2678bfbe7ad46f057b9f9938c1677bcf39e405e163e272c6f9814b8", size = 326458, upload-time = "2026-06-24T19:01:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0e/81fa50c0d6c4d664e5be6d0a3c4f2c7edfef87ab12d0bac764abca1f2955/protobuf_py_ext-0.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1610865622e2e27568277ca63d8d2d23dcc55eaa767398865c21539ddf4ce24d", size = 493596, upload-time = "2026-06-24T19:01:46.344Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/3183cf6e4de62846c20549654a1d573dae51ca06aaf7fed06accdfab74f6/protobuf_py_ext-0.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8995efba9476e1ea18ef9873306871dba697308994bc2766558fec3387acc3de", size = 539656, upload-time = "2026-06-24T19:01:48.21Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6c/09841f3dbe7c3d4b3f2779f8f2832ac23fb96ac8ab71674e91af732cf9ff/protobuf_py_ext-0.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba61d49dace8f874361583030a7c48139b42eb37c9ffbb1e7e8a227a51576f44", size = 305017, upload-time = "2026-06-24T19:01:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/52/43/e450b5e202f6715274acc9acd9f9769908cbdeab861a53c798fcbd467d35/protobuf_py_ext-0.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a9c2f026096ca4c595c89a297067ef371e241d3ff8e1f6d7c779aa8419cdca7", size = 316215, upload-time = "2026-06-24T19:01:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/97/5c/23e79b35f1b5755b9bdc0c0c9b8cd8abababaef1a1639608d8a96bb61a9d/protobuf_py_ext-0.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf78707a040b9294e5e1ec4a1875f0046acfe52e92150cea27de4e9fc9db39bb", size = 326419, upload-time = "2026-06-24T19:01:52.93Z" }, + { url = "https://files.pythonhosted.org/packages/e8/de/5493de0ec12920a3b1dd72608ce0c1e8cdb7e0eb9e87accaecc5216df29e/protobuf_py_ext-0.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4373845cbb85bdade3ef5368cc2f4b5f80bf173383afc1ab063f95644e5599", size = 493734, upload-time = "2026-06-24T19:01:54.301Z" }, + { url = "https://files.pythonhosted.org/packages/98/89/31da55b414e6332aad11d858e9a86bd36fbb324f52fa3fc6d8f1c57840a9/protobuf_py_ext-0.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2a4cc478eef7a2acc1daaebcde479a3ac2396d47e6bdc7e776ca4c4147ba8b4c", size = 539703, upload-time = "2026-06-24T19:01:55.707Z" }, + { url = "https://files.pythonhosted.org/packages/1c/71/39f231838ef06476d46ac40dd814894277a732a3688c0a0c994850b3b62f/protobuf_py_ext-0.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:359dccdc1c3eafed2a913c570bb082b8df848a1d27d548ce8385f246a7d68be2", size = 302145, upload-time = "2026-06-24T19:01:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/87/f1/01c81ff5f420600366a0e6a613ce2af20834534d2b3d7523f4f3b4ddb54f/protobuf_py_ext-0.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91c38b4a10a306366443273ee03ca554537a0965bf05b7ada8e7dbdee04cc93e", size = 314541, upload-time = "2026-06-24T19:01:58.745Z" }, + { url = "https://files.pythonhosted.org/packages/86/d8/1cbf5a0298ceddc0cdbb28bf1ecaf8bd3cff54ed4ced6a1392d5226172fc/protobuf_py_ext-0.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79eee3bcbb289d6ea114eb8fe3a1469c5b59bf53e207238469f567d9c53ba56f", size = 325092, upload-time = "2026-06-24T19:02:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/af/23/8b1694616044cbfec2d18d4c6fffb03e05089c8bdb5970c6bafd60cc7fd5/protobuf_py_ext-0.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:10992141a8282a71ac3e3530d1a489efb27618d84000b9a47918cf70e5816d9b", size = 491684, upload-time = "2026-06-24T19:02:01.862Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/99997a7a6cf6989c944d9183c5deb6a045c27460add0316c7b34763891b3/protobuf_py_ext-0.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4411ffe0e06a774b83c5c71c546ce097640a25f596c45f95f53d3e3148e3f22d", size = 538048, upload-time = "2026-06-24T19:02:03.362Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/9e99ecbb68e1d5b46016d821eb1cbba9a91e6b50e71e75faf0c1e6189f0d/protobuf_py_ext-0.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55a17d80ea419501ff221a6627523f38a431bb33e6aa3de81ae3a7f271c49c75", size = 296337, upload-time = "2026-06-24T19:02:04.787Z" }, + { url = "https://files.pythonhosted.org/packages/af/f2/305338a28225fb54b28d6c3f5948109b9088b329a2b6f77ca610c2bdcd34/protobuf_py_ext-0.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbb518b5638403ceaa08ac4fc7dac626f45ed9b856b3517de3906cf3de4d632", size = 308961, upload-time = "2026-06-24T19:02:06.185Z" }, + { url = "https://files.pythonhosted.org/packages/60/f9/416103c93677ff2ea407704ea64fa6de9e700dc030c48360a182d91ce373/protobuf_py_ext-0.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a4adc65ab6a5e4885c67fc808bcacb83d755d05b566d312a0e10c2a873f2ad4", size = 321895, upload-time = "2026-06-24T19:02:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/5a/83/eb3e81bb2f83834b7deff4cee2d56eeb1adcbc847492a56b7f910ab257db/protobuf_py_ext-0.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e97f45c49676efacfb8e95bfcb1a002bc337e618a6780be1e55df2ba5ebd2f1e", size = 486091, upload-time = "2026-06-24T19:02:09.243Z" }, + { url = "https://files.pythonhosted.org/packages/99/96/bd88fca38556b3105e4dd41d6a176e31dcc583fe979e830aeedd2f8c20ee/protobuf_py_ext-0.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:53a6b3590f6aa7f97b8ed60f62fef9b096babfeae82f7283fd3a4c405827d4f8", size = 534582, upload-time = "2026-06-24T19:02:11.227Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +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" } +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" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pyqwest" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/ee/0ff9facfa9e7a4f6df2a770d4eaf1ad0f74165da7e8c28e888461f07604c/pyqwest-0.10.0.tar.gz", hash = "sha256:6c1a693be17d57d2c2eca4085e32c2809c53090c16719a907c90ebcf1f40dc01", size = 482248, upload-time = "2026-08-21T06:09:20.656Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ee/b1a28f57c689606cfd065d8a553841150f7daaa91d20e58dcc2c5ea191f8/pyqwest-0.10.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:aa492d5777dd145a60795ed95d9d4707a3cd1091fdcdfc93a82ac7fdc43ebacd", size = 5261059, upload-time = "2026-08-21T06:08:04.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/13/9c5046cfd6ef705bde0b620ba8a794335bcabc0839342a2a647f2427b27e/pyqwest-0.10.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:59f3f16628e518c674102e7b5fcff2101bba6abb4f6737ec5fade9b9278e6a53", size = 5134207, upload-time = "2026-08-21T06:08:06.955Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/50021dd88d82d6966ab1c27593ceaee9d1ed62fbe597c40e8dc187cfa5fd/pyqwest-0.10.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6e7db305a8318b1f3218053e87501f8f245ca8bd63e948e0282d04bf0883470", size = 5640730, upload-time = "2026-08-21T06:08:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3f/5bf6c32e9e701837a8c47ce6e3ad38978cfec8eb7bc6596181e5f9e1eaeb/pyqwest-0.10.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5c757cfac5f53c8671dcb4850d5fc4c4339ea3e90636331c9318f8e3ddabc06", size = 5561462, upload-time = "2026-08-21T06:08:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/ca9ba5721461b7ce5cfaac373ab3a1723ddcc434af7430f8bd628da6e623/pyqwest-0.10.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:234b3f71e3f314d997c203d8cf829b7117edd041153f9c277d0060ab90134148", size = 5801847, upload-time = "2026-08-21T06:08:12.502Z" }, + { url = "https://files.pythonhosted.org/packages/d2/44/95593919b996a417093f598d887822b9b899e8d025588c9bfaf8c60dd812/pyqwest-0.10.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5637256a0dac0ef57e0eaa02b032014965e4a4c995e1deca1b1b97e6d1765f78", size = 5978692, upload-time = "2026-08-21T06:08:14.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/b2821ce5188457168ebb25d5ff65b1ca1bf27bc6b4a33df4bcc2357e625c/pyqwest-0.10.0-cp310-abi3-win_amd64.whl", hash = "sha256:7ea761937acf3a00d1a7e70e982949d18946e5471d1419266ab3a78bbfa19759", size = 4876627, upload-time = "2026-08-21T06:08:16.084Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/16ccef1c203fa258ce46a86aefc1a79c13b5f0b8d49627347d90eef25efd/pyqwest-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a21f1f15252a8303623b4f17b9c6de595ace11b3ade07f2adb6d07121e8191aa", size = 5274815, upload-time = "2026-08-21T06:08:17.777Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/6a87f84f571441ea43279587d4bfcad4543505918ae2b83a1ebdcfa98be5/pyqwest-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eb472c6e5d6833ebfec79db310e426eb17b01ac64c0e2c251bd9192c0d2ee0c5", size = 5123656, upload-time = "2026-08-21T06:08:19.501Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/ab69e581cf9b798b0e169f7b27fd3f8b6f9f1631bd4d3b6e22e5abaf8d8b/pyqwest-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83578e24cccd5e0dc04d60a0af7bfb43325b5f22d03ff74ff79ed0ecf553b50d", size = 5641253, upload-time = "2026-08-21T06:08:21.627Z" }, + { url = "https://files.pythonhosted.org/packages/9f/dd/f1a62eebf8321ace506bd94551a01431f6a45b882225455eae3ea6e8c6d1/pyqwest-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bb511c434f79c641efb5573e5795e56dc972252f4b96e52a9636d4ece5231a4", size = 5567341, upload-time = "2026-08-21T06:08:23.346Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/51d767691973e046887f5e6d96e32142fe296823b163ccba732233a6ef72/pyqwest-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aaccd8a9db9430b2aedb5bad8ead80742cbc056b85c229516c70dc80539f906", size = 5803874, upload-time = "2026-08-21T06:08:25.096Z" }, + { url = "https://files.pythonhosted.org/packages/58/0a/d2834ccc6e59ad110718895cc65ff2a68aa6e010f0ba8fbe42a57ea33c21/pyqwest-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:73d9eb438ab4a957a1ce0619d3af8c1c1126bfb9181033b123d792fcf4224531", size = 5981518, upload-time = "2026-08-21T06:08:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/75/f3/274b4c268e9a55fbdb1b3637ac50b5bf42cd3a85d1cfbdc15c602a7b0d9c/pyqwest-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:317a74d633abe3bc5bccabf479e069c515dab9e6a755274b0ccb1d8a5bbfede3", size = 4870638, upload-time = "2026-08-21T06:08:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/8b1092f25159bf61a9470ebd35438c669b91ef553a7ee205bdec8006107b/pyqwest-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3978e794b9cfd8eaa500fb5d7aee63bc6172c605efa0abc1f62d85485bc049e1", size = 5273599, upload-time = "2026-08-21T06:08:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/75/10/54a9786123942b124c2afb9562b74e158afec7be40ef0caa0d37f615d379/pyqwest-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:715991fd4f04862cd7a9d7452daabcdbd74dff4dff55eb20c22d60382dc2a4ed", size = 5122920, upload-time = "2026-08-21T06:08:33.025Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5b/6a6bd76f91e068b9a619f62aef9fe5ef201f859ddbb6b0a11ad3875ecdda/pyqwest-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c04798bed79c1dfa0e5b0e30fb137124311083490d44d6dfbe068d3dd254349e", size = 5639577, upload-time = "2026-08-21T06:08:34.896Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/12277a24a8dd74b0a7f124c624d9ed58eccb41e2087ff1087a14d348c778/pyqwest-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35b472877e73dd63fed089c2bc8fa198407f005c8c19e0a93f025ebefde01a81", size = 5565835, upload-time = "2026-08-21T06:08:36.567Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b6/b7da4a3f1fe43600154ae75e91ba7969024d886d60077dd3a1ba8e66d170/pyqwest-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:564ec360b7848b35e009038ffbca00466305a9708ab21829477f64aa8cad4c64", size = 5803078, upload-time = "2026-08-21T06:08:38.362Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6f/0c9ba210f49f232289afaa8f06369c5f135ac786e1ca0cb22243b7f1fe2c/pyqwest-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5c80e88a5967c1cadb3237c450f91a84a3683f8838c8dca96f09fee3612e762", size = 5980431, upload-time = "2026-08-21T06:08:39.967Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/306eeed41a3cd3100247e6e442f4345277b70f1d24efe1641181b14839cd/pyqwest-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc3d80b402fb59dbe015e25993ac8147456fb231a4c949f92a89f31315ad50f9", size = 4870198, upload-time = "2026-08-21T06:08:41.687Z" }, + { url = "https://files.pythonhosted.org/packages/64/18/0086a408e7cbf39dab18fa5b7e42c969a98382da5a4e6debe40f05acc6a1/pyqwest-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:23a28beb55fa6d975949bffae4adfb69378f3229bb5cbd71231e95bf66f5b26c", size = 5274542, upload-time = "2026-08-21T06:08:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/e1b9aaaf7596e4faaa53cefc2efaca4e3cde721e308e6385e366361cfdcc/pyqwest-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e4415ae40b8eedb1713dab14d7f9fecc3f79d26f3206c561087b88b99d5ce24b", size = 5127922, upload-time = "2026-08-21T06:08:45.116Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/e6d68bb1de5dd26100fcfc878cbd67c402a928774edf1e8ae304c5a84f5b/pyqwest-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14b875d2273212d7fa8e4b755d8d736ffd226b1c707a9c0017dfdc8393a96eca", size = 5645856, upload-time = "2026-08-21T06:08:47.055Z" }, + { url = "https://files.pythonhosted.org/packages/f7/48/8c9f9f0467c41f6a563146d57a52f8f6d60c0cb09d0fd3ef88ad5a1c442f/pyqwest-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5071491e416ea54e3b95bf9ffbed0bd065b093cb96e10a75c3d8f2cbe3c9823", size = 5569831, upload-time = "2026-08-21T06:08:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/16/ac/c85ab70c6c72078d49a82da76b820e46aaf95a3f6fe271dac955ac195d21/pyqwest-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b68b5e68d513a4c63a072f8f40e38015160cf90bfbf7e8ef7c3935ca87e9e022", size = 5806735, upload-time = "2026-08-21T06:08:50.652Z" }, + { url = "https://files.pythonhosted.org/packages/66/db/ad7375b22fb2d0807431dcc9bc2aaf840e374c298cd15071024d5f6dd6d1/pyqwest-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c48910d27820b9c46fcd001b0fe514a3cf47d4784f59512dcdb8c91c395f82e4", size = 5984786, upload-time = "2026-08-21T06:08:52.377Z" }, + { url = "https://files.pythonhosted.org/packages/17/88/c449a772afe129683fd7acc657cbc7c69bec085dc75b6e8710a50fbb44e7/pyqwest-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:d03ba2cd17948b623a6210981d342eb122546d8a8e910ec77511aff4b1acdd00", size = 4872288, upload-time = "2026-08-21T06:08:54.101Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f8/439ffc0ee12cd7d9b57ac07ccea78ad3ba66b0d6817d429dd661d73308c4/pyqwest-0.10.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:07a0eb595f4096232c2d22549b6e4612c1ecada7934e46462c2c37ce14a89cfb", size = 5256823, upload-time = "2026-08-21T06:08:55.681Z" }, + { url = "https://files.pythonhosted.org/packages/df/2b/72ecd27d104d2b4284710194cce796d607674966c3ea66436768e812a66a/pyqwest-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:26401baf7dafc71c8d12d2e8389519d141e6f7c14094d0dd4cf9ec1d3b5555bd", size = 5112624, upload-time = "2026-08-21T06:08:57.366Z" }, + { url = "https://files.pythonhosted.org/packages/a4/24/0fb89c3f7d5a0410fcb7560588b4c741ef24b19195c307d4263f04e75c2b/pyqwest-0.10.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5e3c436e041d8873ce5bb0fdcf9f9e86f5604e8f0ef9e03149efebd8cb474f6", size = 5631844, upload-time = "2026-08-21T06:08:59.165Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4f/921d14754a186f0143ad62b50108dd808328e347e98e9dafab3897eeb405/pyqwest-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09364115761579eabfc79d1e954cdb3ded508dac1903fac7285d4c6f058c683f", size = 5555869, upload-time = "2026-08-21T06:09:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/b8/70/504780417319a626fe9549a7e6f9020a3d448eddf8a09617238a3426e90c/pyqwest-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559674a98a8b1217e1830ecd41c9905bf2b60983c6b8017063dfac199f00727c", size = 5793738, upload-time = "2026-08-21T06:09:03.324Z" }, + { url = "https://files.pythonhosted.org/packages/45/f7/8d0a5b8a3289f4300dc9005ebde75d316f2371a2671617f942036337731a/pyqwest-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f399a696392fff3db3eef0a18ef65b8a3b8396d129193487d966b8eb11006376", size = 5972889, upload-time = "2026-08-21T06:09:05.2Z" }, + { url = "https://files.pythonhosted.org/packages/89/c4/f4c781e475c451cb5f4762a2f814a1750ef375b8db4db09bb2dcea03c4e5/pyqwest-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0f9163d6dd991bf1bf27308ba38ba021af660b15fffa47ebca98e41cf6f00309", size = 4858036, upload-time = "2026-08-21T06:09:06.926Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +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" } +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" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/65/f8bae11b228647e2e2f45b63dec7448efaddb7cb51f529de1fdba69e63b5/python_engineio-4.14.0.tar.gz", hash = "sha256:eaa1e386baf9c2c7959eef7f9d9165c5ea910c5b392f5316e78d29ed073cb43d", size = 80863, upload-time = "2026-08-30T19:52:01.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/de/07cfd386974c2a26a7bde41f2111be29bbfc92b9ea0bb76694415a4a1a78/python_engineio-4.14.0-py3-none-any.whl", hash = "sha256:9f0fe275fb7d67bfc1a632421adf22949fd4843bd9c458c004b0a89cede302a2", size = 60291, upload-time = "2026-08-30T19:51:59.776Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "python-socketio" +version = "5.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/04/8647675c93b5e74a3daa41a2a03930bac0cbdcfcf307900f0441ae6550ba/python_socketio-5.17.0.tar.gz", hash = "sha256:c3bbfc4937dcfea7c4d1b182afa94d4a30335d153987e8f2078b344beacf95a0", size = 134574, upload-time = "2026-09-14T22:51:02.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/be/44b558c944bc16618483967ecd3424c578705aa33ceee7df8c1e4ab43ea0/python_socketio-5.17.0-py3-none-any.whl", hash = "sha256:b5826fd2f8aa02e11347816349b74ac6b53e8a4f4e4b1cf1388e1aff19b7f3f4", size = 82548, upload-time = "2026-09-14T22:51:01.405Z" }, +] + +[package.optional-dependencies] +asyncio-client = [ + { name = "aiohttp" }, +] +client = [ + { name = "requests" }, + { name = "websocket-client" }, +] + +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "realtime" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/34/54a1eaaefa24db5cb12596fd74792e08efa53ed30dc5bce2c0a68ded6146/realtime-2.31.0.tar.gz", hash = "sha256:9e641cb4d77ca0fe768515f8cf9f83550c79f49ce1550a95afc2dc0e252be8c9", size = 18716, upload-time = "2026-06-04T13:37:22.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/60/164246615e8b059f6d53d34648a0784260421ec98a07eb1e45160f063221/realtime-2.31.0-py3-none-any.whl", hash = "sha256:f6e494b53d6a6e80b6efcee6711c8dd40413a52e766271de1bce8ced6c36cc1d", size = 22374, upload-time = "2026-06-04T13:37:21.162Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { 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" } +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" }, +] + +[[package]] +name = "regex" +version = "2026.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/5c/f403115361de25809e8f785686ec7096e30fef73be9ae35aa51da4e80abb/regex-2026.9.10.tar.gz", hash = "sha256:1e321e2c84f0e52c457f5ea5944f796d6e8e09cb99738ea98dcc1bfe402a128d", size = 417072, upload-time = "2026-09-09T21:00:21.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/c8/bfbe893e90ee0148bd2860dd086f09b5d2080ca2b125f740c2e118c16982/regex-2026.9.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880ac684c27176464c00c3fdc456116364f5ebc70da07aad0c2d4a7ba45e98db", size = 496609, upload-time = "2026-09-09T20:57:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ac/56d5ae6efb759255c3b3db650a4be25f96a844ea3613b91e1e189a3b7294/regex-2026.9.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9d36b03dc362aa40ffaaec9d9bd75e87763529563ec008c43b0e07782f5be7a", size = 297024, upload-time = "2026-09-09T20:57:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/60/4b/0f2d5f6bbb791cc10f22f0ed16c487e630dde8fa8fa0bd92a2bfe21a4b20/regex-2026.9.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866de9f98df0611d7b62b3a8729d3284a64c0cc6edd90bb95a533e443a4939cb", size = 291905, upload-time = "2026-09-09T20:57:13.127Z" }, + { url = "https://files.pythonhosted.org/packages/89/51/3fb5fe0d32f4cf0bc982286722c729a8d6f522d2fa2d5d14a702d9fc87f8/regex-2026.9.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c66d54042a14a503907d81861b8a5235e6d1f03d4fbc1d8767f652eaf957ac1", size = 800055, upload-time = "2026-09-09T20:57:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/89/46/ee507bd2f9d4420f26a594b35c551d7194b66f5d7897f63730fae6ec05c1/regex-2026.9.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:032da15431c890d376f53547f0a6219f4f4cd19f3e4f11bdc321453b5bd207e4", size = 871133, upload-time = "2026-09-09T20:57:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/cbaa90689684f91b1bc017e7f8c6d9425c6bd299108db02482dc51376d8a/regex-2026.9.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:23ac9a28180f274d7dd7651fa131ad5b02d343b75df4b040737f0356223895dd", size = 919627, upload-time = "2026-09-09T20:57:18.402Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/dcdf5e0d898024005cfcce631e3e934d111dfbe177ca0b7f253ae8a735a2/regex-2026.9.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e67f8843f0e4b931f1fa860bf3bbe4134b714c0155cc5c7c0d7ea450230aae0", size = 804587, upload-time = "2026-09-09T20:57:19.859Z" }, + { url = "https://files.pythonhosted.org/packages/73/70/eedfe81c29bae266a06ab4250978361a9bccd474704d88d4f4ef4506dff8/regex-2026.9.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bb7774924f8cd69f49cba0b3c2d679a6326f777e0e67d130ad5203e4df53f0d3", size = 777320, upload-time = "2026-09-09T20:57:21.62Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e5/86b207077efbcd91305700488f170b7eb1e1c54721cea74175273aa3b9a4/regex-2026.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c32480f3371b75068decaf9e5da72c224e953830dd71e36e06cf80e30ea39d8", size = 790572, upload-time = "2026-09-09T20:57:23.048Z" }, + { url = "https://files.pythonhosted.org/packages/b2/92/f622c3b2323f4c035b98e80221740a442127ad7993135b814f52057430db/regex-2026.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d2d377fd1cad611b806cdd732d86b65f536c768209890cb442556548daa65a23", size = 865485, upload-time = "2026-09-09T20:57:24.658Z" }, + { url = "https://files.pythonhosted.org/packages/4a/be/34bd621d3d6ac906ad67e57ed56c40cd45f7d51b9c0328335e97a7cb8ecb/regex-2026.9.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c014641157e9049b0603b8daa5343bd408d9b757b709aaa0f373cd3fab2d7944", size = 767925, upload-time = "2026-09-09T20:57:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/0d/28/ddbf7cba86f2adf5038c6c16aa829636ffc6e437f81bb0cbf302899cea5e/regex-2026.9.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1562aabd9d4eb09bd88a62ad97ed06800094b529ac43419e43020b9cefec79b0", size = 858800, upload-time = "2026-09-09T20:57:27.901Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/e8d7656ff3d3bd32e881d32f540b4981c79dee61908d6b790a45966e6895/regex-2026.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2479171edccced52ef02b899558f88ab2c235fe05b93180fdcae1670aacd89e1", size = 791648, upload-time = "2026-09-09T20:57:29.786Z" }, + { url = "https://files.pythonhosted.org/packages/fc/65/eac1a79115c8475d8ff539602eb54031564d719fdea5a599c4b0a1a26d1b/regex-2026.9.10-cp312-cp312-win32.whl", hash = "sha256:239620b0e0681669367c0e218c8eb2551d9f8fe3b9fccfc8d0003377804e8348", size = 267326, upload-time = "2026-09-09T20:57:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d4/4dcd0a05d3e97ca829165df1c39717f899d1d484dac8a6032439f2cb8d6d/regex-2026.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4db7d00c4afbfbb55b8e17b1e371da11418ea9389b030acec63c1fa4c7ad4b86", size = 277933, upload-time = "2026-09-09T20:57:33.648Z" }, + { url = "https://files.pythonhosted.org/packages/55/f8/22617a80dee28f2451011eae36bc26b3d78c4994ba87b5281d60acf9b6c0/regex-2026.9.10-cp312-cp312-win_arm64.whl", hash = "sha256:c25a754bb81a2edcfc3b65eda50f017d736f818112ed43e8aafd595cb00678ae", size = 277447, upload-time = "2026-09-09T20:57:35.156Z" }, + { url = "https://files.pythonhosted.org/packages/20/90/d4452bf1ef7dbe406980e8b921a257024482203c1dafac535eae207611bc/regex-2026.9.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ef5a059ea1c6ee5d1c7e99a2484e628608d010921efe876c6f0e2029d2f35eca", size = 496408, upload-time = "2026-09-09T20:57:36.757Z" }, + { url = "https://files.pythonhosted.org/packages/6a/35/c763c6424a0f99d021d46dc1f9065147bb5a40c2b2cdf28d2ebdbcd96508/regex-2026.9.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dce932f8e3ba936475ea3d0d8b59f7b050a9e206e994f53f8fd80299871e87da", size = 296931, upload-time = "2026-09-09T20:57:38.811Z" }, + { url = "https://files.pythonhosted.org/packages/fa/68/241f88458b17c46ed2f80147a60a03b2ada7fb815c23b6bc76c298abb0a5/regex-2026.9.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d8c668af8f7bdb1d18739c27d30cd9f4b371495a883f75a002fb7a39d740fecd", size = 291741, upload-time = "2026-09-09T20:57:40.482Z" }, + { url = "https://files.pythonhosted.org/packages/90/9e/974d6de404c63e2d09525f4ddb99874c7ab8e1f781ccbe0dd3e26fa6f6e5/regex-2026.9.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aebdd9a946de328b3f6f61dbf48dd064a36eb6dddf96e34ae6651d37f6e9383", size = 800088, upload-time = "2026-09-09T20:57:42.098Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/3875b73f9e7ba3321dcaa02c19f650c05c61345328acf84599ac6f45ceed/regex-2026.9.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f2374c27deb189b282ec7e16106752c22ad39b056bbd8018960b1e4cc95d67a1", size = 871212, upload-time = "2026-09-09T20:57:44.03Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/2358e791c0e171194dd6a8b97b520579098a21397fb79dbe6b7edc9e3fa7/regex-2026.9.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e0dc78251154b66dc60211563fc115345da332eaa881e4e2523fb1edae3772f4", size = 919752, upload-time = "2026-09-09T20:57:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/000c79c3f9c06b7542225a5d3a7f9a85405da7224b3b9af94a491d07abea/regex-2026.9.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bafa41b0dd63669e5c0f8adf3d24819efeb73c847f492eb011212eb352e69041", size = 804578, upload-time = "2026-09-09T20:57:47.548Z" }, + { url = "https://files.pythonhosted.org/packages/30/6d/195eedb1de87f26639191e7487e41eb81e2ce255bc7563a64f3f5a95eb08/regex-2026.9.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ebb2ba68e4641a994061f70bf44ed448fba0b9b1d18c94ffb9efc1cca805b39b", size = 777345, upload-time = "2026-09-09T20:57:49.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/11fe2b313fcd92cb75c583648f2746031b9f4da9e9ed4241204a5e8b3721/regex-2026.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:048a89ee797db10160bd2bd519286577a6b43a100279bd4b7d8456a3d69c80a0", size = 790556, upload-time = "2026-09-09T20:57:51.27Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c0/07ec9b4c43b0e16d62454971a5ab3886eccb0bfa161300a02d801ab28620/regex-2026.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:79e9432995e14c749d34209413de5e621ec8e67789bf4f46dbfabea9d06a2406", size = 865572, upload-time = "2026-09-09T20:57:53.163Z" }, + { url = "https://files.pythonhosted.org/packages/19/07/43bc9a9cf9fc8e37d2ba47980dfe4a6e151d2cf3ab969e0031e2a9b21484/regex-2026.9.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5847e22bbf959764d776937d791d034cc2d19b787e361c88d97e859e8dc68502", size = 767971, upload-time = "2026-09-09T20:57:54.805Z" }, + { url = "https://files.pythonhosted.org/packages/9c/49/3b9286a3a94f3c89ed4ddbe74e72bdde21c1a5eadd520d5f4ed4a61936cb/regex-2026.9.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c103b3b14e011774af4fb7e4617ad4d72b9171905cd3b231a70a4efd76e477d7", size = 858835, upload-time = "2026-09-09T20:57:56.627Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/e5d27ce9fee8e3ef95f886c7b6ecec211efa4cfc18bd73bd5cf26cca4741/regex-2026.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6b34a778c695d24e77c140e3b4c95da69282e34f2f6b02b55656aa4a0379f643", size = 791793, upload-time = "2026-09-09T20:57:58.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/03/c28a6bebedc3e2d86ee27ec2de16f7ec0419dcd10e771d43dcc9c58a2e99/regex-2026.9.10-cp313-cp313-win32.whl", hash = "sha256:7abb38b8c40f3a235235a44da452c64b7b5c1d650ec6351027db0e090804f2e5", size = 267298, upload-time = "2026-09-09T20:58:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/cd/fd/5c85fa6cfb8e034080bda5a72fa0a4df2b7777a35eb7e73c2799c2adda7a/regex-2026.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:20e8bfb07ad79a282f8b95b56fe67f9750b1b7f775724e4ba1f23cb296115ce4", size = 277894, upload-time = "2026-09-09T20:58:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/c1/28/f5a25f6f65501675977fda35d9f61abb1468c4b87c0f73e536d8b21a60b8/regex-2026.9.10-cp313-cp313-win_arm64.whl", hash = "sha256:3bdeed3318a8eb2bbadc9c56347e0ff651639e934a47e168d05a3b12929fd0e7", size = 277436, upload-time = "2026-09-09T20:58:03.422Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ed/98e9b07d8bb9c765d07774f0b2c19b301b96d51f44630fea48951051c94e/regex-2026.9.10-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd6bd89b9fc06018d35851cab0240adb7dd84d51941b19f6574ac90cd54e3ae5", size = 496662, upload-time = "2026-09-09T20:58:05.118Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a8/9dfe9be48378b47c5a8f04b0f200ea225f9ee0f8e93f010433f661a37878/regex-2026.9.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ef4c0a9dfdc90581b90b1b95a8c3d1557f8ff8f5a2a53536d26314de699d1468", size = 297115, upload-time = "2026-09-09T20:58:06.822Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/5d1f005a3825ec49842ad349061c1c26e6d42f47ccf105e6e5aa6aeed392/regex-2026.9.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14caa05ce39ec70437af5aac8814c50ee6628f4a90353871c059692f448a164f", size = 291896, upload-time = "2026-09-09T20:58:08.675Z" }, + { url = "https://files.pythonhosted.org/packages/be/15/44ce83fca50c6058f42b62fa8300a8030eea7e4e5a973a2dd33db0f557fb/regex-2026.9.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3264132d576847ab5f88bb83e7debe67854bf165b3ea613bd467312b6099536a", size = 800534, upload-time = "2026-09-09T20:58:10.339Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/a62e070a5a033643b287489576f02ae6a9c584c337d62349e340a2b4d001/regex-2026.9.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cef9f3d14796500ea834c41dbe688f1f6b23c7024dc23e8a794d7ebaf5d71d0", size = 872038, upload-time = "2026-09-09T20:58:12.186Z" }, + { url = "https://files.pythonhosted.org/packages/f3/06/8b8e2483949b1329df10c5b615e85d332066deb426b11332b799629b9201/regex-2026.9.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d278ad30ec83b6b9202685b0f80b741a51ea3ca7f0595ebda96e7628b6398876", size = 918927, upload-time = "2026-09-09T20:58:13.903Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/9b56f69100d3afdbc9c4fa6e302764f9cb717fbc06a9d50558d98ca89cd2/regex-2026.9.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb018cfe7144585fc83882405906ff84994a2d154afc2509ecc7752c51f864", size = 803694, upload-time = "2026-09-09T20:58:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/07/43/d00d59a7c8fd0e070ae8457a8743597f45ad9682b100f57b9c9c405fbdfd/regex-2026.9.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fd555fc9abef50c530869690b2daca054c8811a7aff632d11f9a7b2590b2742", size = 777770, upload-time = "2026-09-09T20:58:17.628Z" }, + { url = "https://files.pythonhosted.org/packages/46/6b/a11d0446484efbc9eb67abec133f254c6d66a1568b8f3fb36d39a73a1129/regex-2026.9.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8d5c4518235a2ec1611e57af85fa488d529c1106aacff12adadcedf8687012cd", size = 791234, upload-time = "2026-09-09T20:58:19.476Z" }, + { url = "https://files.pythonhosted.org/packages/12/09/bcd24e78b373fd4f98090caa43eade439223f6703b909be79b9efd9ab0ab/regex-2026.9.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:175cf49ce7a994c88b8f15e3cb17cdb66a48ebb2d36de736b8205033db950f89", size = 866259, upload-time = "2026-09-09T20:58:21.683Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/c57ba5e94222a813260af4c17ced92d40cdb44737eb6b50979688310b6a3/regex-2026.9.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b71649169a9fcf30b395ee01047fa7ad6654a4c900ca75b23c04dedcce6a1f8c", size = 768219, upload-time = "2026-09-09T20:58:23.842Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a2/3820037587d00901ace96c5864335c2cd1b899d5263ea0dd2261359e0bee/regex-2026.9.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8ba1f78bd4fef2d8f84b894ec28ac3481afe6cc07aaa253ad4717ef7b3fe6bcb", size = 858582, upload-time = "2026-09-09T20:58:25.607Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/f9a838ca6219ae4821de0175e4548db73ef56de5ec08d032fb427732fa07/regex-2026.9.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:217e98ba5fc8908ed8ffd4ebac04753a0c831067cbfb495b9821b94cc61eaa76", size = 791405, upload-time = "2026-09-09T20:58:27.406Z" }, + { url = "https://files.pythonhosted.org/packages/f7/bf/67d71cc4e13ae2e0022d21243ca069e0868701a99eb29cdecd13d356e694/regex-2026.9.10-cp314-cp314-win32.whl", hash = "sha256:b298cdc33c5cc6969ff07f0fba19cc73e0fd8576373c50935feadaca2f6b4405", size = 272702, upload-time = "2026-09-09T20:58:29.116Z" }, + { url = "https://files.pythonhosted.org/packages/c1/38/40a93e72703a741235115ed1b1e5f6b869917677b7643005034ce1611d70/regex-2026.9.10-cp314-cp314-win_amd64.whl", hash = "sha256:c32818b28bcd153b25b63038348a9fe9b9fbcddb60df43f204c3ab55eeb57f77", size = 281170, upload-time = "2026-09-09T20:58:30.899Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ac/e95387f00617c16bb41b786d900bacd17eab88afa81b4f263df532b3a731/regex-2026.9.10-cp314-cp314-win_arm64.whl", hash = "sha256:75242f44a3e283106077be4ab717bc535e4701c9d54ad69e195945c22f137a1d", size = 281511, upload-time = "2026-09-09T20:58:32.594Z" }, + { url = "https://files.pythonhosted.org/packages/39/e5/a4b12262edc488a8a7a95b672db317dd8aa9bf2fab98297f9c91bb11ad4d/regex-2026.9.10-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2dd9286093c71afc8f55ef035c5b9d2776641fd72c6535f1febc92d0b0be9666", size = 501128, upload-time = "2026-09-09T20:58:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/9ca31c0fa5197ded8614c8ae0e105bcff2d979025ffa3c75580baa334e2f/regex-2026.9.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:71879292c9c7ac67b1680345b16daba1be937cb027362cfa04e68f65db2dcfdd", size = 299428, upload-time = "2026-09-09T20:58:36.296Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d1/ee7662561735f90475443c3ca1977e5cecaeaa8f29620dec75580aebc839/regex-2026.9.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ccd139b2061132e7b265cfb4b4721baeb9f8928b81415304abf1ec7e3181c26", size = 294494, upload-time = "2026-09-09T20:58:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b1/333168e45ed6cfe71f6d17e21e5f54725f44d171f84edd12469a2f739227/regex-2026.9.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7327795089ddb44912dce1434e1d7244be2e9fb48fcc2d6782936af7a3062db", size = 814925, upload-time = "2026-09-09T20:58:40.438Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a5/df1b38536d0a3b24a030eb4130ce98b403cc925220ff530f5313a7c436eb/regex-2026.9.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff4d7b14ea19e50c8d9d6d83f45bd9b45cbb624c07ac1fa54db0a019049abed7", size = 873323, upload-time = "2026-09-09T20:58:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/93/ea/aa71fe62dd63a8336bbdec1ff002a6c53b40ccfca95961c18ee4f09bf03c/regex-2026.9.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f0407474ffac8e5e89d93ca41d60891e29f0ab8423eb66ff292d850a86a0843", size = 923080, upload-time = "2026-09-09T20:58:44.488Z" }, + { url = "https://files.pythonhosted.org/packages/86/38/49f8d6fd34fc1a9c75b5b96364ebdde702a8144e5ed63d2213c58d454c27/regex-2026.9.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ad10a135fa0b4e4a462a61d07c6654d7518cfdb5cb8da08f9ff7d61384af1fe", size = 821284, upload-time = "2026-09-09T20:58:46.56Z" }, + { url = "https://files.pythonhosted.org/packages/32/45/91a977c96d4be13d1ade8208c260c26841c836d4c91745e1828a30589070/regex-2026.9.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9fbd2e5d8002dc49a6129fb321ec51c57a025e752ed525ddce0ba9223c4350a7", size = 789256, upload-time = "2026-09-09T20:58:48.473Z" }, + { url = "https://files.pythonhosted.org/packages/2d/79/4d110bf01bf9651bf9b3f88a6d9fa7e643e0586921a18432b25a478edbfa/regex-2026.9.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4a761ea45f2ad74c575ef5850ea514cef97302a552d3c7c9d1a1a870d4661d6c", size = 803722, upload-time = "2026-09-09T20:58:50.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/67/b587a0d3bbac2635309ed9c40c197120c39e1ec0afb8813cbed1338fdd75/regex-2026.9.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:75aa39d3f4f1650eea84e46b0d8cefe77dd5478c10e3d0aaf0b0f00493475a7a", size = 870085, upload-time = "2026-09-09T20:58:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/07/fc/0827bca20ddba1d70fa5111a2a64e6b6b38bfdab43fc435022b54172a111/regex-2026.9.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f5c629df03adec31ee505dda3c8988f106c9390e4cbd343600036eb8b3d6724f", size = 776970, upload-time = "2026-09-09T20:58:54.478Z" }, + { url = "https://files.pythonhosted.org/packages/9b/03/ab9d08d30568ca868791bfb99551db60b947f05e2e65dcd1261e87083c21/regex-2026.9.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3a66e40a1a20de96a2fee00ed67e11012b62d85b277688258677fd19997addb7", size = 863611, upload-time = "2026-09-09T20:58:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6d/8063ae86b543ae878a7d6e7ba21ebf0af4af06161230e0012e2d652320c6/regex-2026.9.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:968c1e33edd9a104d1bf24c8d476c72de7e3839ae7f894b37e9e4f4739fdeeca", size = 804064, upload-time = "2026-09-09T20:58:59.066Z" }, + { url = "https://files.pythonhosted.org/packages/c5/63/b19305c4b8d3d7867699f7d83c43550273d91a2f31793452d87edb1d259f/regex-2026.9.10-cp314-cp314t-win32.whl", hash = "sha256:fbc4e2f3cb7ce8436154e6483079e7d35eeb321a952fa936e180300630d8b873", size = 274607, upload-time = "2026-09-09T20:59:00.942Z" }, + { url = "https://files.pythonhosted.org/packages/32/b8/1695072a512a49060294024e23b945eeb87675b02c06e53a92a1b42b0bfd/regex-2026.9.10-cp314-cp314t-win_amd64.whl", hash = "sha256:c37fa93bf18bf4f90b01c0fa9f11ea567ee4b7dd8bf96e63663e5edc37aa38cf", size = 283944, upload-time = "2026-09-09T20:59:02.876Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9f/65bac17f39991a67e22f8b3c849fdc02a56147c97029b5351494341959b4/regex-2026.9.10-cp314-cp314t-win_arm64.whl", hash = "sha256:ffc2da104e43db716ce30cef9f28049a1faa6aca385dd8771b033268d0730b07", size = 283780, upload-time = "2026-09-09T20:59:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/67/ca/1d1f83bc2f8fff4f186266ac82d73254e686565530cec9ab5228fb5c63dc/regex-2026.9.10-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6afcad14310f1311d077553ed374b42a5e538f85a8c884b4e38e52de091c8077", size = 496869, upload-time = "2026-09-09T20:59:07.051Z" }, + { url = "https://files.pythonhosted.org/packages/33/42/4217510286501a2ebcd372b781b4754ac961e043fa13ef8dce803c44d89c/regex-2026.9.10-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:3fb4ae8cf83ef4e9addd43b2da31a9f45be816a8036fae8af59c8998b72718e2", size = 297121, upload-time = "2026-09-09T20:59:09.007Z" }, + { url = "https://files.pythonhosted.org/packages/55/a7/595468ed0bbccd94be92c6b5d67736ba128b204d942429a8485c2693914d/regex-2026.9.10-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f7d4656e17ab736e9415a6442a345bfc97bb8b7dcce47884bb74a37f70f08d0c", size = 292139, upload-time = "2026-09-09T20:59:10.859Z" }, + { url = "https://files.pythonhosted.org/packages/29/1c/ac92c123e0ab9bea75a904272171b356940bd6139e4a35de44f2254dca8f/regex-2026.9.10-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ba3bab0c45079735f55ac61526774de1d84bc4a0333cc554e1a4ab74913924", size = 802375, upload-time = "2026-09-09T20:59:12.823Z" }, + { url = "https://files.pythonhosted.org/packages/8d/16/d349f6fa9f908162359004e4f067353a0146e8074d24989490778244be44/regex-2026.9.10-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ff6b3267318661dfddf6b3628663e00e5946bd0a5c8fa678537a1401f0388f91", size = 872328, upload-time = "2026-09-09T20:59:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/df/81/251b5aef23147057e926346fdb7c8c352d0568f65a492e4e9eb6120f6446/regex-2026.9.10-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030fa9e23624e39b3b94e46b90a5abd1a1678eb2f58fcdd3fd6c27526bf91c7e", size = 919594, upload-time = "2026-09-09T20:59:17.31Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6e/1f25319dc1b9cf4b7ffa303f3d16ca53260fe92aff45e81aa1b3c6c7cba2/regex-2026.9.10-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fbc8314436353e097c050e11b01a6c11433579437ed0579730157676ef59e2f", size = 807394, upload-time = "2026-09-09T20:59:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/3da6b3dffddf12d5e96cbbe6f5e65ebcd64f92e3388a6690a53e0878232d/regex-2026.9.10-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e6c0b5ec6ddee4032247585dc491b0fa58627745b66a705728703a3f0331231", size = 786018, upload-time = "2026-09-09T20:59:21.592Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6f/1cc86dafddc912ef44c5ccd4f729060be8c6735600932664eb6d0e25469b/regex-2026.9.10-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bf29611e5376fec8f795879bb5c6153a76c3a292573d173c26784042b01eb840", size = 793504, upload-time = "2026-09-09T20:59:23.734Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cb/11692e29388d006211163627fcefa0c79917ab9f94d46a1b2254fe5efc81/regex-2026.9.10-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:ec8855f08c17895a26fbf5f19ed829722e19b34a96629e49a43c92974924026b", size = 866766, upload-time = "2026-09-09T20:59:25.798Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/3631df969830f94d83fcfc5fc71a7b39caad905e18f7b17350a94135d625/regex-2026.9.10-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94c5ce3bc41d226b4eb89ca3f842b2e28c031487fb1f34eb2153d98235831325", size = 775825, upload-time = "2026-09-09T20:59:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/762892a8e3e21d119eacaffde848aa247e6cf4e1d90c46011671e86b1b9e/regex-2026.9.10-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:9ce239acb15843ab03976626af810a4424b0409689ec2bbc52088ab5479ab487", size = 858901, upload-time = "2026-09-09T20:59:30.656Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/78048fada5c2f61d795efb26ea24f820a5564c4aa26574920284d45cd656/regex-2026.9.10-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c22df8dd6373bbe3898e77429ffc85594300e39d752fd0e68a31e59d37899376", size = 796208, upload-time = "2026-09-09T20:59:32.852Z" }, + { url = "https://files.pythonhosted.org/packages/aa/58/632681f7b9aaa3d83b40e5862ba46364a453c8eb2bc7d43fece3dc31f972/regex-2026.9.10-cp315-cp315-win32.whl", hash = "sha256:1aa309ab7ba89a62d6cf70dbd38d4176440bce3c7001ab86256704cf4c18c6eb", size = 272704, upload-time = "2026-09-09T20:59:34.927Z" }, + { url = "https://files.pythonhosted.org/packages/5d/64/81cce28754c37037b1fe740b6d7a556d51d97cf935ea4547bfab104f43f7/regex-2026.9.10-cp315-cp315-win_amd64.whl", hash = "sha256:58da726d3e766c0b3f5a3997dfaf0275898a1107b8191cdd6b0437fe45fd817d", size = 281182, upload-time = "2026-09-09T20:59:36.86Z" }, + { url = "https://files.pythonhosted.org/packages/33/28/5a13a340c9c759e863a0e7f765d323601d03d6538c8a627ed628662e083b/regex-2026.9.10-cp315-cp315-win_arm64.whl", hash = "sha256:75f9297b16fcb588a1f8d8a55dabef3c0c20b0c7bac43c87ceaaaf1a825c12f4", size = 281512, upload-time = "2026-09-09T20:59:38.875Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/a3caebcd5105be90708071db20bd261b0961b8ea4fe5e8be45c2632519b5/regex-2026.9.10-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1270cdec69248592bbe38a0b263ed58d907b891bd2b93703e225c317e421bda1", size = 501336, upload-time = "2026-09-09T20:59:40.987Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/ec8887b2658bf0a5df143c7c1fcd562b0abd2fa208c04ebf15c6607c9bb2/regex-2026.9.10-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:681ed38664b64c6617d3c3c332018d1948c77e139c5ea667c1886efa671e426f", size = 299318, upload-time = "2026-09-09T20:59:43.039Z" }, + { url = "https://files.pythonhosted.org/packages/d5/58/84724a9eccf6e8cd46f7e4534576093e2476a5eeb55e2453aa6606e86059/regex-2026.9.10-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8e127d9a80cbf1c3276bb465c6d047e8705e97b58c2b8f2f0c0a69c336b44b37", size = 294847, upload-time = "2026-09-09T20:59:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/50/03/70ccc5e53905984abf8eab63eebd3ce740522ef8c8d392622b64d79ef290/regex-2026.9.10-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:990797e765d89a423880052c68b61c31afe701de94a8c060f61c40605ca6c727", size = 814359, upload-time = "2026-09-09T20:59:47.194Z" }, + { url = "https://files.pythonhosted.org/packages/29/53/40f7a11ec547e4a947883c9d5e8a075f6d4f59af2b8dbcaa8bf5b504aca0/regex-2026.9.10-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e5e4a6e0734a685d13b9685622bb503bdbb2927f8b0df025a5085f0ea067475b", size = 875586, upload-time = "2026-09-09T20:59:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9d/83f3e022d99ce601727c4ef5f7b527753901ce4509ed89a1bb6a2263380a/regex-2026.9.10-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63bb62cf62217dc38c8a6b2b61b165b0e4eb8fa93b0aba12139251c0986a8fa3", size = 920990, upload-time = "2026-09-09T20:59:51.776Z" }, + { url = "https://files.pythonhosted.org/packages/b2/64/dfdb367d8f4f5c9b8ccb4b59789c2ce996f2c69c3b8192aa986fe7d92ec4/regex-2026.9.10-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb76a9c4e07a6a47849726af0ed14c41741a182f097f134a8cf29c1bc0f4dde8", size = 818660, upload-time = "2026-09-09T20:59:54.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/a6/fb7d0b64487913845834f319aa84f8377d55305958a5db7e19c128798366/regex-2026.9.10-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31e4df2b11d48f61d511019bc1ee9b477055f17c352b68fe72db7a98b14d603c", size = 794976, upload-time = "2026-09-09T20:59:56.799Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1c/23484edaae387ea0d31f4d414463211e3516c8aa210ce8d0640f5aff3502/regex-2026.9.10-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:cf377960d2ac37d987394a9dbaa75e91338c41a46d41e1d25e90125e7b3ee2dc", size = 804081, upload-time = "2026-09-09T20:59:59.39Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/e30d138f13aecec99ea9aecef7e31563de6ec6a2f5ce1d65fc506aee33fe/regex-2026.9.10-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8fbd9cb30c68c1686b94029b9ef845d5870d3d65baf66cb126b676849b9d72b", size = 870430, upload-time = "2026-09-09T21:00:03.304Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/81f9ce86f7ae4f57901543597c95751fa01c41a672ec1636dd913f8a000a/regex-2026.9.10-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:53e182b6b04d0011909b47d51a2d72d908de07c7b1c7f16b3adda2204d723bc1", size = 783327, upload-time = "2026-09-09T21:00:05.68Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1b/d7bf8f91534740f6a8ca17e5ac9c5337903baf527c8de240fbac1bbedfd0/regex-2026.9.10-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:0aa7589394230e0f0a422ab6b90841ff12c87e855e7aaf75d192a54a5f124548", size = 860874, upload-time = "2026-09-09T21:00:08.41Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1d/52cc88364aca7c9013dfe9abe0fea67f8d394efe384d07798300a6e2f27d/regex-2026.9.10-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1b891f77554bff991804cee24b78b40789f7d5993a24c7907bc7025fd2a70c8d", size = 806705, upload-time = "2026-09-09T21:00:10.768Z" }, + { url = "https://files.pythonhosted.org/packages/cb/51/37a194df7707f92f33173260ba7b221d4ec376419a53babaec50019a2804/regex-2026.9.10-cp315-cp315t-win32.whl", hash = "sha256:5bef622850cf760154719d4e0d74b0a855962432995168e250069899ae12fe8f", size = 274780, upload-time = "2026-09-09T21:00:14.003Z" }, + { url = "https://files.pythonhosted.org/packages/77/12/3227a52970d90908b230f15b2c86df903f49ac72c9eedf4f6e8b5bb5e1a7/regex-2026.9.10-cp315-cp315t-win_amd64.whl", hash = "sha256:07b45ba5c94b8fcb30cb6c56a11f715c57533a3017964504322ea52690a27b72", size = 283936, upload-time = "2026-09-09T21:00:16.275Z" }, + { url = "https://files.pythonhosted.org/packages/f3/bc/c567c5a61671f04d30e83f20b496b465432879196574d051007285576205/regex-2026.9.10-cp315-cp315t-win_arm64.whl", hash = "sha256:f70b9f0e39c2dba1d9da6bf7ef7c377cad7277f8440e9a69be05ede529ff024c", size = 283747, upload-time = "2026-09-09T21:00:19.113Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { 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" } +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" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +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" } +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" }, +] + +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + +[[package]] +name = "runloop-api-client" +version = "1.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/d8/e85232182d50fa8374f495bb518e29b0d7de8aacfc86e4598019eb8718e9/runloop_api_client-1.32.0.tar.gz", hash = "sha256:1f2077fb5a99b5ef30dbc1d96d47c1ca1d3f77895b9e186d985e47365fe85634", size = 670118, upload-time = "2026-09-08T23:00:33.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/17/7c9bd35638526ab83d2a40e67454b3445d5de5a74a0e56e52541edefa3a5/runloop_api_client-1.32.0-py3-none-any.whl", hash = "sha256:23c36910de7423f73aef91d4a963abea80a6b2f5b2df5bb8e439d788d5486cc7", size = 414368, upload-time = "2026-09-08T23:00:35.085Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + +[[package]] +name = "safehttpx" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/d1/4282284d9cf1ee873607a46442da977fc3c985059315ab23610be31d5885/safehttpx-0.1.7.tar.gz", hash = "sha256:db201c0978c41eddb8bb480f3eee59dd67304fdd91646035e9d9a720049a9d23", size = 10385, upload-time = "2025-10-24T18:30:09.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/a3/0f0b7d78e2f1eb9e8e1afbff1d2bff8d60144aee17aca51c065b516743dd/safehttpx-0.1.7-py3-none-any.whl", hash = "sha256:c4f4a162db6993464d7ca3d7cc4af0ffc6515a606dfd220b9f82c6945d869cde", size = 8959, upload-time = "2025-10-24T18:30:08.733Z" }, +] + +[[package]] +name = "scantree" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/e4/40998faefc72ba1ddeb640a44fba92935353525dba110488806da8339c0b/scantree-0.0.4.tar.gz", hash = "sha256:15bd5cb24483b04db2c70653604e8ea3522e98087db7e38ab8482f053984c0ac", size = 24643, upload-time = "2024-08-03T20:08:59.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ce/828467ddfa0d2fe473673026442d2032d552a168e42cfbf25fd0e5264e0c/scantree-0.0.4-py3-none-any.whl", hash = "sha256:7616ab65aa6b7f16fcf8e6fa1d9afaa99a27ab72bba05c61b691853b96763174", size = 20690, upload-time = "2024-08-03T20:08:58.137Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "semantic-version" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[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" } +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" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "storage3" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/30/fee43d523d3f680a833a4aae5bf8094de0b9031b0c2bddb3e0bc6e829e1b/storage3-2.31.0.tar.gz", hash = "sha256:d2161e2ea650dc115a1787c30e09b118365589ac772f4dd8643e3a503ecfc667", size = 20348, upload-time = "2026-06-04T13:37:23.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/b2/60d86a3a99ae743e8a00a6df912f85269b3bc882ba217b8ce1690881c669/storage3-2.31.0-py3-none-any.whl", hash = "sha256:4bf46e8bea320743179a6beafdc7531c5242495e00e0cc22af7c7a9d69d4ed84", size = 28492, upload-time = "2026-06-04T13:37:22.792Z" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, +] + +[[package]] +name = "supabase" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "postgrest" }, + { name = "realtime" }, + { name = "storage3" }, + { name = "supabase-auth" }, + { name = "supabase-functions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/8e/54a2f950629689b1613434a61fc3bff5f92f84ba6b20213f5b2add05c1bb/supabase-2.31.0.tar.gz", hash = "sha256:3467b09d00482b9a0138235bdbde7a350426f93cf2a1342372eaddfc669f1206", size = 9805, upload-time = "2026-06-04T13:37:25.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/06/5e6f4bf89dedadf81f893832115c1866da1aa093142c9989c2818780dae3/supabase-2.31.0-py3-none-any.whl", hash = "sha256:25f2a99207a75f2d9377e2332783b4389cf56b02cbebdaf0c1743112dcbb704e", size = 16728, upload-time = "2026-06-04T13:37:24.278Z" }, +] + +[[package]] +name = "supabase-auth" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/8a/408689cf39820f0d46d2731d6747ff94dbefc87ae977b4b5c4066da5b070/supabase_auth-2.31.0.tar.gz", hash = "sha256:0945b33fa96239c76dc8eaf96d7d2c94991950d24b4cfe4a5c2da9aa5e909663", size = 39151, upload-time = "2026-06-04T13:37:27.375Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/5e/22f3b0546bb1f0985f06fb1ebf5f6405a3b1bb7a5db01142c26cf148e988/supabase_auth-2.31.0-py3-none-any.whl", hash = "sha256:5e9c8b4ecdee6af04dbcb06455ce78cb15674806fcb6b425170455307d70b0ee", size = 48363, upload-time = "2026-06-04T13:37:26.26Z" }, +] + +[[package]] +name = "supabase-functions" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "strenum" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/5d/61c2446ed26a57fa5543f9c270a731320911569202340d15341f72cdba7c/supabase_functions-2.31.0.tar.gz", hash = "sha256:4ad027b3ae3bd28b31233339f4db1da6965affd3546f655b421baf40cee2690f", size = 4683, upload-time = "2026-06-04T13:37:28.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/79/1a8162ce7d705381a4f2668c0da68e097b103c1aa701418a31da52905c7b/supabase_functions-2.31.0-py3-none-any.whl", hash = "sha256:3fdc4c4766152bfda63bdd0e286fc8a06f50e1280711fae4a1dfc9b7e9ebabc6", size = 8794, upload-time = "2026-06-04T13:37:28.022Z" }, +] + +[[package]] +name = "synchronicity" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/1c/f51dc54bbd302991026a53f9790735540e0e9e1184e9d5939f02446aa5bc/synchronicity-0.12.5.tar.gz", hash = "sha256:94d96b1d85698e3056b96a793b8c0949af6584e4a7d877fabdeb5385efe230aa", size = 60745, upload-time = "2026-06-18T21:06:23.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/74/ad9b99520f70c0bc3318e582e359d360cfc0f7afd7bf368a7f24013cece7/synchronicity-0.12.5-py3-none-any.whl", hash = "sha256:fdbbb10d437bc08a6b0f814fc66fddd1b58ffed314533d42f1ab555801e781af", size = 41107, upload-time = "2026-06-18T21:06:22.505Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/62/167a842aa0429d45f5e797354fd4343a96f6043d67d0513c675c7b8d36e6/tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874", size = 38898, upload-time = "2026-08-17T19:49:49.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/da/e273746b9d24a63c776bc60fba914351573ad9c575b52601eb5e60632564/tiktoken-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36", size = 1094408, upload-time = "2026-08-17T19:48:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/fe6b1aca23331aa5271df5a4bd07bf68a7059254d47faee1b8272592a777/tiktoken-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4", size = 1038499, upload-time = "2026-08-17T19:48:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/0b/35/e9f47647c9e163bd1de30fe1a491669b7248cfc67b7404c35c009a701e1a/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6", size = 1186355, upload-time = "2026-08-17T19:48:51.93Z" }, + { url = "https://files.pythonhosted.org/packages/51/11/9976ad86980a00cdef05e730a0127a2578a1bc6d11644d8d47246de2eb26/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d", size = 1204197, upload-time = "2026-08-17T19:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/d4/9c/7035b0bcfaa68d1ee4803fc5be5214ad865669b05bd20e7105ae8a18afc6/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482", size = 1250635, upload-time = "2026-08-17T19:48:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/69cabf18bed7f4366da076735816abce0d4db3fae491ae338a6612128777/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6", size = 1316085, upload-time = "2026-08-17T19:48:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/bd/bd/a2e884fb1402cba5be08836590320012b2d8ada0e2eef9911a64df4bcd2d/tiktoken-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3", size = 941208, upload-time = "2026-08-17T19:48:56.938Z" }, + { url = "https://files.pythonhosted.org/packages/50/53/ee1453623bf65f019328721ccb6587846d2c5b7b82f34e73ca09101f072e/tiktoken-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9c5fe393aab56469f04e432ff851216d3def3436cf5f07e442a240164bf500f", size = 1094198, upload-time = "2026-08-17T19:48:57.955Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/6448cfe278c3664ba9ec5b5ac08344341f7dc3d42888476e215a14eda2be/tiktoken-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbe2cc3bba939bcdaf103e03df9d5039d33887080b315624be28ec69059e5f94", size = 1038820, upload-time = "2026-08-17T19:48:59.015Z" }, + { url = "https://files.pythonhosted.org/packages/69/3b/d67eac1bcce9dee3abe23aff5e3ded3116bbebaf67b80a0811c06d3806fc/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2157f52e4b4d7ac5ecc7457b3716834706e7ef9a46f5144029bfeb7cf71f4e06", size = 1186175, upload-time = "2026-08-17T19:49:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/37/62/cae690d9783146b0f81f564ada0f8f611de68178c0c9c7e1e969f0516b48/tiktoken-0.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:26e60f6a956ee171ab728b37b8439905d7ea1db435c30f9822f291e9861c861d", size = 1203884, upload-time = "2026-08-17T19:49:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1e/633e30237b94e383cf814145499079f3bb9cdd4aeafc1bc42e01b0f810a6/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:380873f330b741c4435574f37edb20813d04603ace2d53e0a63560e1fec83010", size = 1250980, upload-time = "2026-08-17T19:49:02.274Z" }, + { url = "https://files.pythonhosted.org/packages/cb/56/4c12f07b812f84206f38d723eb1ebfdd34bad9309b5dbc0bee6bbcff4cbf/tiktoken-0.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3fd7c14b1cb45b486c39fc9b3443bb341f3e2fc7e6f31247f3435a5836651632", size = 1315434, upload-time = "2026-08-17T19:49:03.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e0/c65603f0c44811def666d3fbf611bf2af3b5e1ef613e06c19411419830b3/tiktoken-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:90a762670c7f968184723769a06ed51f5cf5ce5dcd1e30164f25c72d85c2d1f1", size = 940883, upload-time = "2026-08-17T19:49:04.583Z" }, + { url = "https://files.pythonhosted.org/packages/59/b0/1cf129f4af8fc513931f931023def596b7c4bfc77026513cd9d851da9e88/tiktoken-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e067f4cbcc5d036e8aff7fe7a6b530a8f4de2e4616ad9005a24a1879e24e6450", size = 1096273, upload-time = "2026-08-17T19:49:05.807Z" }, + { url = "https://files.pythonhosted.org/packages/62/85/2ae74575e321148484147e10b53c3b1717c59ebaa9edb4fe18b1f5c055f8/tiktoken-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f2af4a336ea56d6c14f27741a0e1d8294a35dd0b038bcf990d232ebb54eb994b", size = 1040269, upload-time = "2026-08-17T19:49:06.943Z" }, + { url = "https://files.pythonhosted.org/packages/89/29/92a1120a12e4bcf2d5464350d1a91b68a433d63ce656bb7f806c27aec09c/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f702e0aeeb6506e57687e881c59e844ebe8f0a6a097ddafe20e3ab25f387be4e", size = 1186101, upload-time = "2026-08-17T19:49:08.102Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7d/144af98dc5ad68108451a82e2f5a17f80e2663f5115058b8dfd215c1ad02/tiktoken-0.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e3442bbb2f0c588cec876061e37ae67b455b9df9978b003c8fe30e45f2ef5b42", size = 1204457, upload-time = "2026-08-17T19:49:09.28Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1f/be7cb06ab2108f612f3e92e7b76cf391e192db0db37a984616f0cc32aafc/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:979c1524f753b662b0f3cd261b135afe6659cce33caaa7a5ea00dd1756b3055c", size = 1251716, upload-time = "2026-08-17T19:49:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6b/81f158d0f90adb826cd704069c2129a046cb784a2a09861009519fc41cf4/tiktoken-0.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2cc19ac87b41c9493c9778ff5847f0c8bbcf5bd0ec6b87ce06c1c802adc8a771", size = 1315432, upload-time = "2026-08-17T19:49:11.844Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ec/f5fa35ec13f07279fdcaf3cc9c04bbb154ea591d23978651f2b672593e8a/tiktoken-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:eceeff0c62419bc78d4b6e70a4762a4d25df3ae8f2d5946e3853ce93e7a57098", size = 988046, upload-time = "2026-08-17T19:49:13.282Z" }, + { url = "https://files.pythonhosted.org/packages/68/c9/7756717408d3d0dfea3f046c9466144b28afde39ff69d5808f2475dcd7f5/tiktoken-0.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6eb94895c45f26bb8f5546e5fd8a069efcf6e3f108ea9d5cbe3bf6f7f3983438", size = 1096261, upload-time = "2026-08-17T19:49:14.351Z" }, + { url = "https://files.pythonhosted.org/packages/79/29/46ad8061f57bd9f8b2ea0aa82bf574e0f2aa040b0857a1582adba9957899/tiktoken-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:86951a971c53979ec857bd8c4a32dc227ab0fd33f6c12a3bd62d3fbf5f0bfcaa", size = 1040183, upload-time = "2026-08-17T19:49:15.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7c/3184d17b868456f17b60b1a75f5ec0405618a43aa753336df341d8f11781/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e2eca764c53490f8930dbce329e0769f11108d87d908282a80c5c130e26e7037", size = 1186719, upload-time = "2026-08-17T19:49:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e8/46de4400d5bf859f640feee85bd7e32235f68ddf25db53c63be78e581e3a/tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:26cc4b4840fa0e9f4b72ed489883e12f57e00d1021ca794720e3c29a12f0edef", size = 1204660, upload-time = "2026-08-17T19:49:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/af8964c38bc8226dd8950305b7a255fa33345d5572f78af7275a313d28e0/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2fc834fbe3f6a0736905c36ab709537e6840dbd63b982dc9e0216ae7d305ba1a", size = 1250932, upload-time = "2026-08-17T19:49:19.28Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4b/323631116fc986d9cc5bbeb2b8223c7c85e61a8bb94ea5ab4951023b149b/tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ca4db6ff5c5bf600f9b7761a0070ed44dfe5797a76bd432fb978bc480ef40c58", size = 1315190, upload-time = "2026-08-17T19:49:20.467Z" }, + { url = "https://files.pythonhosted.org/packages/18/8b/ba48a73729c9270989b36f37ab2ed5525e52690d715097c9fa791aaa5d05/tiktoken-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7aab286a020660a039097912a088236b985d18a3090d73f136c4413d29d37ca0", size = 987717, upload-time = "2026-08-17T19:49:21.704Z" }, + { url = "https://files.pythonhosted.org/packages/1d/10/b73b7e319179e0f60b32475f783b044f9cece872c53b6662664e9084b0d0/tiktoken-0.14.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:14b47e3674f2624803a8acc8fb367b7e24fc53055f9df3296482fe9a3a34a232", size = 1096280, upload-time = "2026-08-17T19:49:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6b/09999a9bf1d559670d1680e8f8e419ac0e2c5f6aac82e9bfdf70f260b30a/tiktoken-0.14.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:19d643d701fdaa70e5b9c7f8f96abcaffe77ca5e482a3a1a7dde46feb4284695", size = 1040433, upload-time = "2026-08-17T19:49:23.998Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7b/8537be0836f3df99b2a636b44399bfa43cd757f2b8b4097dacb794cf24a7/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:e4ddf863b59347deaa92302dcd90e5eb003cdc9be06ec2b692c38d1bdd9efd49", size = 1186989, upload-time = "2026-08-17T19:49:25.021Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9d/f9c56d7a943a4468abf9ef37661bb9b8e0cd3aa8aa87368c7146cc3f3222/tiktoken-0.14.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:60c47ca69ddda0dea8256fffd12e1b86f4b59734a20e4a70c61f63cc5f021df4", size = 1204615, upload-time = "2026-08-17T19:49:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/98a38579db25c4a8a84e31dd95d9072ec5f21f7e70de591da0412e29b25b/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:728303a072163130c5b477b1f20d6211895569c1d5302c24ffc93a3009160871", size = 1251828, upload-time = "2026-08-17T19:49:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/0c/83/467be424746c039c5493c0f4102feab16b9b48eb6f5c089b2a2438e3cde2/tiktoken-0.14.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3c5349c9f916283bba32bec8af69b763e4faa304dc004d0eaaea66a3cf004c1f", size = 1316260, upload-time = "2026-08-17T19:49:29.101Z" }, + { url = "https://files.pythonhosted.org/packages/02/ee/ddf46ca78e371f5890e96b6e7d089a85b3536432be219851eb0481786ca8/tiktoken-0.14.0-cp315-cp315-win_amd64.whl", hash = "sha256:1b6e4adcfd285c44502aed51df98aaaca4f0fea028165dbf8a9e857b9f98d8ea", size = 988230, upload-time = "2026-08-17T19:49:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/2a/00/5162e90c851a28da18ed382d34898b79a8022548e5619a64e14c03ce7c3d/tiktoken-0.14.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:11d8211b290855d2721334ff17dd9b3a17bfb26872be01f25d73612ef7ece890", size = 1096186, upload-time = "2026-08-17T19:49:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/65/97/a5a7bfccf25b1bb65e82bae8edff11ac3c9c041c374b7b4a823d60c38133/tiktoken-0.14.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d0781223705199b289faa59601bb9c2441712d4c600dd13c43d8fd6a33d22cd5", size = 1039947, upload-time = "2026-08-17T19:49:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/ef427fc638f1439181c5e12dd26b70e881861f89c007aa7e5b36300f8342/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2ea70afba6b9eddbf22c165142e5f0a2ad7aa36a452873c48b57bb2aeb8492ae", size = 1186997, upload-time = "2026-08-17T19:49:34.121Z" }, + { url = "https://files.pythonhosted.org/packages/3e/88/2f3f85a968cdc514152129af0a060ebcccb067005a2f29b0d5ef3c838514/tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:78571efc311c30b73f31eb949a921d6dac39a5d9dc42d1cfa8f8db157b3447b1", size = 1205211, upload-time = "2026-08-17T19:49:35.284Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f6/80760e98a08e6649d2d68afb6035af713121dfb615acce8c4f73810ec438/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86f66c85e796f5d05d5c4a60ec1d40cbfebc47a32464053528c797163fa9ab89", size = 1251479, upload-time = "2026-08-17T19:49:36.419Z" }, + { url = "https://files.pythonhosted.org/packages/c5/84/50966fb6918a0fb9b32721277e5342bf729a2d74350074d662fbedf9772e/tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:149d97453c4c98c04b081d64a85e635921269b532710d6faf81e9e82b790e7d3", size = 1316673, upload-time = "2026-08-17T19:49:37.756Z" }, + { url = "https://files.pythonhosted.org/packages/35/5e/9b01afd037bfa22a0033963fa091e0f75b6fb15cd85bffb42ff86e697323/tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9", size = 987929, upload-time = "2026-08-17T19:49:38.947Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/1e/bc6587c5ab643b2e17776cace9070a2ae73549c86bffac9934a600bf3c31/tokenizers-0.23.2.tar.gz", hash = "sha256:7f0f085686b9de0d0079e6f874ae053600db64c5d13049e0bbc0119926d25aac", size = 385745, upload-time = "2026-09-03T08:55:42.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ed/8a443528baa6fac8dfe8c3b75b038c63ac92bb539bcabe311e227c718173/tokenizers-0.23.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:85a9a357a3764aecc904ee76bdaf8cf1ad8e5a67a1b929a487c4a39b49ed0e90", size = 3148852, upload-time = "2026-09-03T08:55:30.874Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/22da045a91732384d3a3771816bf188dc5a1f702c32e635afa7c679c0bef/tokenizers-0.23.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:986670e43691469dcee610ea0f846f91a8f84e91fc6f7a48d4c064414c0ec2bf", size = 3101593, upload-time = "2026-09-03T08:55:28.587Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4d/8f569ed49372a3ed8e57099bd515055fd48d7c95912c4307cda6973c2168/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a37039b5dfc4af84eb3ef0a92f4307e28936c8f9adccba2629d36f652e9bf7a2", size = 3516830, upload-time = "2026-09-03T08:55:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/e2f14c8919d5bf51874051d00d6c7b7e0e8bde6c6a2dbeddda7f642896ff/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b7e37ba198f24150f523e1242e83c4970de4a525480586be5dcc24d9add32c5", size = 3407975, upload-time = "2026-09-03T08:55:16.842Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bd/93c69152d02ef06ce47aed8b2bf4952dcf733c935a62791873932b2934d9/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43e4f2071e3cc8d5d86421c874aebc82659bb51a68bcdef5a0da75ee89511ccb", size = 3748165, upload-time = "2026-09-03T08:55:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b7/56b84b80bc96942bba8eb23751a9e8a1fce4faaf4390425e7083f721c98c/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325fee2e0418a9dc6c9ecf736a5f5f0db7875183ace9549ae339da76f7a1fbb7", size = 4024165, upload-time = "2026-09-03T08:55:18.806Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/0175e216f005c2fe08238292663aa41e4c802b216e71047a69a0e9fc6fa3/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:950d7c9426fa72406a0ffeacdbc0bb9985f5db20eb8b263f29c79aaf83105703", size = 3591899, upload-time = "2026-09-03T08:55:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/ca6b93c7820df123b2662a9469e8facc826ccc94e98fdd0d615f6431e73a/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305", size = 3386843, upload-time = "2026-09-03T08:55:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a4/4f9106d317b14a80aefea9f0e3a8d07ef25f856a7607eb7f5ab894281fcb/tokenizers-0.23.2-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:12f0835dc2ee694746a76adf7b1567d4346a4a502ebe93fb1f5f80ea49799b78", size = 3577314, upload-time = "2026-09-03T08:55:20.825Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6a/1552b70fb0d9ab074fd3fc961435d01364e79c9058481822c3af6e8d402c/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb2f9c8a24da020ea8c11a01a19c1c2547912d92121ae4a01cfbca46125dee40", size = 9967367, upload-time = "2026-09-03T08:55:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/06/01/3ccb3a956c7528b2507b8a9714155c4baf86af593039db6ea375dd0c96c3/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f486f402f6f9abee5bb032553736813af0c710a86b2e0ca592634c55cea1f835", size = 9811886, upload-time = "2026-09-03T08:55:35.642Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/7038e612d48bda1599457f712f6bd3854eae1a9dc9c13aa47f835349db48/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:bef235815a067b2648caf6dcc7a71091b0b0fff9ee8057f6451eb9335fae52ef", size = 10146224, upload-time = "2026-09-03T08:55:38.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d8/8e9e4e0b287a338d8f88976729628c9d22e8a54cfaf9777018a7f7cb58a0/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5c56bda1511921587789163e524d196ed8284174ac23abd7685d5ea8da6c4718", size = 10256304, upload-time = "2026-09-03T08:55:40.977Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1f/c79a01f671a49728ebb0b61f7ff9ea45663b66cab40bc0858e9859b25c16/tokenizers-0.23.2-cp310-abi3-win32.whl", hash = "sha256:debf978920d93ba9c219bd67cc4bbfaf912c9039e41e7a28b91ec15e3728c95a", size = 2592809, upload-time = "2026-09-03T08:55:48.02Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/0a69ac6b82dbccf3f71add938a161c497952749294b8dd6dfe03a819dc40/tokenizers-0.23.2-cp310-abi3-win_amd64.whl", hash = "sha256:2e96f5699d5249c9c64aa8412e044f727aae3a4098cf830f9901ec1afc361cde", size = 2863236, upload-time = "2026-09-03T08:55:46.193Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/dee84cb44175be1b4c35bd2f770727494e78f0bb38e571a623ade94dbebb/tokenizers-0.23.2-cp310-abi3-win_arm64.whl", hash = "sha256:e49c394456dd9985787fec76132438ba3fb8911f857b1bf3d40119f9292d41aa", size = 2729352, upload-time = "2026-09-03T08:55:44.345Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, +] + +[[package]] +name = "typeguard" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, +] + +[[package]] +name = "typer" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, +] + +[[package]] +name = "types-certifi" +version = "2021.10.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/68/943c3aeaf14624712a0357c4a67814dba5cea36d194f5c764dad7959a00c/types-certifi-2021.10.8.3.tar.gz", hash = "sha256:72cf7798d165bc0b76e1c10dd1ea3097c7063c42c21d664523b928e88b554a4f", size = 2095, upload-time = "2022-06-09T15:19:05.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/63/2463d89481e811f007b0e1cd0a91e52e141b47f9de724d20db7b861dcfec/types_certifi-2021.10.8.3-py3-none-any.whl", hash = "sha256:b2d1e325e69f71f7c78e5943d410e650b4707bb0ef32e4ddf3da37f54176e88a", size = 2136, upload-time = "2022-06-09T15:19:03.127Z" }, +] + +[[package]] +name = "types-toml" +version = "0.10.8.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/11/6ece999e91f2ccb848ab4420f3f4816e78ac0541f739e6864affdaaa5737/types_toml-0.10.8.20260518.tar.gz", hash = "sha256:80e10facd24fdeda9d5c672187d72be3ac284843788d67f5aae59e3e016db6fe", size = 9419, upload-time = "2026-05-18T06:02:16.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/25/489751806bf5c95e4007f8e17409199c54d31e49ffbea07c5729b1286c8e/types_toml-0.10.8.20260518-py3-none-any.whl", hash = "sha256:0e564ab05f6fde62a315b3b5a9b6624fda569399795d30a37e64705a70459303", size = 9669, upload-time = "2026-05-18T06:02:15.86Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + +[[package]] +name = "uncalled-for" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5a/92ce0b3ea5481915f55da994c2c2c5f7a3c09949afde196ee89f8ab961aa/uncalled_for-0.4.0.tar.gz", hash = "sha256:335b95bd2422332ec210d518f314a16e4c640921c39fc8bf2ad095bd3538f4af", size = 56979, upload-time = "2026-08-10T14:51:46.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/40/97cec87c077eb3291fc7905e6633e08b7ca593c57d30238444bcb6bb3d53/uncalled_for-0.4.0-py3-none-any.whl", hash = "sha256:16c4bb3337532e4bd5569adc192285976f3ad5305402256d34c67a12b5c968bd", size = 15502, upload-time = "2026-08-10T14:51:45.068Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "use-computer" +version = "0.0.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/98/000d9d8ecef3bff34567f3176e7a246a521cc2268de6e9e83e8c8fcdbf7c/use_computer-0.0.46.tar.gz", hash = "sha256:f5167d6d9fa146cb14c84bee3916010c342c7e8cfe331a93dbf5db2216bf196c", size = 460911, upload-time = "2026-09-04T15:16:46.431Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/fb/d51d45eca4d15ee948ac4fe3947d439104be7a30cf04eb705d3218a40a84/use_computer-0.0.46-py3-none-any.whl", hash = "sha256:2a6a7ddabd280914826f8ed7817cc32c65ad591fa30e276e0d8671b778463e08", size = 82865, upload-time = "2026-09-04T15:16:45.306Z" }, +] + +[[package]] +name = "uuid-utils" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/35/2e9666504bcb3ab50656b86cd468880cedb86d3c97b88d3805dcc124b95d/uuid_utils-1.0.0.tar.gz", hash = "sha256:8ed2e0156d29c4cfa0f931b4b71b35d2705d84054f63ba07a78f7acc2eb09a5c", size = 43759, upload-time = "2026-09-08T13:27:28.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/57ecfa3d19021dd361d54c3213fa504122e24fcf379a6804c9077b2ec8cd/uuid_utils-1.0.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:20d6b4ea345912ecf2ba0dfc0bee0714c40b092830f814f1b768c33a55a38da0", size = 557800, upload-time = "2026-09-08T13:25:45.277Z" }, + { url = "https://files.pythonhosted.org/packages/78/31/fc8cab83464720c384082398f96c25b2b77de327485d436cfc73aba21358/uuid_utils-1.0.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:75f132bd55a715d091e8b2e91a118862a203777d34c4b2794caa218de0cd947b", size = 287237, upload-time = "2026-09-08T13:25:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2f/496b126dd703e12b33637246793abd97e05fa163e764ada0be4abca37056/uuid_utils-1.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61ad43743b1b6dd791798c37a5163d236d57705fa32944cee35c5d4f06c23009", size = 322768, upload-time = "2026-09-08T13:25:48.196Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/627a187b22b97b29aa7f3af02edd898fcb33c472c8c4898c6f5103fb868e/uuid_utils-1.0.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:129d2e1245c0f54282cdfcfb9498342808fdf9259782aa01e09d15e4c51b8a83", size = 330985, upload-time = "2026-09-08T13:25:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/f8/20/5bf65a065f369ce0fd8a031688c2767265ae3ee0e6293002633e2fd1dbdf/uuid_utils-1.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64afb1db6f732b9526cda719275922ab9e3a4ff7dd255b89f40709e65c70dbb2", size = 445612, upload-time = "2026-09-08T13:25:51.167Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d6/d3dc9b10ac5d6453d5225459b5bc2a2a7a9f53b7139d13727aa64661c2da/uuid_utils-1.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95b5ec6b070e5e3f3e02f7d195a22b90c1037afbc41006f122fb6d0499938276", size = 324362, upload-time = "2026-09-08T13:25:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/aba31a49583a59dd9022136a0032ffc6a8bda71d7f894da289aaa63f48d6/uuid_utils-1.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4f401251b95ddb077daed0871d4f43b4a7af88c80da595329206e734b4629e7d", size = 347120, upload-time = "2026-09-08T13:25:54.449Z" }, + { url = "https://files.pythonhosted.org/packages/23/e1/ebefd7241f763ca0a37ec7caad67e2d84e311335f74c5f3cf6ec52ce2e2f/uuid_utils-1.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d8b55e9759506f5b5849747f6fef927d3d81970d8c20285a99e797119ae3ca5", size = 501346, upload-time = "2026-09-08T13:25:56.135Z" }, + { url = "https://files.pythonhosted.org/packages/47/0e/7d155c4ea6af24eab30ff926d737d028f4cb356f6446f23efc9cbefeb44d/uuid_utils-1.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:2f9b1f16576237171e2782390f95d888dd7adce851fc85016e4b7b25d1c89fc0", size = 607597, upload-time = "2026-09-08T13:25:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/88/79/87708b9b618a29883d6b7be62569972aa5f7bc4af63338bc496532b5640b/uuid_utils-1.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2ee3fbcc187d2b46e1bbac64203fca75b24451f1d39a436b8196d1445bf79014", size = 564136, upload-time = "2026-09-08T13:25:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/0f53c4954944311d51fd6ae9db25ad705e6c433d0db04c753b0743aa2c2b/uuid_utils-1.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:083b21bba1adef508f84f8ef8da3fe2f085bd95988b1c2333618f06a804efd16", size = 529072, upload-time = "2026-09-08T13:26:00.857Z" }, + { url = "https://files.pythonhosted.org/packages/be/5b/4124fc1794ce8ed5fe4ced71ececdc0cacfb1d08c6d192c735402ae49d58/uuid_utils-1.0.0-cp312-cp312-win32.whl", hash = "sha256:e8b27a32095b43eb9e4abcc297afc4d4f4b130e9fcf9c9d09f93eec1382d1f8c", size = 170398, upload-time = "2026-09-08T13:26:02.437Z" }, + { url = "https://files.pythonhosted.org/packages/83/23/f1eacc16c91cd78ff86e62990b650adebf65782c2b992564c41311201662/uuid_utils-1.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac9e2ed981262301c85e27520b2564d03102bb8be95130e3085f0060add619be", size = 175625, upload-time = "2026-09-08T13:26:03.771Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9a/729645992d308d2806043cf5e8add10413b072574e0a25b3fc014053cc5a/uuid_utils-1.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:6988755b05dd27af81da59ae1bf15b14226e824d87dd98b60559d116df6826d1", size = 174128, upload-time = "2026-09-08T13:26:05.089Z" }, + { url = "https://files.pythonhosted.org/packages/c6/59/950f27905400b098797d8996d914fbc7faf73e4eb7be2ed5b5bbc16005eb/uuid_utils-1.0.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f82137ecd4ffe69134ed632ca865587dd44ddc5caf512cfefe181c0f6eba4cb7", size = 557666, upload-time = "2026-09-08T13:26:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/da/b5/aae34a85fd138c084440a0cc510cb245c11e5696f79e54f1a36225aae3b4/uuid_utils-1.0.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9ab40d7cfbbae6b2f291e597a664d82b8aee38debec9dac83c951adcf2c6c331", size = 287174, upload-time = "2026-09-08T13:26:07.968Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/0df3e19cb56969514d14b466069bb6c14f10f8d711f1b67487f280f34c6a/uuid_utils-1.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1c12ee0c50756a35047fcaa7faba50b464462e2eaae65d60b608288a246191e", size = 322917, upload-time = "2026-09-08T13:26:09.442Z" }, + { url = "https://files.pythonhosted.org/packages/14/77/07b9c92a711c69c0fb6bc25b9b7f9de2fce00dde7d56e433b5475cceed1a/uuid_utils-1.0.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab2b2b41690c6207ffafb01d0bc32ef7b3da56342b2b861ff5e03a772e39a928", size = 331363, upload-time = "2026-09-08T13:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/f28c80de9657aeb2fcd42ac0e5409f9dc22b3dd6438708e083bcf41c3a1d/uuid_utils-1.0.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe5f9fe3dfd076adc4460d3ede28c9225d47f998f77af77e67cb9d1c1ca935a0", size = 443183, upload-time = "2026-09-08T13:26:12.432Z" }, + { url = "https://files.pythonhosted.org/packages/cd/71/49ad8656c0c0565e17caacf3cf0d270605ca98775a957909d60daf7754d3/uuid_utils-1.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:184b79b46e85c322b537b48d81b750952f6874db8e0662441b176de13b10b0bd", size = 324259, upload-time = "2026-09-08T13:26:13.989Z" }, + { url = "https://files.pythonhosted.org/packages/44/ad/a88215e7fcb395929d09045c9fea2505ef0af7130aad2674a6d03543b142/uuid_utils-1.0.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4f0bcbaea1199ddc92cb94a0d7df151e4fbed558e6c00d900a214913f67a901f", size = 346937, upload-time = "2026-09-08T13:26:15.555Z" }, + { url = "https://files.pythonhosted.org/packages/1d/19/7f07c428461fb3923081065a6d38cb6782c5e08e87ee28cb84a61eaf2797/uuid_utils-1.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4c4bf45ffb105c5a8d701bcbe4c1a0a28a55d29090fc6fcb0d1504cb9bd2b85", size = 501465, upload-time = "2026-09-08T13:26:17.038Z" }, + { url = "https://files.pythonhosted.org/packages/86/cd/72c265eb24499b9b57bf368a349a57d6ff0b8a979b879ed7828a41714df4/uuid_utils-1.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:36eb692a959e54815edb0df9b1f566fcf889292f92e810a1019d2fee783ba830", size = 607865, upload-time = "2026-09-08T13:26:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/2d698113ecfd9b071191219fcfb98852d924dcf5387a4b00458863547a58/uuid_utils-1.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:71bd49db6f19d9dff7dfd560c81b72083455ab22ce2114837fca5b3adb2b790b", size = 564249, upload-time = "2026-09-08T13:26:20.01Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b7/399e029f06cd587a5719ff3dd59af7a34124ccb6a5f6078304391b6c015c/uuid_utils-1.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d1890c89b50a70e3651db7a88aceba2f73c4a00d4bdca6c3c0c777480f69fefc", size = 528943, upload-time = "2026-09-08T13:26:21.579Z" }, + { url = "https://files.pythonhosted.org/packages/1a/cc/6aa21ec6d99ff3eaa53673e23d06917ddfc1812a0361805ecf081ed9710d/uuid_utils-1.0.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:6d94d66a073d76dbb3662baa1a659f4b6f4c87cddb9e5ced611e93a4fd34f55e", size = 99237, upload-time = "2026-09-08T13:26:22.97Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/c6ba25b7153c4257c7629e7eab446830cc9b72c73772e687253b00d23e95/uuid_utils-1.0.0-cp313-cp313-win32.whl", hash = "sha256:a33de2ae30c8f5a0b82294ea979f19951c01f39a7800c9806b50d7a1b301253c", size = 170584, upload-time = "2026-09-08T13:26:24.495Z" }, + { url = "https://files.pythonhosted.org/packages/e9/67/c9815dce0216be38b0eb89fd1bf198657649422679b28d93697ba477bbb6/uuid_utils-1.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:3474e58925c9779012318785a82a0e883b5f99cbf1cde75f60c28b9252a3840a", size = 176648, upload-time = "2026-09-08T13:26:26.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/55c869c409c9b372d5e8a9ed709c4937f3030df34833644a599db0e70d91/uuid_utils-1.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:b8f6a3a66703943f5cfcd317b77565fe9b7c5d8eeee50282de673bdeff1697f6", size = 174877, upload-time = "2026-09-08T13:26:27.515Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/6dfd6641e312d8d714c64ce19540b95d79caaa79bec31b41f35689bcd921/uuid_utils-1.0.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1501e0aef7e2ea759b6aa3967395896d803e8073d360ef95f62e18c52c5730f4", size = 562333, upload-time = "2026-09-08T13:26:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/ee/01330e815a75a5fe65f156ef194f2aabc214d3d8129e62c814579bde3040/uuid_utils-1.0.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:06f5da96427cfb28dc374a212ffe2d9becf5d099be9ee6b06600f77c31acb016", size = 289324, upload-time = "2026-09-08T13:26:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/34/d2/0a5b7baba5590460c610f436c9108e5897010d745de423246192a5c5f2c5/uuid_utils-1.0.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:874b4fbb841197d94f256db3058ede8687e5293f3838b988346aad1f9af46b12", size = 324645, upload-time = "2026-09-08T13:26:32.546Z" }, + { url = "https://files.pythonhosted.org/packages/39/4b/141d0547f40f1a56888e186722431971b2978ab82f001445d2aca8c0e293/uuid_utils-1.0.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:570de62607ca78bfb5f5ae09871aaeb7d5d3f41b625663df124fbd1f56a87a98", size = 334452, upload-time = "2026-09-08T13:26:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ea/342fbc8bcbf07cc3c137a01d6a4ddbe622b476120bb7c2a4be5c1699ecdf/uuid_utils-1.0.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b87289c3e9e1ce8a6849d8754cbbbe06b96cade7b72261675eb0d5444955d935", size = 448997, upload-time = "2026-09-08T13:26:35.87Z" }, + { url = "https://files.pythonhosted.org/packages/88/da/6451810f642abeeb158b7db39104d55a6df729fa9068ab946dca72c9a7dc/uuid_utils-1.0.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0538babadda38ce86196315a710b10c7928697e33cc4ad01573aecadad9043c7", size = 326083, upload-time = "2026-09-08T13:26:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/96d2a6b9b6dd3d8ec52d3f00c21aa3ce94c7775d4cd1796655393b996d85/uuid_utils-1.0.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0482b3f41f9c9f5c2a59f865de8a9498e0a8d82649955a2b5ec5ceb2793ddb1f", size = 350628, upload-time = "2026-09-08T13:26:39.022Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2d/f0ab13421101c1917a3a969d16aa5b998643446a8c0a5376b2909bc81d50/uuid_utils-1.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8af4a4166e84be69ba78851bcfb529302845f0551d2be87499e6f7c8859f9bc8", size = 503168, upload-time = "2026-09-08T13:26:40.518Z" }, + { url = "https://files.pythonhosted.org/packages/08/fa/1a31c44665623562def4b1991c7e2951ba3fa7678390d117aafdac45eb90/uuid_utils-1.0.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9ddba367907dbb0892583dfd6a5989dda9257428b4022c86def98cc164db6096", size = 609820, upload-time = "2026-09-08T13:26:42.32Z" }, + { url = "https://files.pythonhosted.org/packages/df/c3/a441d1ade251b19b31728fe44aaba22d6d135dd7b90e4b6318d701ee62ae/uuid_utils-1.0.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0e2e87a57cf1346786cd8067c1ebee1a0991996686a8fd9fa62db0d377a219ce", size = 567491, upload-time = "2026-09-08T13:26:44.309Z" }, + { url = "https://files.pythonhosted.org/packages/83/33/39a1d3a4d7e223aed19d61aeb3110d27eef2a640dcee0ab538f6b1821045/uuid_utils-1.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d892757ce2dbe4224a4fe5e589b9d12cefc77765c8d388b8f041433edec63d4", size = 531390, upload-time = "2026-09-08T13:26:45.886Z" }, + { url = "https://files.pythonhosted.org/packages/c7/01/f9139035e3fdbd9e395c23c60b0b5d97462930130f5bac68e4a4c1cf378f/uuid_utils-1.0.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:72db71871915b8048e63444c91587a28c92eb9ac15dba94568bef20ec350e238", size = 101139, upload-time = "2026-09-08T13:26:47.504Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/d914af7a3504a086eeb525625ead867b47731ce28e1cffbd995a6f923a51/uuid_utils-1.0.0-cp314-cp314-win32.whl", hash = "sha256:fda1280fdbc110b7e9166796e30974f3400bac8e1fe135c9da00e96acc7c51f5", size = 172895, upload-time = "2026-09-08T13:26:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/cd/18/6c1700d7d637a4fea48f83b83ecd395bd66e685e2d6afdaf113be115f552/uuid_utils-1.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:7126a2b7a43ae6abdb5143aa228ebfefb8e436cc4bcbf91fbf080cf06c26f5cd", size = 178287, upload-time = "2026-09-08T13:26:50.325Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/02cd47c857bce282434b02732c5ee601e76a2c15db0590fbb130c2a8c2c4/uuid_utils-1.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:ac789644b2b50a5fb4c670df304f03259d9c2bec7c3b46facbfed760cd0249a3", size = 176454, upload-time = "2026-09-08T13:26:51.776Z" }, + { url = "https://files.pythonhosted.org/packages/a6/bb/805a581bac06982b94ce786092cdd48e7aaa3b4d02bacee7980129933979/uuid_utils-1.0.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7acf1911189491b976c0a55889c8420cf4040c242a3c3d46ce2bf7e654a2398e", size = 563300, upload-time = "2026-09-08T13:26:53.335Z" }, + { url = "https://files.pythonhosted.org/packages/b2/36/d5d54eb9b8673a0e410bca3e27cacfa153d6547635c13ac08e4625ed4a44/uuid_utils-1.0.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c2c3ca406fee6bae70aaed1341ce22d3dc50b34ab2d965174dbd3a69c9e5b770", size = 290189, upload-time = "2026-09-08T13:26:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/44/b1/2cb5fafc86d6dab4269e9bbebb125e204bc3f5d1003ef48741d87f97044f/uuid_utils-1.0.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f2852ae83fd158fbb7b842bdd5119010a3d101b3fc1d80bd6f2353469e3b054", size = 324986, upload-time = "2026-09-08T13:26:56.324Z" }, + { url = "https://files.pythonhosted.org/packages/05/02/1f9632c4c8ba04c00f41323e68221757abb3af32d8da706d2191001d212a/uuid_utils-1.0.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b6c5661b63ca6b83c5bb4d916b00620883334ee639f61065ef9a6036e4f406f", size = 332704, upload-time = "2026-09-08T13:26:58.005Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9b/6682303842806da6874022e706325601e86780dc4cc421d2a3ab1c4bb98b/uuid_utils-1.0.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73facd346472b56ffec54d6dc05a0506d7ece0e7d9c6bb53e6bf711cab7e7f07", size = 448017, upload-time = "2026-09-08T13:26:59.616Z" }, + { url = "https://files.pythonhosted.org/packages/44/ca/4213f7bd913695b18cf6a280204368cc4869aa89eb6005b93d2a0013fe09/uuid_utils-1.0.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad58a78d06c232f1c817f31d4b974ec59608faeffa4b07b859fc023f8add1d17", size = 327722, upload-time = "2026-09-08T13:27:01.078Z" }, + { url = "https://files.pythonhosted.org/packages/26/a8/691f91c8d28be9bc567983bdef53c49021ddfd3582fbdc4f9a3682895ac2/uuid_utils-1.0.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a1417bb6abce4039a1b4a8a4d83e6ddaa694809297f2d167bffea077cd7e8aa", size = 350181, upload-time = "2026-09-08T13:27:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/88/50/96143792454351baae7d5eafe877c67c9a3c1d9f5453ffd8a817f66e0de7/uuid_utils-1.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3d9c6ffb566f29d741c6e59a367d4ddf073fbd3decd50c43ef99aa3a3285b691", size = 503063, upload-time = "2026-09-08T13:27:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4d/333c251382484bcbc41e83046ecc95d7e6ece767cabeaf79af69d779f23f/uuid_utils-1.0.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d826362cacccd6ca52140877c6b84fb0ea6544b9cdf8bd860eb877f76f16ba4d", size = 608178, upload-time = "2026-09-08T13:27:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/69/db/57f42643e324d1dd4b8764e9f51f63cccc97e0ea9dfdfae4763e616e43fd/uuid_utils-1.0.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:87f68b983e73ef8aacb4fbcfabda37d2d6e0431c54a209c1853bc3487e3237cb", size = 566908, upload-time = "2026-09-08T13:27:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/c5/54/deb1dfd1634df28de05d73ee874fc8cc2745ba8b71776bddea039f6954bd/uuid_utils-1.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f18787481b251b700bce778a8906b7683478fd4895878ec4b38e150d481fbaab", size = 532506, upload-time = "2026-09-08T13:27:09.425Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/0e811a8817a1bb4e974b42ffbeac3eb8a01b6c75795c5632d8f078b69781/uuid_utils-1.0.0-cp314-cp314t-win32.whl", hash = "sha256:7217beaa4650030bc225d21583fe6105dbe33271b8cb994bca367cdc32dac47e", size = 172683, upload-time = "2026-09-08T13:27:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/fb2336e98432cc13bf26765740bef8d15951e62e3e83e103ebd081748923/uuid_utils-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a8cc8dbdda2615d4aef270bb0f1a931071b4211a21e18dcbe734143beab1780", size = 178478, upload-time = "2026-09-08T13:27:12.361Z" }, + { url = "https://files.pythonhosted.org/packages/ad/47/cdc26ca2af2fec7ecd7154d7a611237a56d39bc58ba8562f6cd0800f3324/uuid_utils-1.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b4920f5abfe2c84c5b2fc3e6b6063f8c7032289adb92d578750e0c1937ee77da", size = 176067, upload-time = "2026-09-08T13:27:14.183Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchdog" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/38/764baaa25eb5e35c9a043d4c4588f9836edfe52a708950f4b6d5f714fd42/watchdog-4.0.2.tar.gz", hash = "sha256:b4dfbb6c49221be4535623ea4474a4d6ee0a9cef4a80b20c28db4d858b64e270", size = 126587, upload-time = "2024-08-11T07:38:01.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/f5/ea22b095340545faea37ad9a42353b265ca751f543da3fb43f5d00cdcd21/watchdog-4.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1cdcfd8142f604630deef34722d695fb455d04ab7cfe9963055df1fc69e6727a", size = 100342, upload-time = "2024-08-11T07:37:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d2/8ce97dff5e465db1222951434e3115189ae54a9863aef99c6987890cc9ef/watchdog-4.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7ab624ff2f663f98cd03c8b7eedc09375a911794dfea6bf2a359fcc266bff29", size = 92306, upload-time = "2024-08-11T07:37:17.997Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/1aeba2c31b25f79b03b15918155bc8c0b08101054fc727900f1a577d0d54/watchdog-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:132937547a716027bd5714383dfc40dc66c26769f1ce8a72a859d6a48f371f3a", size = 92915, upload-time = "2024-08-11T07:37:19.967Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/eb8994a182672c042d85a33507475c50c2ee930577524dd97aea05251527/watchdog-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd67c7df93eb58f360c43802acc945fa8da70c675b6fa37a241e17ca698ca49b", size = 100343, upload-time = "2024-08-11T07:37:21.935Z" }, + { url = "https://files.pythonhosted.org/packages/ce/82/027c0c65c2245769580605bcd20a1dc7dfd6c6683c8c4e2ef43920e38d27/watchdog-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcfd02377be80ef3b6bc4ce481ef3959640458d6feaae0bd43dd90a43da90a7d", size = 92313, upload-time = "2024-08-11T07:37:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/2a/89/ad4715cbbd3440cb0d336b78970aba243a33a24b1a79d66f8d16b4590d6a/watchdog-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:980b71510f59c884d684b3663d46e7a14b457c9611c481e5cef08f4dd022eed7", size = 92919, upload-time = "2024-08-11T07:37:24.715Z" }, + { url = "https://files.pythonhosted.org/packages/8a/b1/25acf6767af6f7e44e0086309825bd8c098e301eed5868dc5350642124b9/watchdog-4.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:936acba76d636f70db8f3c66e76aa6cb5136a936fc2a5088b9ce1c7a3508fc83", size = 82947, upload-time = "2024-08-11T07:37:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/aebac95d6f954bd4901f5d46dcd83d68e682bfd21798fd125a95ae1c9dbf/watchdog-4.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e252f8ca942a870f38cf785aef420285431311652d871409a64e2a0a52a2174c", size = 82942, upload-time = "2024-08-11T07:37:46.722Z" }, + { url = "https://files.pythonhosted.org/packages/15/3a/a4bd8f3b9381824995787488b9282aff1ed4667e1110f31a87b871ea851c/watchdog-4.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:0e83619a2d5d436a7e58a1aea957a3c1ccbf9782c43c0b4fed80580e5e4acd1a", size = 82947, upload-time = "2024-08-11T07:37:48.941Z" }, + { url = "https://files.pythonhosted.org/packages/09/cc/238998fc08e292a4a18a852ed8274159019ee7a66be14441325bcd811dfd/watchdog-4.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:88456d65f207b39f1981bf772e473799fcdc10801062c36fd5ad9f9d1d463a73", size = 82946, upload-time = "2024-08-11T07:37:50.279Z" }, + { url = "https://files.pythonhosted.org/packages/80/f1/d4b915160c9d677174aa5fae4537ae1f5acb23b3745ab0873071ef671f0a/watchdog-4.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:32be97f3b75693a93c683787a87a0dc8db98bb84701539954eef991fb35f5fbc", size = 82947, upload-time = "2024-08-11T07:37:51.55Z" }, + { url = "https://files.pythonhosted.org/packages/db/02/56ebe2cf33b352fe3309588eb03f020d4d1c061563d9858a9216ba004259/watchdog-4.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:c82253cfc9be68e3e49282831afad2c1f6593af80c0daf1287f6a92657986757", size = 82944, upload-time = "2024-08-11T07:37:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/c8931ff840a7e5bd5dcb93f2bb2a1fd18faf8312e9f7f53ff1cf76ecc8ed/watchdog-4.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c0b14488bd336c5b1845cee83d3e631a1f8b4e9c5091ec539406e4a324f882d8", size = 82947, upload-time = "2024-08-11T07:37:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d8/cdb0c21a4a988669d7c210c75c6a2c9a0e16a3b08d9f7e633df0d9a16ad8/watchdog-4.0.2-py3-none-win32.whl", hash = "sha256:0d8a7e523ef03757a5aa29f591437d64d0d894635f8a50f370fe37f913ce4e19", size = 82935, upload-time = "2024-08-11T07:37:56.668Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/b69dfaae7a83ea64ce36538cc103a3065e12c447963797793d5c0a1d5130/watchdog-4.0.2-py3-none-win_amd64.whl", hash = "sha256:c344453ef3bf875a535b0488e3ad28e341adbd5a9ffb0f7d62cefacc8824ef2b", size = 82934, upload-time = "2024-08-11T07:37:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0b/43b96a9ecdd65ff5545b1b13b687ca486da5c6249475b1a45f24d63a1858/watchdog-4.0.2-py3-none-win_ia64.whl", hash = "sha256:baececaa8edff42cd16558a639a9b0ddf425f93d892e8392a56bf904f5eff22c", size = 82933, upload-time = "2024-08-11T07:37:59.573Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/15/dc61746d8c0852f6d711ad09c774b63cf7c8211aa49e30871ac3d342b7e2/wcmatch-10.2.1.tar.gz", hash = "sha256:ecac70a5c70e62ba854b78318d3a1408e8651f8f1c96e5837743b71aa6a4fb92", size = 132497, upload-time = "2026-07-02T17:21:48.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/ba/20b48eedeab5316bf9a502bb9eb7e3b1588bd61d0f565822fefa8f06e10b/wcmatch-10.2.1-py3-none-any.whl", hash = "sha256:2d775395b93f233af66690f62cb9d52b084ec159a31cc4084f4069d72f437acd", size = 39763, upload-time = "2026-07-02T17:21:47.134Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, +] + +[[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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "wrapt" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/a6/6375d56c44d590ef24acf0f8f5bf7ed768ff7a510b959306ec412611e90f/wrapt-2.4.1.tar.gz", hash = "sha256:fd6390aab9e8aa40c52eff3c180f098e8d9f5894b1fd4c4fd2c207067b33ed16", size = 164597, upload-time = "2026-09-10T23:12:16.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/1f/a2f3225c5ecf522684c1d051aea8ce8253f240e55be75826b787777afd6c/wrapt-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e86fbc2ac8a363ea04abf631fad82720e16b17a25020f32dbe9b24a2ed2b0e3", size = 98890, upload-time = "2026-09-10T23:10:07.876Z" }, + { url = "https://files.pythonhosted.org/packages/68/6c/eb45660fd4d92cce11ec923f55bb2e647a6c18d30e53734eb07a3c530e31/wrapt-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24389748f0b9d5b67e478fad4fc8b3f1108422ef80716e48eead6cebcebbff08", size = 98742, upload-time = "2026-09-10T23:10:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/17a89a580cb0082e61b8375074d5c9e5d38e4aa83ee19b6174ee472d17c2/wrapt-2.4.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30d11c289b013bf384ff1a1a6553f150d0b855901708a9bef667a5680f8247c9", size = 236301, upload-time = "2026-09-10T23:10:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/01/ca/4700eb008a34bf02de328806ddde15fc84c8d1e65d3dcafb92a935a50319/wrapt-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9356dbb59199a0e4709de35fa4a1ac1a88ef6da99711a397f5b009233faff326", size = 237805, upload-time = "2026-09-10T23:10:12.594Z" }, + { url = "https://files.pythonhosted.org/packages/75/5d/26c1740299b29e190d5f4b4a99eb001401042a9d9ab338e3f8e1ce140ecb/wrapt-2.4.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8342f332dada211f64b74609e332d727b13315e9a83177f7918bf68c59f815f2", size = 217037, upload-time = "2026-09-10T23:10:14.028Z" }, + { url = "https://files.pythonhosted.org/packages/3a/55/ec72991153a2ae8b40238bc44cec7c3ddf7706ef6e2d314b0c6f5c7febce/wrapt-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f86e328c482bc5383b4eda5094be0bed3617fc3076aa9225ff1a9eb6372de9b", size = 234659, upload-time = "2026-09-10T23:10:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/7cfa1e070dcda76ea56a3e252341cba1cd1e9412baf23cd7adfebf1114e2/wrapt-2.4.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4dc92697444ee380544fbb43c86612d8486529aadf917c22b5524141d4af074c", size = 214590, upload-time = "2026-09-10T23:10:16.744Z" }, + { url = "https://files.pythonhosted.org/packages/79/10/248841cb30107f6f32c53a02662e1c3e0c7c06bea0b8ebfaaee94885dcee/wrapt-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edd03758a7578526642508b8833d43496fdfba0f64e0025dfca153a7c1777735", size = 225103, upload-time = "2026-09-10T23:10:18.346Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/5b2bf7b35b008a39939a2908e85dba3b867596e535fc9f12ddf3ba1fcaf6/wrapt-2.4.1-cp312-cp312-win32.whl", hash = "sha256:5d83e412665aeb1e854eefbf1564d0d67872d9994b502a0bce96e6ff7f4970b7", size = 93441, upload-time = "2026-09-10T23:10:19.81Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2a/47be56772bfb07ef242d6e924049688e38384af2b2fc99b0f31180988448/wrapt-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:b4e7efdd476ac631a0181551fd9aace844765ea3ce2b5133b194fae4421e8ad0", size = 98808, upload-time = "2026-09-10T23:10:21.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/40e4c9735626afd1dc0eeb310310278361f192800503cda0f5b3d8d24db4/wrapt-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:38819761401baa2d11916d7265b82f23265f8fe5a31c431dd7c24a8863c65f88", size = 95240, upload-time = "2026-09-10T23:10:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1a/9b5aa3c2391aa6d00fffa085c2219e8fc91cd4d2b9b080d2b4e4b6b93f42/wrapt-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55f36bb1461f93beaf18d818e568b5de343dd8fade7456773d201a38ee723bd3", size = 98597, upload-time = "2026-09-10T23:10:23.723Z" }, + { url = "https://files.pythonhosted.org/packages/fe/07/dc98150c2f9ee5b5fcbd841765e178ea6cc6c43733ab8d1f5181a4fb9f3d/wrapt-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:53e15cd74bd6b84d7fa90b93dda7334d85f4641fae97632de8aa61d268dfd145", size = 98842, upload-time = "2026-09-10T23:10:25.109Z" }, + { url = "https://files.pythonhosted.org/packages/08/c2/0e772a570e8d75c1b3ec45930a3023492fa68223f8f5e3485af4844c10c3/wrapt-2.4.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be6cdd7121adc89a6f52e3c2f4e26a2d4dcdc1c0fde47e3156234db8939e4cdc", size = 234642, upload-time = "2026-09-10T23:10:26.644Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/4ce4d02dd95ff9ed972a2fc396d04fec636daa5a4ac18cc73a3dc20aa6c3/wrapt-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03b5598edd435373278731d0d53449ce7a9626bc48d5548e4a71124ee3e526a1", size = 235484, upload-time = "2026-09-10T23:10:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/436ef1df620ef8edd9ef057b4d55a0cd704435ff66852a0ef26cbaa02a58/wrapt-2.4.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdc997819a6df4c65bdb0a1c5601c98b479ee34cc2f94ffa98a767034ad6366d", size = 214371, upload-time = "2026-09-10T23:10:29.841Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2c/cc5b7503843399087db3106a7cf60d60d0900f69539e96148b9fff116291/wrapt-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3bfc6907ebed560d2d3f677c3b17bf6199679163b6c6e475035c9dca497d1697", size = 232347, upload-time = "2026-09-10T23:10:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/86/fb/17668b1ca572c44b458c09d76064d35f5f57d5ac7629248871604bef816c/wrapt-2.4.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0c3b217332cf0c4df085fec41c126bf7507d6c1ca0efb3ba8d2b3fd234d4e73", size = 212792, upload-time = "2026-09-10T23:10:32.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2b/0c5de10d7259e07c478c44bf2fbe179c6878727d09edf0632b97884801db/wrapt-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a23b89621cfeb3329b1a290596bf402e61d7d5a647a65ca8ea735e48771b71d4", size = 223585, upload-time = "2026-09-10T23:10:34.43Z" }, + { url = "https://files.pythonhosted.org/packages/aa/36/013dce1c687f8f1c87b1e78f403d04c80ae9b2ae77de4ddba16069a3e7d1/wrapt-2.4.1-cp313-cp313-win32.whl", hash = "sha256:bc67d4872af5ab2dc1b88904097b92ac00e7658fdf010dee36807807fe882ac4", size = 93425, upload-time = "2026-09-10T23:10:35.901Z" }, + { url = "https://files.pythonhosted.org/packages/b7/1d/d53bcf5910209a45191c8bc173ff0e00830416547d8038772dc444932ee0/wrapt-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:1fe758b9c2d49138231ec3efabd106fec665f86fec50b3d02d7edd75f08a69ca", size = 98569, upload-time = "2026-09-10T23:10:37.316Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/759d0c522918f9dfb620136622d8e1ce619570adda0de8fa2cfbb55cbb86/wrapt-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c974c36e8205255a3947dad9c2fe000431b57dd522946e56c192174a7f92a0f", size = 95272, upload-time = "2026-09-10T23:10:38.657Z" }, + { url = "https://files.pythonhosted.org/packages/08/05/ed5aa8991c5e9969e2ca17f0e44eb324a9757f44fa982bfc13844cfe9e1d/wrapt-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6c35541cc729964c65c2b9b1f9cf317811039abc2da13b4557f20f21cdc292b", size = 98876, upload-time = "2026-09-10T23:10:40.078Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5f/f8bf07c3b9a2ad01d28d5ca419e28819bb16937d129167d97c446c05875d/wrapt-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6b9df84f0a96763159cccb8e3b0ed83cc950c7c8bf82d6e45428372a805b3224", size = 99054, upload-time = "2026-09-10T23:10:41.484Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/518e5f3ebd18472652e5332bf37728cd6634ff81a0aa568969d05cc66099/wrapt-2.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78af62413095d0a57077606654ce85e273deda8e2bfa28fdb04257b962d94ef2", size = 237430, upload-time = "2026-09-10T23:10:43.007Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/503835d183c1ca99268e0b5a98af8f487d528ebabb124f4003cd96e73b1f/wrapt-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60702ebc914d0bb01aa48f5c1785ceaaaa505e0a841b444a69d6ceb5de4097e", size = 237988, upload-time = "2026-09-10T23:10:44.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9d/a11a3e1eb18ae1d9adef1c39f8e8e36fd5b86db33938bebf8e6bc5fd9b06/wrapt-2.4.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8c4b44e4be7680fc496816e824b6ed134d781c4da296e44b75399196e5c248f1", size = 218554, upload-time = "2026-09-10T23:10:45.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/61/61a6f983737ba81008561ab634373bba25b986761d3a0f0aded8352ae3e8/wrapt-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fb5b3f94258bcf71db902795f4a71151c7a74d9fe21fffa00752d2bd286866f8", size = 235476, upload-time = "2026-09-10T23:10:47.528Z" }, + { url = "https://files.pythonhosted.org/packages/42/68/acb7cba46e0f662eaae421487807d2fed28ba52df9a00a3f04db16675e38/wrapt-2.4.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f1556a96b20d5bfdc9cb5dcd0a3ec65cbbac8a4eebcd3cd34efdc91c9519f660", size = 216454, upload-time = "2026-09-10T23:10:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/8b/52/3a4dab8d4803df595de82f19775535d28c036746340c1b498fc8ecba39aa/wrapt-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:094a606d0bf1c4b847b3a743d22fc69cb164015b8c70cf6f20a534d30889ed47", size = 225346, upload-time = "2026-09-10T23:10:51.052Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/d72d051757fb7955021d8174014679f951fd5067187d2faed95520e1f8d6/wrapt-2.4.1-cp314-cp314-win32.whl", hash = "sha256:b0ee076be124406a7f97ca663c4a3ba32b6bcdd9102ca82497feaca79e9cb33c", size = 93870, upload-time = "2026-09-10T23:10:52.495Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/1a1bf4d8b60b9e7ac009969595438549ae6e33b2e3e174b78d26a833aad3/wrapt-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:d2d6f9abaa52de05090a2b4a4c1d0e858a268c4de69eec277506af9fbcccff91", size = 98910, upload-time = "2026-09-10T23:10:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/1d/59/b3169c3bcdcff70a6d02ed2b6366097b083d31391d616c407efe9a943a82/wrapt-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:3152b2e94d733a9bd70dbd1c95f148266f079a70e9ffca2a9c5dc421ade1b4e6", size = 96013, upload-time = "2026-09-10T23:10:55.607Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/d29a063c2ca6e779305cd444dd5acaac60833d73b2d94c7b1d0820c27e3e/wrapt-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:37ff91b390400463ecd4d080ea823314510b6843ac53e6e35cc09bba1805d466", size = 102069, upload-time = "2026-09-10T23:10:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/7435de516099b528e2232770b459d6706002736f41d8f092bbb11ea01575/wrapt-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e0a518cac3e789443af54fc77f23e66f17d80192c281b160b42058f11fabccc5", size = 102647, upload-time = "2026-09-10T23:10:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a7/cca7fe0f26aa11cbbf09b2e6f4f774db167feeca43ad22fa1772afdccbad/wrapt-2.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ecdeb8a1ec13397421e6f925412186bd87ab86d63517e6825b6d7bccf781f26", size = 275614, upload-time = "2026-09-10T23:11:00.464Z" }, + { url = "https://files.pythonhosted.org/packages/77/22/f3b5f4428ae679b1e2c0d4833519045d8983a9df0ad24df96b195d44dd1b/wrapt-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15c4ede3fde08723cabab0a892d4b75d35b5a6f0a51c84e6542ac0bdc0507aa", size = 287064, upload-time = "2026-09-10T23:11:02.111Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b8/24274ec01b6ab6d0672f4312bfa29b5f1ffd9d56f16c8da67d5683883956/wrapt-2.4.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e32c5951c36fed88b6c603dc0bab209a62dc3217bd8762413634725fe28f2f3c", size = 255440, upload-time = "2026-09-10T23:11:03.746Z" }, + { url = "https://files.pythonhosted.org/packages/8b/df/c63a36f0f03d70c7b92f60ec1ad3757088167914e1a262f5dcc7df233d51/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:898513db90d55a4ed3009312d41c12932b8163edbae3535280046d47aaff774f", size = 281681, upload-time = "2026-09-10T23:11:05.41Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c7/4930b6a369d9b3da59f5ada4ae9e659e2d4a68fe72c5d1ce15e5bead2d24/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:42d01574bd4bcafc3476e77a95c6c0dd6167101991401325fc5591e2db21a91d", size = 252409, upload-time = "2026-09-10T23:11:06.867Z" }, + { url = "https://files.pythonhosted.org/packages/68/d7/f6bc5703061598c4a2ad4f7a2efce696782be6e9d5afed714a59f5902ead/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68a403adbeb4dd2654d6d108e43e69f0f90e6d34cb7588e0e6589109f6985e67", size = 270559, upload-time = "2026-09-10T23:11:08.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/a3/952337a153e634f530079334c47dcc935b20f8ea22f364729fc2a8cf1821/wrapt-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:7cb3035b332bc9d21478600ba82c7c71e2d074ed9b4e76364297f54598792227", size = 96241, upload-time = "2026-09-10T23:11:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/48f0651547a125dd84e312b86268d4150c565a29f81ff9ca6fd81eba4be4/wrapt-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:707d2bef68deddd0fc74a81103d91b286a69ac5a9ff0f0a6dad66f8787e86697", size = 102655, upload-time = "2026-09-10T23:11:11.388Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/977441bed5e5afbffe7a789067ac2f5fd2e9f848884122a23db9ccae6a70/wrapt-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28cb1c2713b4377bf03ddcb3e76d8216e4bb334199066c6122be7eed2f72de8c", size = 98495, upload-time = "2026-09-10T23:11:12.952Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1c/03bcbc3dbf6ac11231f0434f2494aa7b80657b130be1a1a639192a3e44c9/wrapt-2.4.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:96a5023fa63ca2f095f7776c8bfaa1694547577f762646c375abafd8f8ae649b", size = 98878, upload-time = "2026-09-10T23:11:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/87/26/5ad9bd421b8cdce0b2227a61f09586423765da3c284f8bbf0f69fc149fdf/wrapt-2.4.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b21924949dedc3ac63725e09b9b0a130e771975f03432789344ed3422c46008", size = 99090, upload-time = "2026-09-10T23:11:16.101Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c7/fa768261966d587650004ac37bef541540014b1fe23fbf4919c497ebc98c/wrapt-2.4.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f725af353bb3319c528ee69dbff838599426974288b281a3258d5397b18cb63", size = 237803, upload-time = "2026-09-10T23:11:17.738Z" }, + { url = "https://files.pythonhosted.org/packages/0f/37/9c1c876bd88fef8d065b3e8bf125cad9291d70ed3695cedb41b36b2453ec/wrapt-2.4.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20327e162ef7953fae56b31a43ca457f94ed1fc7a206d01e3d5c8412d1b91572", size = 238338, upload-time = "2026-09-10T23:11:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/91/5b/47b2f2f08b90d9d3b4e1790ccae0d4e4de40e73f6614c9a52465a04f02a5/wrapt-2.4.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2cedb743dbdfb9b6d4f11acd8cb6329460264429777e81ea2e86b1b72ea503af", size = 220668, upload-time = "2026-09-10T23:11:21.422Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/da7844c7a3f7c01f3496e690e15ce9d01c0949278c64d4287ec472c90f72/wrapt-2.4.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5a54744b1193505f19016194979b773ee3754a199e9ad90db18f2e9a18fffa16", size = 235812, upload-time = "2026-09-10T23:11:23.211Z" }, + { url = "https://files.pythonhosted.org/packages/69/af/ac4aa9f795721a54a21bac329201f7df1b199b71ef064187a266d4d29836/wrapt-2.4.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b0d38d9cc23e9781e6584f303e5c5a9f0d45b85de92ad45678c4dde8d49d9535", size = 218195, upload-time = "2026-09-10T23:11:24.983Z" }, + { url = "https://files.pythonhosted.org/packages/78/aa/cff7670ec1cfa75b951171cfb41d45c7bdb94e928620a1599ec2b00cd2dd/wrapt-2.4.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a524ca32f0bcdde2b728d9f81f9527d4dd24b13f15d180f05d08cdc01d1cebe0", size = 225713, upload-time = "2026-09-10T23:11:26.576Z" }, + { url = "https://files.pythonhosted.org/packages/77/c6/64f138aef50b5ea1dfacca2a023b32ba3e41ef087bbd92e8997a95f4a0b8/wrapt-2.4.1-cp315-cp315-win32.whl", hash = "sha256:47c267617551e906de72f6e7265aa3bce84c63d44513d2d2e735e943422aa0a2", size = 93880, upload-time = "2026-09-10T23:11:28.253Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/afe7484a0f7232e87f2ebb65286c1025d43cbe1de4e4051b127b024a92f7/wrapt-2.4.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee437bd7fd050823ef731aad20e968ca8fe670b95e1841f5d201bfdbad4a4e96", size = 98919, upload-time = "2026-09-10T23:11:29.832Z" }, + { url = "https://files.pythonhosted.org/packages/83/21/6865f976c6a1082ca61e2792bc6a2d6a0ed03cd750a46f837753127191f8/wrapt-2.4.1-cp315-cp315-win_arm64.whl", hash = "sha256:ebf3b703752b53366fd02b7fbd8c447428e0349c9af3fc17c7a05076dbdd749a", size = 96021, upload-time = "2026-09-10T23:11:31.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/48/baf784cc9f1fe554f96daf15af06c7c466d76469d5540e357cf564755606/wrapt-2.4.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:0f5990f5db090f069fcd577cc61cc8d463db73cde132abba9033b0a264fc06d0", size = 102028, upload-time = "2026-09-10T23:11:33.102Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/fb809c95bf5512196aeda4c640aabf4e92f607340b8b2556555690a52b7a/wrapt-2.4.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef19a2590b195ac294deff8ee350a027a479afdd2ef2170ce3900af92406e110", size = 102640, upload-time = "2026-09-10T23:11:35.145Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/bd462010ccbbe298479f320bf2bb02996706003e9c20a15790b65a186f8b/wrapt-2.4.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:432f402f9b6014403cacf9fd18a6bf089c77964330eae951a155131ab0414f5d", size = 275999, upload-time = "2026-09-10T23:11:36.863Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/bd043b2bad2943336e090583ff6cea2f3e8776bde02f073ccf760b16eee1/wrapt-2.4.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb7c0f8bdd21e954bad89d554cfb7431c2f8179842853f199841ab90cbebe914", size = 287576, upload-time = "2026-09-10T23:11:38.79Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d9/9937bbd61ca4e7f58a7e8a38f5ff3562334f741de51b87f0e031df441254/wrapt-2.4.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e82a1669d63b79a2b53041bbb6ea096b5763cab3ca698ed7ac244b3acb7cd51", size = 256763, upload-time = "2026-09-10T23:11:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0f78cc7e7fc6313cdba32df06f4b29a45a4a2aed3499040d6c1628d3f339/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b19e71c914c435d2c5652caea386c9bf2807979e957183f5bb06d5ed5fa8b55e", size = 282244, upload-time = "2026-09-10T23:11:42.483Z" }, + { url = "https://files.pythonhosted.org/packages/be/0d/30317d738b9898a7fbedc193657f151a468b0fa7235f4abd58a646a99cd8/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a1e4870d3368c6c918f38e308d5dcea970ea5b908ce889c21412f3a517ebf0c3", size = 254153, upload-time = "2026-09-10T23:11:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/12/53/fe1f251a89f471ca7c5e17f2edbd8efd48fea6f1c08430dcf60986ea4183/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2286e8e4a937966706463d1bcea30dfefee529e6e73e097a18e9e066a07ae6c2", size = 271234, upload-time = "2026-09-10T23:11:46.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7c/d63758cd7fa0d18c415a87312a07cdd9f0102eb824aaab5c7450b3ec08e5/wrapt-2.4.1-cp315-cp315t-win32.whl", hash = "sha256:80afa3b7010e82899044a2a189c468a0cd8c89980398a59ba3b96b451a6dc5e9", size = 96236, upload-time = "2026-09-10T23:11:47.942Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2e/1785e6d2696db08b07c053b1f4ccb2b5e8f9d12e9bf2c7497693be6b4504/wrapt-2.4.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3593b43fabab6b59fe77e38f7e40974aae120c30cea3fd1ceaea6621ee96be00", size = 102662, upload-time = "2026-09-10T23:11:49.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/fd/3f4ac5c4754948355e050f952ae7a64cdca94891e49fe9927c3f90a73334/wrapt-2.4.1-cp315-cp315t-win_arm64.whl", hash = "sha256:ac1939ccf3e1c33f463706fbf52db5075f5eefb6042bcc1f9cf48c2c20ef478c", size = 98500, upload-time = "2026-09-10T23:11:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/edcfc8d9a30375791b775715f8501364588f65b494ec4f6930568a19e765/wrapt-2.4.1-py3-none-any.whl", hash = "sha256:1e84ec5d89a0a07a0ef6bcd343f5c8ecdc95601d71de3058cdc63274e86c193c", size = 75317, upload-time = "2026-09-10T23:12:14.82Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +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" } +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" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/examples/harbor/nemo_shell_profile/README.md b/examples/harbor/nemo_shell_profile/README.md new file mode 100644 index 0000000000..6f613f899b --- /dev/null +++ b/examples/harbor/nemo_shell_profile/README.md @@ -0,0 +1,17 @@ +# NeMo shell qualification profile + +This opt-in profile runs NVIDIA NeMo Agent Toolkit 1.9.0's native ReAct agent +with a shell tool inside the Harbor task sandbox. It qualifies that concrete +workflow; Harbor's default single-call chat workflow remains unchanged. + +Harbor installs this directory through `workflow_package`. Select +`openenv.harbor.nemo_profile:NemoShellProfile`, `llm_type=openai`, and +`version=1.9.0`, with `OPENAI_BASE_URL` and `OPENAI_API_KEY` pointing to the +OpenEnv capture session. The profile reuses Harbor's provider YAML generation. + +The shell uses the sandbox filesystem and runs bash with a 60-second timeout. +Do not run this workflow on a host containing unrelated workloads. Tool failures +retain their exit code; a timeout terminates the command's process group. + +The live qualification driver selects this explicitly with +`--nemo-profile shell-1.9.0` and records that selection with the evidence. diff --git a/examples/harbor/nemo_shell_profile/pyproject.toml b/examples/harbor/nemo_shell_profile/pyproject.toml new file mode 100644 index 0000000000..fc9c6cd0e8 --- /dev/null +++ b/examples/harbor/nemo_shell_profile/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-nat-shell-profile" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["nvidia-nat[langchain]==1.9.0"] + +[project.entry-points."nat.components"] +openenv_nat_shell = "openenv_nat_shell.register" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/__init__.py b/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/__init__.py new file mode 100644 index 0000000000..95c57cf84f --- /dev/null +++ b/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/__init__.py @@ -0,0 +1 @@ +"""Explicit shell-tool profile for NeMo running inside a Harbor task sandbox.""" diff --git a/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/register.py b/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/register.py new file mode 100644 index 0000000000..82f5bfd333 --- /dev/null +++ b/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/register.py @@ -0,0 +1,20 @@ +"""Register a sandbox shell through NeMo's public function API.""" + +from nat.plugin_api import Builder, FunctionBaseConfig, FunctionInfo, register_function +from .shell import execute + + +class ShellConfig(FunctionBaseConfig, name="openenv_sandbox_shell"): + timeout: float = 60.0 + + +@register_function(config_type=ShellConfig) +async def sandbox_shell(config: ShellConfig, builder: Builder): + async def shell(command: str) -> str: + """Run a bash command in the task sandbox; returns exit code, stdout and stderr. + + Use this to inspect task files, run Python analysis and write the answer file. + """ + return await execute(command, timeout=config.timeout) + + yield FunctionInfo.from_fn(shell, description=shell.__doc__) diff --git a/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/shell.py b/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/shell.py new file mode 100644 index 0000000000..ce6821f665 --- /dev/null +++ b/examples/harbor/nemo_shell_profile/src/openenv_nat_shell/shell.py @@ -0,0 +1,34 @@ +"""Execute only inside the sandbox where Harbor installs this workflow package.""" + +import asyncio +import json +import os +import signal + + +async def execute(command: str, timeout: float = 60.0, cwd: str = "/workdir") -> str: + proc = await asyncio.create_subprocess_exec( + "bash", + "-lc", + command, + cwd=cwd, + start_new_session=True, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except (TimeoutError, asyncio.CancelledError): + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + await proc.wait() + raise + return json.dumps( + { + "exit_code": proc.returncode, + "stdout": stdout.decode(errors="replace")[:24000], + "stderr": stderr.decode(errors="replace")[:8000], + } + ) diff --git a/pyproject.toml b/pyproject.toml index ab758ee896..0aef4a00c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,17 @@ modal = [ inspect = [ "inspect-ai>=0.3.0", ] +harbor = [ + # Every sandbox backend. Bare `harbor` gives one that lists all 23 backends and can + # instantiate none of them: each raises MissingExtraError from its constructor, which + # surfaces as a failed rollout rather than as a missing dependency. + # + # Not `harbor[cloud]`, which is unsatisfiable: it pulls `langsmith[sandbox]` + # (websockets>=15) and `tensorlake` (websockets>=13,<14) together. + # + # Harbor requires Python >= 3.12, so this extra is excluded from the 3.11 CI leg. + "harbor[e2b,modal,daytona,gke,ec2,runloop,novita,blaxel,beam,islo,opensandbox,cwsandbox,use-computer,cua]>=0.22.0; python_version >= '3.12'", +] [project.scripts] openenv = "openenv.cli.__main__:main" diff --git a/rfcs/012-harbor-capture-providers.md b/rfcs/012-harbor-capture-providers.md new file mode 100644 index 0000000000..f9655d3e9e --- /dev/null +++ b/rfcs/012-harbor-capture-providers.md @@ -0,0 +1,68 @@ +# RFC: Harbor capture purpose, provider fidelity and live-session ownership + +**Status**: In Review +**Created**: 2026-09-16 +**Authors**: @adithya-s-k +**RFC ID**: 012 + +## Summary + +This addendum defines the evaluation and training APIs implemented in Harbor integration PR #1036. It extends RFC 005 and the capture design in [RFC 006 / PR #941](https://github.com/huggingface/OpenEnv/pull/941). Its change to RFC 006 decision D2 is explicit: hosted providers are supported for **evaluation**, while training export still requires exact engine tokens and processed log probabilities. Endpoint reachability is not training certification. + +## Motivation + +One long-lived environment serves multiple harnesses, sandboxes and inference endpoints. A hosted evaluation, a trainer-controlled rollout and a playground user can overlap. Capture purpose, upstream credentials, sampling policy and live traces must remain scoped to their own session. Prompt rewrites must not silently replace sampled tokens with re-tokenized text or fabricated log probabilities. + +## Design + +### Purpose and provider + +`purpose` is `eval`, `train`, or legacy `auto`. An explicit evaluation remains evaluation even if its endpoint exposes tokens; training export rejects it. Explicit training fails when the endpoint cannot supply engine token capture. `auto` retains capability-based selection for existing clients. + +The upstream descriptor adds `provider` (`openai`, `anthropic`, `hf`, `vllm`). Client caching includes the provider, endpoint, requested model, authentication header and credential identity. A session's credentials never become another session's defaults. Native Anthropic requests preserve signed blocks and supported metadata; cross-protocol conversion rejects semantics it cannot preserve. + +`sampling` selects the training distribution and requires a positive finite temperature, full-vocabulary sampling and neutral unsupported penalties. Capture stores requested and submitted policies separately. A changed effective training policy is fatal. `eval_sampling` is an optional, validated override available only for explicit evaluation. Invalid combinations fail before sandbox allocation. + +A server-side session may set a positive integer `metadata["max_output_tokens"]` before forwarding. Capture caps it at the server limit and validates it before inference. This lets one shared service reserve different output budgets for training and evaluation without mutating global configuration. The hook is not a client credential or a way to raise the server ceiling. + +### Exact training contract + +`openenv.harbor.contract.to_trace_entries` is the authoritative Harbor reader. The environment wrapper imports it, and the UI download uses `export_training_contract`, schema version 1. Each eligible entry contains engine `prompt_token_ids`, sampled `completion_token_ids`, aligned `per_token_logps`, and a `loss_mask` spanning prompt plus completion. Prompt positions are zero; partial completion masks survive export. Token IDs are nonnegative integers and supervised log probabilities are finite and nonpositive. Missing sampled probabilities are never filled with zeros. + +Evaluation and fatal capture findings reject training export. Auxiliary calls, discarded retries and ineligible turns cannot gain supervision in the downloadable audit. A zero verifier reward remains a valid grade; missing or failed grading remains distinguishable from zero. The trainer owns advantages, weighting, batching, staleness and weight synchronization. More captured rows do not imply fair rollout weighting or a memory-safe batch. + +### Streaming and session ownership + +Upstream responses are buffered to preserve complete capture, then replayed as protocol-compatible SSE. Keepalive comments maintain delayed connections without adding model output or captured tokens. Fast errors retain their HTTP status. After headers are sent, late errors use an error event in the caller's protocol. Disconnects cancel pending inference capture. + +`run_rollout(..., on_session_created=callback)` reports only that rollout's session ID. A live UI follows this callback, never the difference between shared registry listings. The callback is local process state, not an HTTP or MCP request field. A callback failure returns a failed rollout and releases its session. Session creation precedes sandbox setup, so the UI does not claim a ready sandbox solely because a session exists. + +### Qualification and profiles + +A versioned external evidence report supplies stable, experimental and unstable classifications for the recorded provider/model/adapter combination. No report means unqualified; the UI requires an explicit experimental opt-in. Recorded evidence does not qualify a newly entered endpoint or pin installed agent versions. `harness_profile` selects an explicitly supported ACP or NeMo workflow on a local seam copy, without mutating global defaults. Unknown profiles fail. + +## Examples + +```python +# Server-side rollout with exact training capture. +result = await run_rollout( + task_dir=task_dir, harness="opencode", sandbox="daytona", + registry=capture.registry, intercept_url=public_capture_url, + model=model, trials_dir=trials_dir, upstream=upstream, + capture_level="tokens", purpose="train", + sampling={"temperature": 0.8, "top_p": 1.0, "top_k": -1}, + on_session_created=live_session_queue.put_nowait, +) + +# Consumer and UI share the same validator and mask semantics. +entries = to_trace_entries(result) +contract = export_training_contract(result) +``` + +For a hosted evaluation, select its provider and use `purpose="eval"`, with no training sampling policy. Consume the verifier reward and captured messages; do not request a training contract. + +## Validation and trade-offs + +Deterministic tests cover invalid policies, exact masks/logprobs, provider conversion, signed content, SSE keepalives and disconnects, concurrent live-view ownership, cleanup and qualification filtering. Live provider smoke evidence is bounded by the tested tasks and versions. Optimizer replay is recorded separately from weight synchronization and reward-based learning. Buffering favors capture integrity over first-token latency; lossless prompt forks favor retention over row count. + +This proposal preserves reset/step/state and the server/client boundary. It adds no trainer or tokenizer dependency to capture. Consolidating older environment-specific interceptors and implementing rollout-normalized trainer weighting remain separate work. diff --git a/rfcs/README.md b/rfcs/README.md index 989e075ccb..c60eec4a85 100644 --- a/rfcs/README.md +++ b/rfcs/README.md @@ -93,6 +93,8 @@ Each RFC should include the following sections: ### Agentic Harnesses - [005-agentic-harnesses.md](./005-agentic-harnesses.md) - Agentic Harness Integration (OpenClaw, Claude Code, etc.) +- [012-harbor-capture-providers.md](./012-harbor-capture-providers.md) - Harbor evaluation/training purpose, provider fidelity, exact export and live-session ownership + ### Validation - [008-environment-auto-validation.md](./008-environment-auto-validation.md) - Environment Auto-Validation: local `openenv validate` levels 1-3 + the contracts for operator-run hubs diff --git a/scripts/logprob_parity.py b/scripts/logprob_parity.py new file mode 100644 index 0000000000..d34ecf11cc --- /dev/null +++ b/scripts/logprob_parity.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Are the captured logprobs the ones the model actually assigned? + +Every other check in this project verifies the SHAPE of a rollout: ids present, lengths aligned, +prefix chains exact, ATIF agreeing. None of them can see whether `per_token_logps` are the right +NUMBERS — which is the entire claim the training contract makes, and exactly where the raw-vs- +processed logprobs bug lived: aligned, negative, correctly counted, and wrong. + +This scores the captured sequence again with the SAME serving engine and compares: + + captured the logprob the engine reported while SAMPLING each token + recomputed the logprob the engine reports for that same token when asked to SCORE it, + via /v1/completions with prompt=[prompt_ids + completion_ids], max_tokens=0, + prompt_logprobs + +Equivalently, this asserts **the GRPO importance ratio is 1.0** on freshly captured on-policy data: +`exp(recomputed - captured)` must be 1 if the policy has not moved. Any drift — raw instead of +processed logprobs, a re-tokenised prompt, an off-by-one in the loss mask, the wrong tokenizer — +appears here as a ratio away from 1, which is the number a trainer actually multiplies by. + +The engine does the scoring rather than a locally loaded copy of the model. That keeps the property +the whole design rests on: nothing is tokenised or re-implemented on this side. + +Temperature is pinned to 1.0 for both the sampling and the scoring pass. At T=1 the processed +logprob equals the raw one (dividing by 1 is a no-op), so the two passes are directly comparable; +comparing a turn sampled at some other temperature would require knowing that temperature, which the +capture deliberately does not store. + +WHAT THE RESIDUAL IS, AND WHY THERE IS A NEGATIVE CONTROL +--------------------------------------------------------- +The captured logprobs are copied from the engine verbatim, so "captured == what the engine said while +sampling" holds by construction. What can genuinely go wrong is ALIGNMENT: the graph stitching the +wrong prompt in front of a completion, or an off-by-one between a logprob and its token. So the +number that matters is not the absolute residual but its size RELATIVE to a deliberately broken +alignment, which this measures in the same run rather than comparing against a magic threshold. + +Measured on Qwen3.5-4B served by vLLM 0.25.1: + + aligned, as captured 0.03 - 0.14 nats ratio 0.97 - 1.15 + prompt truncated by one token 0.35 nats ratio 0.70 + completion rotated by one 5.89 nats ratio 360 + +The honest residual is not zero, and it is not this layer's doing. Scoring the identical sequence +three times gives bitwise-identical logprobs (max diff 0.000000), so the engine is deterministic with +itself; but its SAMPLING path (incremental decode, KV cache, prefix-cache reuse) and its SCORING path +(one prefill over the whole sequence) do not agree exactly in bf16 — measured directly, with capture +out of the picture, at up to 0.026 nats for a first sampled token, and wider on later turns that reuse +more cached prefix. + +The consequence is worth stating plainly because it affects training rather than testing: even with +perfect capture, the GRPO importance ratio at step 0 is NOT exactly 1.0. It was within 1.03 typically +and 1.15 on a long cache-reusing turn here. Ratio-clipping thresholds should be set knowing that. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.request +from pathlib import Path + +# Resolved from this file's own location, so the script runs from any checkout. It used to carry an +# absolute path from the machine it was written on, which meant it could not run anywhere else — and +# a check nobody can run is not a check. Prepended only when `openenv` is not already importable, so +# an installed package still wins over the working tree. +try: # noqa: SIM105 + import openenv # noqa: F401 +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from openenv.core.harness.capture.contract import to_turn_records # noqa: E402 +from openenv.core.harness.capture.export import export_session # noqa: E402 +from openenv.core.harness.capture.graph import RolloutGraph, TurnNode # noqa: E402 +from openenv.core.harness.capture.upstream import ( # noqa: E402 + normalise_engine_base, + normalize_response, +) + +TOOLS = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + } +] + + +def post(url: str, body: dict, timeout: float = 300.0) -> dict: + req = urllib.request.Request( + url, + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read()) + + +def capture_conversation( + base: str, model: str, n_turns: int, top_p: float | None = None +) -> RolloutGraph: + """Drive a multi-turn tool conversation and build the graph exactly as the proxy would. + + The conversation grows the way a real agent's does — assistant reply, tool result, next call — + so the turns form a token-prefix chain rather than n independent one-shot calls. A single-turn + test would pass even with the stitching completely broken. + """ + graph = RolloutGraph() + messages: list[dict] = [ + {"role": "system", "content": "You are a shell agent. Use the bash tool."}, + { + "role": "user", + "content": "List /tmp, then report how many entries there are.", + }, + ] + + for index in range(n_turns): + body = { + "model": model, + "messages": messages, + "tools": TOOLS, + "max_tokens": 96, + "temperature": 1.0, + "logprobs": True, + "top_logprobs": 0, + "return_token_ids": True, + } + # Sampling with top_p<1 is what a harness does by default, and under + # `--logprobs-mode processed_logprobs` it changes what a captured logprob MEANS: vLLM masks + # the truncated tail to -inf and takes the log-softmax afterwards + # (`v1/sample/ops/topk_topp_sampler.py`, `apply_top_k_top_p` then `compute_logprobs`), so the + # captured value is renormalised over the surviving set. `rescore` below scores over the full + # vocabulary, which is what a trainer does, so the residual between them is the bias itself. + if top_p is not None: + body["top_p"] = top_p + payload = normalize_response(post(f"{base}/v1/chat/completions", body)) + choice = (payload.get("choices") or [{}])[0] + entries = ((choice.get("logprobs") or {}).get("content")) or [] + graph.add_turn( + TurnNode( + node_id=f"n{index}", + prompt_ids=list(payload.get("prompt_token_ids") or []), + sampled_ids=list(choice.get("token_ids") or []), + sampled_logprobs=[e.get("logprob") for e in entries] or None, + model=model, + finish_reason=choice.get("finish_reason"), + request_messages=list(messages), + request_tools=TOOLS, + response_message=choice.get("message") or {}, + n_tools=len(TOOLS), + ) + ) + + reply = choice.get("message") or {} + messages = [*messages, reply] + calls = reply.get("tool_calls") or [] + if calls: + messages.append( + { + "role": "tool", + "tool_call_id": calls[0].get("id", "call_0"), + "content": "a.txt\nb.txt\nc.txt", + } + ) + else: + messages.append({"role": "user", "content": "Now double-check that count."}) + return graph + + +def rescore( + base: str, model: str, prompt_ids: list[int], completion_ids: list[int] +) -> list[float]: + """The engine's logprob for each completion token, given the prompt that preceded it. + + Two engines, two spellings of "score this sequence", so both are tried: + + vLLM `max_tokens: 0` + `prompt_logprobs`, returning one dict per prompt position keyed by + token id (`{"11": {"logprob": ...}}`, or `token_id:11` when the server runs with + --return-tokens-as-token-ids). + SGLang rejects `max_tokens: 0` outright ("max_tokens must be positive") and 500s on + `echo` + `logprobs: 0` with a bare KeyError for `input_top_logprobs`. What works is + `max_tokens: 1` + `echo` + `logprobs: 1`, whose `token_logprobs` array is positional + rather than keyed. Verified to agree with SGLang's native /generate `input_token_logprobs` + to the last digit, so it is the same number by a different route. + + Position i of either form is the logprob of token i given tokens < i, so the completion occupies + the last len(completion_ids) positions. + """ + sequence = prompt_ids + completion_ids + try: + payload = post( + f"{base}/v1/completions", + { + "model": model, + "prompt": sequence, + "max_tokens": 0, + "temperature": 1.0, + "prompt_logprobs": 0, + "echo": True, + }, + ) + entries = (payload.get("choices") or [{}])[0].get("prompt_logprobs") or [] + if len(entries) == len(sequence): + out: list[float] = [] + for offset, token_id in enumerate(completion_ids): + slot = entries[len(prompt_ids) + offset] or {} + info = slot.get(str(token_id)) or slot.get(f"token_id:{token_id}") + if info is None: + raise SystemExit( + f"position {offset}: the engine scored {list(slot)[:4]} but not the token " + f"that was actually sampled (id {token_id})" + ) + out.append(float(info["logprob"])) + return out + except urllib.error.HTTPError: + pass # not a vLLM-shaped scoring route; fall through + + payload = post( + f"{base}/v1/completions", + { + "model": model, + "prompt": sequence, + "max_tokens": 1, + "temperature": 1.0, + "logprobs": 1, + "echo": True, + }, + ) + values = ((payload.get("choices") or [{}])[0].get("logprobs") or {}).get( + "token_logprobs" + ) or [] + if len(values) < len(sequence): + raise SystemExit( + f"scored {len(values)} positions for a {len(sequence)}-token sequence; cannot align" + ) + return [ + float(values[len(prompt_ids) + offset]) for offset in range(len(completion_ids)) + ] + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--llm-url", required=True) + ap.add_argument("--model", required=True) + ap.add_argument("--turns", type=int, default=3) + ap.add_argument( + "--top-p", + type=float, + default=None, + help="sample with this top_p instead of the engine default. Use it to measure the " + "truncation bias directly: a processed logprob is taken after top_p masks the tail, so " + "the captured value is renormalised over the kept set while a trainer's recompute is not. " + "Expect a systematic residual of about -log(kept_mass) at p<1, and none at p=1.0.", + ) + ap.add_argument( + "--margin", + type=float, + default=3.0, + help="how many times smaller the honest residual must be than a one-token misalignment. " + "Scale-free on purpose: the absolute residual depends on the model, dtype and how much " + "prefix cache the turn reused, so a fixed nat threshold would need retuning per engine.", + ) + args = ap.parse_args() + base = normalise_engine_base(args.llm_url) + + knob = "engine default" if args.top_p is None else f"top_p={args.top_p}" + print( + f"capturing a {args.turns}-turn tool conversation at temperature 1.0, {knob} ..." + ) + graph = capture_conversation(base, args.model, args.turns, top_p=args.top_p) + + class Session: + session_id = "parity" + metadata: dict = {} + findings: list[str] = [] + + session = Session() + session.graph = graph + document = export_session(session, capture_level="tokens") + if document["rollout_type"] != "train": + raise SystemExit(f"endpoint is not trainable: {document['capture_level']}") + + records = to_turn_records(graph, document) + print( + f"captured {len(records)} turns, {sum(len(c) for _, c, _ in records)} sampled tokens\n" + ) + + signed: list[float] = [] + + def compare(prompt_ids, completion_ids, captured): + recomputed = rescore(base, args.model, prompt_ids, completion_ids) + pairs = [(a, b) for a, b in zip(captured, recomputed) if b is not None] + diffs = [abs(a - b) for a, b in pairs] + # exp(new - old) is literally what GRPO multiplies by. + ratios = [pow(2.718281828459045, b - a) for a, b in pairs] + # Kept separately because a truncation bias and a misalignment look different: truncation is + # systematic and one-directional (captured always too high, so `captured - recomputed > 0`), + # while a misalignment is large and randomly signed. The max would report both; only the mean + # of the signed residual distinguishes them. + signed.extend(a - b for a, b in pairs) + return max(diffs), max(ratios, key=lambda r: abs(r - 1.0)), len(pairs) + + worst = 0.0 + worst_ratio = 1.0 + total = 0 + for turn, (prompt_ids, completion_ids, captured) in enumerate(records): + if not completion_ids: + continue + d, r, n = compare(prompt_ids, completion_ids, captured) + total += n + worst = max(worst, d) + worst_ratio = ( + max(worst_ratio, r) if abs(r - 1) > abs(worst_ratio - 1) else worst_ratio + ) + print( + f" turn {turn}: {len(completion_ids):>3} tokens prompt={len(prompt_ids):>5} " + f"max|diff|={d:.6f} worst ratio={r:.6f}" + ) + + aligned_signed = list(signed) # before the negative controls pollute it + + # Negative control on the longest turn: the same comparison against a knowingly wrong alignment. + # Without this the residual above is uninterpretable — it could mean "faithful" or "quietly off". + longest = max(records, key=lambda rec: len(rec[1])) + p_ids, c_ids, cap = longest + truncated, _, _ = compare(p_ids[:-1], c_ids, cap) + rotated, rot_ratio, _ = compare(p_ids, c_ids[1:] + c_ids[:1], cap) + + print() + print(f"tokens compared {total}") + if aligned_signed: + mean = sum(aligned_signed) / len(aligned_signed) + print( + f"mean signed residual {mean:+.6f} nats -> mean ratio " + f"{pow(2.718281828459045, -mean):.4f} ({knob})" + ) + print( + f"aligned, as captured {worst:.6f} nats worst ratio {worst_ratio:.4f}" + ) + print(f"prompt truncated by 1 token {truncated:.6f} nats") + print( + f"completion rotated by 1 {rotated:.6f} nats worst ratio {rot_ratio:.2f}" + ) + + broken = min(truncated, rotated) + if worst <= 0 or broken < args.margin * worst: + print( + f"\nFAIL: a one-token misalignment is only {broken / max(worst, 1e-9):.1f}x the residual " + f"(need {args.margin}x). Either the capture is misaligned, or this check cannot tell." + ) + raise SystemExit(1) + print( + f"\nPASS: misalignment shows up {broken / worst:.0f}x larger than the honest residual, so the" + ) + print( + " prompt/completion/logprob alignment in the captured contract is correct." + ) + print( + " The residual itself is the engine's sampling-vs-scoring gap, not this layer's." + ) + + +if __name__ == "__main__": + main() diff --git a/src/openenv/cli/__main__.py b/src/openenv/cli/__main__.py index 385b4e6e20..2d43919530 100644 --- a/src/openenv/cli/__main__.py +++ b/src/openenv/cli/__main__.py @@ -15,6 +15,7 @@ catalog, collect, fork, + harbor, import_env, init, push, @@ -38,6 +39,11 @@ app.command(name="build", help="Build Docker images for OpenEnv environments")( build.build ) +app.add_typer( + harbor.app, + name="harbor", + help="Run Harbor tasks with token-level capture (requires: pip install openenv[harbor])", +) app.command(name="validate", help="Validate a package against the OpenEnv quality bar")( validate.validate ) diff --git a/src/openenv/cli/commands/harbor.py b/src/openenv/cli/commands/harbor.py new file mode 100644 index 0000000000..b9e5864140 --- /dev/null +++ b/src/openenv/cli/commands/harbor.py @@ -0,0 +1,715 @@ +"""`openenv harbor` — run Harbor tasks with token-level capture. + +Four commands, in the order you would use them: + + openenv harbor info what can this machine run right now? + openenv harbor rollout --task-index 0 one rollout, end to end, no server + openenv harbor serve the env server, for trainers and clients + openenv harbor push the same server, deployed to a Space + +`info` and `rollout` exist so the whole path can be exercised without standing up a server, which +makes the failure surface much smaller when something is wrong: if `rollout` works and `serve` does +not, the problem is the serving layer, not Harbor, the sandbox, the agent or capture. + +Examples: + +```bash +# what is usable, given the credentials on this machine +openenv harbor info --llm-url $LLM --dataset AdithyaSK/data_agent_rl_environment_eval + +# one rollout on E2B with opencode +openenv harbor rollout \\ + --llm-url $LLM \\ + --dataset AdithyaSK/data_agent_rl_environment_eval \\ + --task-index 0 --harness opencode --sandbox e2b + +# the same task on Modal with codex — harness and sandbox are per-rollout +openenv harbor rollout --llm-url $LLM --dataset $DS \\ + --task-index 0 --harness codex --sandbox modal +``` +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import shutil +import tempfile +from pathlib import Path +from typing import Annotated, Any, Optional + +import typer + +app = typer.Typer( + name="harbor", + help="Run Harbor tasks with token-level capture", + no_args_is_help=True, +) + +_DATASET_HELP = ( + "Dataset spec: HF repo id, local dir, or Harbor `name@version`. Repeatable." +) +_LLM_HELP = "OpenAI-spec inference endpoint (vLLM). Required: there is no default, because a\nwrong or stale endpoint produces rollouts that look fine and carry no token ids." +_LLM_HELP_OPTIONAL = ( + "OpenAI-spec inference endpoint. Optional here: without it, `info` " + "still reports sandboxes, datasets and harnesses." +) +_KEY_HELP = ( + "Credential for the inference endpoint, for a hosted provider (OpenAI, Anthropic, HF " + "Inference Providers). Defaults to $OPENENV_LLM_API_KEY. This is NOT the key the agent " + "gets: that one is a capture session id, minted per rollout, and this value never leaves " + "the server process." +) +_AUTH_HEADER_HELP = ( + "Header to send --api-key under. `Authorization` gets a `Bearer ` prefix; anything else " + "(e.g. x-api-key) gets the raw key." +) + + +def _split(values: Optional[list[str]]) -> list[str]: + """Accept both `--dataset a --dataset b` and `--dataset a,b`.""" + out: list[str] = [] + for value in values or []: + out.extend(v.strip() for v in value.split(",") if v.strip()) + return out + + +@app.command("info") +def info( + llm_url: Annotated[str, typer.Option("--llm-url", help=_LLM_HELP_OPTIONAL)] = "", + model: Annotated[ + str, + typer.Option( + "--model", + help="Served model id. Auto-detected if the engine serves exactly one.", + ), + ] = "", + dataset: Annotated[ + Optional[list[str]], typer.Option("--dataset", help=_DATASET_HELP) + ] = None, + api_key: Annotated[str, typer.Option("--api-key", help=_KEY_HELP)] = "", + auth_header: Annotated[ + str, typer.Option("--auth-header", help=_AUTH_HEADER_HELP) + ] = "Authorization", + env_file: Annotated[ + str, typer.Option("--env-file", help="dotenv file with provider credentials.") + ] = "", + verbose: Annotated[ + bool, + typer.Option("--verbose", help="List every harness, not only validated ones."), + ] = False, + json_output: Annotated[ + bool, typer.Option("--json", help="Emit machine-readable JSON.") + ] = False, +) -> None: + """Report engine, sandboxes, datasets and harnesses available here.""" + from openenv.harbor.startup import prepare + + caps = prepare( + llm_url=llm_url or None, + model=model or None, + datasets=_split(dataset) or None, + env_file=env_file or None, + require_llm=False, + quiet=True, + api_key=api_key or None, + auth_header=auth_header, + ) + print( + json.dumps(caps.to_dict(), indent=2) + if json_output + else caps.render(verbose=verbose) + ) + + +@app.command("rollout") +def rollout( + llm_url: Annotated[str, typer.Option("--llm-url", help=_LLM_HELP)], + model: Annotated[str, typer.Option("--model", help="Served model id.")] = "", + dataset: Annotated[ + Optional[list[str]], typer.Option("--dataset", help=_DATASET_HELP) + ] = None, + task_index: Annotated[ + int, typer.Option("--task-index", help="Index into the split.") + ] = 0, + harness: Annotated[ + str, typer.Option("--harness", help="Seam name, or `module:Class`.") + ] = "opencode", + sandbox: Annotated[ + str, + typer.Option( + "--sandbox", help="Harbor environment type, e.g. e2b | modal | docker." + ), + ] = "e2b", + n: Annotated[ + int, typer.Option("-n", "--n-tasks", help="Run this many consecutive tasks.") + ] = 1, + port: Annotated[ + int, typer.Option("--port", help="Local port for the capture proxy.") + ] = 8100, + expose: Annotated[ + str, + typer.Option( + "--expose", + help="How the sandbox reaches the capture proxy: gradio | cloudflare | direct.", + ), + ] = "gradio", + trials_dir: Annotated[ + str, typer.Option("--trials-dir", help="Where Harbor writes trial artifacts.") + ] = "", + reward_key: Annotated[ + str, + typer.Option("--reward-key", help="Which reward key is the training signal."), + ] = "", + keep_sandbox: Annotated[ + bool, + typer.Option("--keep-sandbox", help="Leave sandboxes alive for debugging."), + ] = False, + force_build: Annotated[ + bool, + typer.Option( + "--force-build", + help="Rebuild the sandbox image, bypassing the content-hash cache. Needed when a task pins deps loosely and its cached image has drifted.", + ), + ] = False, + api_key: Annotated[str, typer.Option("--api-key", help=_KEY_HELP)] = "", + auth_header: Annotated[ + str, typer.Option("--auth-header", help=_AUTH_HEADER_HELP) + ] = "Authorization", + env_file: Annotated[ + str, typer.Option("--env-file", help="dotenv file with provider credentials.") + ] = "", + out: Annotated[str, typer.Option("--out", help="Write the result JSON here.")] = "", +) -> None: + """Run one or more rollouts without starting a server.""" + from openenv.harbor.runner import run_batch + + datasets = _split(dataset) + if not datasets: + raise typer.BadParameter("--dataset is required") + + results = asyncio.run( + run_batch( + llm_url=llm_url, + model=model or None, + dataset=datasets[0], + task_indices=list(range(task_index, task_index + max(1, n))), + harness=harness, + sandbox=sandbox, + port=port, + expose=expose, + trials_dir=Path(trials_dir) if trials_dir else None, + reward_key=reward_key, + keep_sandbox=keep_sandbox, + force_build=force_build, + env_file=env_file or None, + api_key=api_key or None, + auth_header=auth_header, + ) + ) + + if out: + Path(out).write_text(json.dumps([r.model_dump() for r in results], indent=2)) + print(f"\nwrote {out}") + raise typer.Exit(0 if all(r.ok for r in results) else 1) + + +@app.command("serve") +def serve( + llm_url: Annotated[str, typer.Option("--llm-url", help=_LLM_HELP)] = "", + model: Annotated[str, typer.Option("--model", help="Served model id.")] = "", + dataset: Annotated[ + Optional[list[str]], typer.Option("--dataset", help=_DATASET_HELP) + ] = None, + max_output_tokens: Annotated[ + int, + typer.Option( + "--max-output-tokens", + help=( + "Cap what an AGENT may request per turn. The default of 8192 is exactly what some " + "harnesses ask for (opencode), so the clamp does nothing for them and their first " + "call can exceed a small context window. A real agent turn is short; 4096 is ample." + ), + ), + ] = 8192, + host: Annotated[str, typer.Option("--host")] = "0.0.0.0", + port: Annotated[ + int, typer.Option("--port", help="Env server port (faces the trainer).") + ] = 8000, + capture_port: Annotated[ + int, + typer.Option("--capture-port", help="Capture proxy port (faces the sandbox)."), + ] = 8100, + expose: Annotated[ + str, + typer.Option( + "--expose", + help="How the sandbox reaches the capture proxy: gradio | cloudflare | direct.", + ), + ] = "gradio", + api_key: Annotated[str, typer.Option("--api-key", help=_KEY_HELP)] = "", + auth_header: Annotated[ + str, typer.Option("--auth-header", help=_AUTH_HEADER_HELP) + ] = "Authorization", + env_file: Annotated[str, typer.Option("--env-file")] = "", +) -> None: + """Serve Harbor tasks over the OpenEnv Task API and MCP. + + Two ports on purpose. The env server faces the trainer on an internal network; the capture proxy + faces the sandbox and is the only thing published. Sharing one port would expose the env + server as soon as the capture proxy became reachable. + """ + from openenv.harbor.serving import serve_harbor + + serve_harbor( + llm_url=llm_url, + model=model or None, + max_output_tokens=max_output_tokens or None, + datasets=_split(dataset), + host=host, + port=port, + capture_port=capture_port, + expose=expose, + env_file=env_file or None, + api_key=api_key or None, + auth_header=auth_header, + ) + + +@app.command("push") +def push( + llm_url: Annotated[str, typer.Option("--llm-url", help=_LLM_HELP)], + repo_id: Annotated[ + str, typer.Option("--repo-id", help="Target, e.g. your-org/harbor-env.") + ] = "", + model: Annotated[str, typer.Option("--model", help="Served model id.")] = "", + dataset: Annotated[ + Optional[list[str]], typer.Option("--dataset", help=_DATASET_HELP) + ] = None, + private: Annotated[ + bool, + typer.Option( + "--private", + help="Create the Space private. The sandbox then cannot reach the capture proxy, so rollouts are not possible; use it only to park a deployment.", + ), + ] = False, + hardware: Annotated[ + str, typer.Option("--hardware", help="Space hardware, e.g. cpu-basic.") + ] = "", + api_key: Annotated[str, typer.Option("--api-key", help=_KEY_HELP)] = "", + auth_header: Annotated[ + str, typer.Option("--auth-header", help=_AUTH_HEADER_HELP) + ] = "Authorization", + env_file: Annotated[ + str, + typer.Option( + "--env-file", help="dotenv whose provider keys become Space SECRETS." + ), + ] = "", + bucket: Annotated[ + str, + typer.Option( + "--bucket", + help="Storage bucket holding the task suites. Defaults to a bucket named after the Space. Pass `none` to skip the bucket and let the Space download datasets instead.", + ), + ] = "", + recreate: Annotated[ + bool, + typer.Option( + "--recreate", + help="Delete the Space first, then deploy fresh. A Space keeps variables, secrets, volumes and any file a previous push wrote, so an incremental deploy is not a clean test of what this bundle produces.", + ), + ] = False, + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Show what would be pushed and stop.") + ] = False, +) -> None: + """Deploy this environment to a Hugging Face Space. + + Takes the same arguments as `serve`, because a deployed Space needs exactly the same + configuration — they are forwarded as Space variables, while provider credentials from + `--env-file` are forwarded as Space *secrets* so they are not readable from the repo. + """ + from pathlib import Path + + from openenv.cli.commands.push import push as _push + from openenv.harbor.startup import load_env_file + + if not repo_id: + raise typer.BadParameter("--repo-id is required, e.g. your-org/harbor-env") + + datasets = _split(dataset) + if not llm_url: + raise typer.BadParameter( + "--llm-url is required: a Space with no engine cannot run anything, and finding " + "that out after deploying is worse than finding it out now." + ) + + if private: + # A hosted deployment serves the capture proxy at /capture. On a private Space + # that URL demands an auth header the agent inside the sandbox does not send, so every model + # call 401s and the rollout records nothing. Worth a warning rather than a hard failure: + # parking a private deployment is legitimate, running rollouts against one is not. + print( + "WARNING: a private Space is not reachable from a sandbox. The capture proxy is " + "served at /capture, and a private Space requires an auth header the " + "agent will not send, so rollouts will capture no model calls. Deploy public for " + "rollouts. The proxy still refuses callers without a registered session id, which " + "is what keeps a public deployment from being an open relay." + ) + + # HF datasets are attached as read-only volumes rather than downloaded. A Harbor suite is + # thousands of small files (13k+ for a 2.2k-task dataset), so downloading on first request takes + # minutes and burns the Space's ephemeral disk; a mount is instant and survives restarts. The + # server needs no special case for it, because a mount path is just a local directory and + # `resolve_task_dirs` already accepts one. + # Every suite lives under one bucket, mounted at /data, named after the Space so the two stay + # obviously paired. `--bucket none` opts out and falls back to downloading. + hf_specs = [spec for spec in datasets if _looks_like_hf_repo(spec)] + bucket = "" if bucket.lower() == "none" else (bucket or repo_id) + mounts = {spec: f"{_MOUNT_ROOT}/{spec.replace('/', '__')}" for spec in hf_specs} + + # Non-secret configuration travels as plain Space variables. + variables = {"OPENENV_LLM_URL": llm_url, "ENABLE_WEB_INTERFACE": "true"} + if datasets: + variables["OPENENV_DATASETS"] = ",".join(datasets) + if model: + variables["OPENENV_MODEL"] = model + # The header NAME is configuration, not a credential, so it belongs here. Its value never is. + if auth_header and auth_header != "Authorization": + variables["OPENENV_LLM_AUTH_HEADER"] = auth_header + + # Provider credentials travel as secrets. Only the keys the sandboxes need — never the whole + # dotenv, which usually holds unrelated tokens. + load_env_file(env_file or None) + # Sandbox credentials, plus the keys a task's own verifier may need. A grader that cannot run + # returns no reward at all, which is reported as `reward=None` rather than 0, so the rollout is + # correctly not scored as a wrong answer, but it is also not usable for training. The DataAgent + # grader reads OPENAI_API_KEY for its LLM-judge tier, and without it every semantically correct + # answer that is not an exact string match goes ungraded. + wanted = ( + "E2B_API_KEY", + "MODAL_TOKEN_ID", + "MODAL_TOKEN_SECRET", + "DAYTONA_API_KEY", + "HF_TOKEN", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + # The upstream inference credential. A SECRET rather than a variable: Space variables are + # readable from the repo page, and this one buys inference against a paid endpoint. + "OPENENV_LLM_API_KEY", + ) + secrets = {k: os.environ[k] for k in wanted if os.environ.get(k)} + # An --api-key passed on the command line outranks the dotenv, matching every other command. + if api_key: + secrets["OPENENV_LLM_API_KEY"] = api_key + + # src/openenv/cli/commands/harbor.py -> repo root is parents[4]. + # An installed wheel has no sibling envs/ dir, so fall back to $OPENENV_HARBOR_ENV_DIR. + env_dir = Path( + os.environ.get("OPENENV_HARBOR_ENV_DIR") + or Path(__file__).resolve().parents[4] / "envs" / "harbor_env" + ) + if not (env_dir / "openenv.yaml").is_file(): + raise typer.BadParameter( + f"no harbor_env package at {env_dir}. Set OPENENV_HARBOR_ENV_DIR to its location " + "(an installed openenv wheel does not ship the envs/ directory)." + ) + # `openenv.harbor` does not exist in any released wheel, so a Space that pip-installs `openenv` + # imports the release and dies on `No module named 'openenv.harbor'`. When pushing from a source + # checkout, bundle the working tree instead; the Dockerfile puts /app/env ahead of site-packages. + source_pkg = Path(__file__).resolve().parents[2] # .../src/openenv + bundle = source_pkg if (source_pkg / "harbor").is_dir() else None + + print(f"env {env_dir}") + print(f"repo {repo_id}{' (private)' if private else ''}") + print(f"llm {llm_url}") + print( + f"datasets {', '.join(datasets) or '(none, set OPENENV_DATASETS on the Space)'}" + ) + print(f"variables {sorted(variables)}") + print(f"secrets {sorted(secrets)} (values never printed)") + print(f"openenv {'bundled from ' + str(source_pkg) if bundle else 'from PyPI'}") + if bucket: + print(f"bucket {bucket} -> {_MOUNT_ROOT} ({len(hf_specs)} suite(s))") + for spec, path in mounts.items(): + print( + f"mount {spec} -> {path}" + + (" (via bucket)" if bucket else " (read-only, not downloaded)") + ) + if dry_run: + print("\ndry run: nothing pushed") + return + + if recreate: + _delete_space(repo_id) + + if bucket and hf_specs: + _fill_bucket(bucket, hf_specs) + + with tempfile.TemporaryDirectory(prefix="openenv-harbor-push-") as tmp: + staged = Path(tmp) / "env" + shutil.copytree( + env_dir, + staged, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".venv"), + ) + if bundle is not None: + # A hosted Space mounts the capture proxy on its own app and reaches it at the Space's + # public URL, so the forwarding backends are dead code there. They are excluded rather + # than merely unused: shipping code that shells out to `cloudflared` into a Space is + # both pointless and the kind of thing platform abuse checks reject. `cli` goes for the + # same reason, it is 36 files the server never imports. + shutil.copytree( + bundle, + staged / "openenv", + ignore=shutil.ignore_patterns( + "__pycache__", "*.pyc", "forwarding.py", "cli" + ), + ) + _prune_removed_files(repo_id, staged) + _push( + directory=str(staged), + repo_id=repo_id, + private=private, + hardware=hardware or None, + env_vars=[f"{k}={v}" for k, v in variables.items()], + secrets=[f"{k}={v}" for k, v in secrets.items()], + ) + + # After the push, because volumes attach to a Space that already exists and `--recreate` has + # just deleted it. Setting them triggers one more rebuild, which is why this is last. + attached = ( + _attach_bucket(repo_id, bucket, mounts) + if bucket + else _mount_datasets(repo_id, mounts) + ) + if attached: + # Only now is it safe to point the server at mount paths. Until the mount is confirmed, + # `OPENENV_DATASETS` holds repo ids, so an unattached volume degrades to downloading rather + # than to a server pointed at directories that do not exist. + from huggingface_hub import HfApi + + HfApi().add_space_variable( + repo_id=repo_id, + key="OPENENV_DATASETS", + value=",".join(mounts.get(d, d) for d in datasets), + ) + print("mount OPENENV_DATASETS switched to mount paths") + + +def _prune_removed_files(repo_id: str, staged: Path) -> None: + """Delete files on the Space that this push no longer produces. + + `push` uploads but never deletes, so a file dropped from the bundle keeps running in the + deployment forever. That is not a tidiness point: the first version of this command shipped the + port-forwarding backends, and removing them locally left the deployed Space still carrying code + that shells out to `cloudflared`, which is exactly what a platform abuse check objects to. A + deployment has to reflect the bundle, not the union of every bundle ever pushed. + + Only the bundled `openenv/` subtree is pruned. Everything else in the Space may legitimately have + been added out of band (a README edit through the web UI, a `.gitattributes`), and deleting a + file this command never wrote is not its business. + """ + from huggingface_hub import CommitOperationDelete, HfApi + + api = HfApi() + try: + remote = api.list_repo_files(repo_id, repo_type="space") + except Exception as exc: # noqa: BLE001 - a new Space has nothing to prune + print(f"prune skipped ({type(exc).__name__}); the Space may not exist yet") + return + + local = {str(p.relative_to(staged)) for p in staged.rglob("*") if p.is_file()} + stale = sorted(f for f in remote if f.startswith("openenv/") and f not in local) + if not stale: + return + + print(f"prune {len(stale)} file(s) no longer in the bundle, e.g. {stale[0]}") + api.create_commit( + repo_id=repo_id, + repo_type="space", + operations=[CommitOperationDelete(path_in_repo=f) for f in stale], + commit_message="Remove files no longer part of the harbor_env bundle", + ) + + +# Where dataset volumes are attached inside the Space container. +_MOUNT_ROOT = "/data" + +# Mirrors `openenv.harbor.tasks._DATASET_ROOT`; kept local so the CLI does not import the +# harbor extra just to compute a path. +_DATASET_CACHE = Path( + os.environ.get("OPENENV_DATASET_CACHE") + or (Path.home() / ".cache" / "openenv" / "harbor-datasets") +) + + +def _looks_like_hf_repo(spec: str) -> bool: + """True for `owner/name`, false for a local path or a Harbor `name@version`.""" + return ( + spec.count("/") == 1 + and "@" not in spec + and not spec.startswith((".", "/", "~")) + ) + + +def _mount_datasets(repo_id: str, mounts: dict[str, str]) -> bool: + """Attach each dataset repo to the Space as a read-only volume. + + Downloading a Harbor suite inside a Space is the slow path twice over: thousands of small files + fetched one round trip at a time, onto a disk that is wiped on restart, so the cost is paid again + on every rebuild. A mounted repo is available as ordinary files immediately. + + Volumes are replaced wholesale by the API, so anything already attached is read first and kept. + + Returns: + `bool`: Whether the volumes are confirmed attached. `False` means the caller must keep using + repo ids and let the Space download, which is slower but works. + """ + if not mounts: + return False + try: + from huggingface_hub import HfApi, Volume + except ImportError: + print( + "mount skipped: this huggingface_hub has no Volume support; " + "the Space will download datasets instead" + ) + return False + + api = HfApi() + existing: list[Any] = [] + with contextlib.suppress(Exception): + existing = [ + v + for v in _attached_volumes(api, repo_id) + if getattr(v, "mount_path", None) not in set(mounts.values()) + ] + + volumes = existing + [ + Volume(type="dataset", source=spec, mount_path=path, read_only=True) + for spec, path in sorted(mounts.items()) + ] + try: + api.set_space_volumes(repo_id=repo_id, volumes=volumes) + except Exception as exc: # noqa: BLE001 - a Space that cannot mount still works by downloading + print( + f"mount failed ({type(exc).__name__}: {str(exc)[:160]}). The Space will download " + "datasets instead, which is slow but functional." + ) + return False + + # Accepting the call is not evidence that the volume exists. Read it back, because the failure + # mode of trusting it is a server configured to read directories that were never mounted. + attached = _attached_mount_paths(api, repo_id) + if not set(mounts.values()) <= attached: + print( + "mount not confirmed: the Space reports no attached volumes, so the datasets will " + "be downloaded instead. Attach them from the Space settings if you want the mount." + ) + return False + print(f"mount attached {len(mounts)} dataset volume(s)") + return True + + +def _delete_space(repo_id: str) -> None: + """Delete the Space so the next push is a clean deployment. + + A Space accumulates state a push does not own: variables and secrets set by earlier runs, mounted + volumes, and every file any previous push wrote. That makes an incremental deploy a poor test, + because it can succeed on leftovers the bundle no longer produces. Deleting first means what runs + is exactly what this command uploaded. + + Deliberately destructive, so it only ever happens behind `--recreate`. + """ + from huggingface_hub import HfApi + + api = HfApi() + try: + api.delete_repo(repo_id=repo_id, repo_type="space") + print(f"recreate deleted {repo_id}") + except Exception as exc: # noqa: BLE001 - nothing to delete is the expected first-run case + print(f"recreate nothing to delete ({type(exc).__name__})") + + +def _fill_bucket(bucket: str, specs: list[str]) -> None: + """Create `bucket` if missing and copy each task suite into it, server side. + + `copy_files` copies by xet hash: the Hub moves the references, nothing is downloaded here and + nothing is re-uploaded. That is the difference between seconds and the ~47k-file upload a local + sync performs, and it is why the bucket is filled before the Space exists rather than after. + + Suites already present are skipped, so adding a dataset to a later `push` copies only the new + one and leaves the rest untouched. + """ + from huggingface_hub import HfApi + + api = HfApi() + api.create_bucket(bucket, private=False, exist_ok=True) + + try: + present = { + entry.path.split("/", 1)[0] + for entry in api.list_bucket_tree(bucket) + if getattr(entry, "path", "") + } + except Exception: # noqa: BLE001 - a brand new bucket may not be listable yet + present = set() + + for spec in specs: + prefix = spec.replace("/", "__") + if prefix in present: + print(f"bucket {spec} already present, skipped") + continue + print( + f"copy hf://datasets/{spec} -> hf://buckets/{bucket}/{prefix} (server side)" + ) + api.copy_files(f"hf://datasets/{spec}/", f"hf://buckets/{bucket}/{prefix}/") + + +def _attach_bucket(repo_id: str, bucket: str, mounts: dict[str, str]) -> bool: + """Mount `bucket` on the Space and confirm it attached. + + Returns: + `bool`: Whether the mount is confirmed. `False` leaves the caller on repo ids so the Space + downloads rather than reading a mount that may not be there. + """ + from huggingface_hub import HfApi, Volume + + api = HfApi() + api.set_space_volumes( + repo_id=repo_id, + volumes=[Volume(type="bucket", source=bucket, mount_path=_MOUNT_ROOT)], + ) + if _MOUNT_ROOT not in _attached_mount_paths(api, repo_id): + print( + f"mount not confirmed: no volume at {_MOUNT_ROOT}. Datasets will be downloaded " + "instead. Attach the bucket from the Space settings to use the mount." + ) + return False + print(f"mount {bucket} attached at {_MOUNT_ROOT}") + return bool(mounts) + + +def _attached_volumes(api: Any, repo_id: str) -> list[Any]: + """Volumes currently mounted on `repo_id`. + + Read through `space_info().runtime`, not `get_space_runtime()`. The latter is served by an + endpoint that does not carry a `volumes` key at all, so it always answers `None` and a check + built on it reports every mount as missing. That false negative is worse than no check: it makes + a working mount look broken and sends the caller down the slow path forever. + """ + with contextlib.suppress(Exception): + runtime = api.space_info(repo_id).runtime + if runtime is not None: + return list(runtime.volumes or []) + return [] + + +def _attached_mount_paths(api: Any, repo_id: str) -> set[str]: + """Mount paths currently attached to `repo_id`.""" + return {getattr(v, "mount_path", None) for v in _attached_volumes(api, repo_id)} diff --git a/src/openenv/cli/templates/openenv_env/README.md b/src/openenv/cli/templates/openenv_env/README.md index eea9f1942c..c4e12b5e09 100644 --- a/src/openenv/cli/templates/openenv_env/README.md +++ b/src/openenv/cli/templates/openenv_env/README.md @@ -24,7 +24,9 @@ from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env try: # Create environment from Docker image (.sync() for synchronous use) - __ENV_NAME__env = __ENV_CLASS_NAME__Env.from_docker_image("__ENV_NAME__-env:latest").sync() + __ENV_NAME__env = __ENV_CLASS_NAME__Env.from_docker_image( + "__ENV_NAME__-env:latest" + ).sync() # Reset result = __ENV_NAME__env.reset() @@ -198,13 +200,17 @@ Then multiple clients can connect simultaneously: from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env from concurrent.futures import ThreadPoolExecutor + def run_episode(client_id: int): with __ENV_CLASS_NAME__Env(base_url="http://localhost:8000") as env: result = env.reset() for i in range(10): - result = env.step(__ENV_CLASS_NAME__Action(message=f"Client {client_id}, step {i}")) + result = env.step( + __ENV_CLASS_NAME__Action(message=f"Client {client_id}, step {i}") + ) return client_id, result.observation.message_length + # Run 4 episodes concurrently with ThreadPoolExecutor(max_workers=4) as executor: results = list(executor.map(run_episode, range(4))) diff --git a/src/openenv/core/README.md b/src/openenv/core/README.md index d52b5224bf..80173590ed 100644 --- a/src/openenv/core/README.md +++ b/src/openenv/core/README.md @@ -81,14 +81,17 @@ from openenv.core import EnvClient, StepResult from dataclasses import dataclass from typing import Any + @dataclass class MyAction: text: str + @dataclass class MyObservation: response: str + class MyEnvClient(EnvClient[MyAction, MyObservation, Any]): def _step_payload(self, action: MyAction) -> dict: return {"text": action.text} @@ -98,12 +101,13 @@ class MyEnvClient(EnvClient[MyAction, MyObservation, Any]): return StepResult( observation=MyObservation(**obs_data), reward=payload.get("reward"), - done=payload.get("done", False) + done=payload.get("done", False), ) def _parse_state(self, payload: dict) -> Any: return payload + # Async usage (recommended) async def main(): client = await MyEnvClient.from_docker_image("my-env:latest") @@ -111,6 +115,7 @@ async def main(): result = await client.reset() step_result = await client.step(MyAction(text="hello")) + asyncio.run(main()) # Sync usage (via .sync() wrapper) @@ -125,26 +130,26 @@ with MyEnvClient(base_url="http://localhost:8000").sync() as client: from openenv.core.env_server import Environment, HTTPEnvServer, create_app from dataclasses import dataclass + @dataclass class MyAction: text: str + @dataclass class MyObservation: response: str reward: float = 0.0 done: bool = False + class MyEnvironment(Environment): def reset(self) -> MyObservation: return MyObservation(response="Ready") def step(self, action: MyAction) -> MyObservation: - return MyObservation( - response=f"Echo: {action.text}", - reward=1.0, - done=False - ) + return MyObservation(response=f"Echo: {action.text}", reward=1.0, done=False) + # Create FastAPI app env = MyEnvironment() diff --git a/src/openenv/core/env_server/http_server.py b/src/openenv/core/env_server/http_server.py index 4ddd96a794..52ae278566 100644 --- a/src/openenv/core/env_server/http_server.py +++ b/src/openenv/core/env_server/http_server.py @@ -1281,7 +1281,7 @@ async def mcp_websocket_endpoint(websocket: WebSocket): await self._destroy_session(session_id) try: await websocket.close() - except RuntimeError: + except (RuntimeError, WebSocketDisconnect): pass # Register simulation control routes only in simulation mode @@ -1758,7 +1758,7 @@ async def websocket_endpoint(websocket: WebSocket): await self._destroy_session(session_id) try: await websocket.close() - except RuntimeError: + except (RuntimeError, WebSocketDisconnect): pass diff --git a/src/openenv/core/harness/__init__.py b/src/openenv/core/harness/__init__.py index 34d42f6920..f4162f056c 100644 --- a/src/openenv/core/harness/__init__.py +++ b/src/openenv/core/harness/__init__.py @@ -12,7 +12,15 @@ import math from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Any, Callable, Generic, Protocol, TypeVar +from typing import ( + Any, + Callable, + Generic, + Protocol, + runtime_checkable, + TypedDict, + TypeVar, +) from ..client_types import StepResult from ..env_server.mcp_types import JsonRpcErrorCode, JsonRpcResponse, Tool @@ -109,6 +117,96 @@ def __call__( ) -> ModelStepResult: ... +class TraceEntry(TypedDict, total=False): + """One captured model call from a loop-owning rollout: the request, the reply, and the tokens. + + Defined HERE, not in the trainer. A loop-owning harness (the agent runs its own tool loop and + we read back what it did) is an OpenEnv concern, and OpenEnv is what produces this record -- + `core.harness.capture.contract.to_trace_entries` emits exactly these keys. TRL carried a local + copy with a `TODO(@openenv)` asking for this, which left the trainer owning the schema for a + shape it neither produces nor can validate; the two could drift and nothing would notice until + the token fields came back empty. + + `completion_token_ids` and `per_token_logps` must be equal length when both are present: they + are the sampled ids and their generator logprobs, in order. `completion_tokens` is a fallback + for engines that return token STRINGS (`"token_id:{id}"`) rather than ids. + + `prompt_token_ids` IS THE POINT OF THIS RECORD. It is the engine's own tokenization of + everything the model saw before it generated, and a consumer that has it MUST NOT re-derive the + prompt. This field did not exist until 2026-09; the docstring here used to say there was + "deliberately no prompt field" and that consumers should re-derive from `request`. That + instruction was wrong, and it was expensive: + + * a local re-render with `apply_chat_template` matched the engine on 0 of 28 measured turns on + Qwen3.5-4B -- off by two tokens at the generation boundary, every turn; + * Qwen3.5-4B and -2B ship INVERTED `enable_thinking` defaults, so the same re-render matched + the engine for one and diverged for the other: 100% of turn transitions forked, + drift_tokens_mean 189 against 2.2, KL 0.067 against 0.016; + * training on those misaligned positions collapsed a run permanently at its FIRST weight + update, the model emitting `<|im_start|>bash` where `` belongs. + + Both producers already held these ids and dropped them here: capture has `node.prompt_ids`, + harbor has `HarborTurn.prompt_token_ids`. `to_turn_records` kept them but had no HTTP endpoint, + so no remote consumer could reach it. + + `loss_mask` marks which positions are trainable (1) versus context (0), covering the whole + sequence `prompt_token_ids + completion_token_ids`. Without it a consumer has to guess which + calls were real agent turns and which were framework bookkeeping -- a heuristic where the + producer has structural knowledge. It also carries the case where a turn's logprobs were + rejected on ingest: the tokens stay as context and the mask goes to 0, which a consumer cannot + infer and would otherwise train against a logprob of 0.0, i.e. p=1.0. + + `reward` is a MIRROR of what `verify()` already returned, present so a persisted trace is + self-describing. `verify()` remains the source of truth. `None` means UNSCORED and must never be + coerced to 0.0: a rollout that failed to grade is excluded from the group baseline, not punished. + + Only an ENVIRONMENT can set `reward`. A capture proxy sees model calls, not task outcomes, so + `openenv.core.harness.capture.to_trace_entries` omits the key entirely rather than guessing. This + is a `total=False` TypedDict and every key is optional for exactly that reason: read `reward` with + `.get()`, because indexing it raises on any entry a proxy produced. + """ + + request: dict[ + str, Any + ] # forwarded chat body: {"messages": [...], "tools": [...] | None} + response: dict[str, Any] # upstream reply: {"choices": [{"message": {...}, ...}]} + prompt_token_ids: list[ + int + ] # the ENGINE's tokenization of everything before this turn + completion_token_ids: list[int] # generated token ids for this turn + completion_tokens: list[str] # fallback token strings when ids are absent + per_token_logps: list[float] # generator logprobs, aligned with the ids above + loss_mask: list[int] # 1 = train this position, over prompt + completion + reward: float | None # mirror of verify(); None is UNSCORED, never 0.0 + metadata: dict[str, Any] # task id, difficulty tier, harness, sandbox, session id + + +@runtime_checkable +class LoopOwningSession(Protocol): + """What a session must offer BEYOND `ResourceSession` when the agent owns its own loop. + + In the loop-owning path nothing calls `step()` per turn -- an external agent (opencode, codex, + claude-code, ...) drives itself to completion and the captured trace is read back afterwards. + So a factory used in that mode must return sessions that can be waited on and asked for their + trace. Neither method belongs on the base `ResourceSession`, which models the step-per-turn + contract. + + `wait_for_completion` returns the agent's exit code. `fetch_proxy_trace` returns the captured + turns; where they come from is the session's business -- a file inside the sandbox, or an HTTP + call to a capture server that multiplexes many rollouts at once. That freedom is the point: + the consumer asks for `TraceEntry`s and does not learn how they were obtained. + + `@runtime_checkable` so a factory can assert what it is about to return actually satisfies this + -- the alternative is discovering a missing `fetch_proxy_trace` several minutes into a paid + rollout. Note that only method PRESENCE is checked, never signatures, which is the right + strictness here: the two implementations legitimately differ in how they wait. + """ + + def wait_for_completion(self, timeout_s: float | None = ...) -> int: ... + + def fetch_proxy_trace(self) -> list[TraceEntry]: ... + + class ResourceSession(ABC): """Per-rollout environment/resource session exposed to harnesses.""" @@ -711,6 +809,7 @@ def rollout_func(prompts: list[Any], trainer: Any) -> dict[str, list[Any]]: "HarnessAdapter", "HarnessRolloutResult", "HarnessRunLimits", + "LoopOwningSession", "MCPHarnessAdapter", "Message", "ModelStep", @@ -723,6 +822,7 @@ def rollout_func(prompts: list[Any], trainer: Any) -> dict[str, list[Any]]: "StepEnvSessionAdapter", "ToolResult", "ToolTraceEntry", + "TraceEntry", "VerifyResult", "build_harness_rollout_func", ] diff --git a/src/openenv/core/harness/capture/__init__.py b/src/openenv/core/harness/capture/__init__.py new file mode 100644 index 0000000000..f34faed83b --- /dev/null +++ b/src/openenv/core/harness/capture/__init__.py @@ -0,0 +1,37 @@ +"""Token-level capture for agentic rollouts. + +An OpenAI-spec proxy that sits between a coding agent and an inference engine, recording the exact +token ids and logprobs of every model call so a rollout can be trained on. + +The core idea is that nothing is ever tokenised locally. The engine returns `prompt_token_ids`, so +turn k+1's prompt IS the canonical tokenisation of everything before it, and turns are linked by +exact token prefix. Re-rendering a prompt offline drifts from what the model actually saw, and a +drifted prompt silently fragments one long conversation into several short ones. + +Four wire dialects are supported (chat-completions, Responses, Anthropic Messages, Google +generateContent) because coding agents did not agree on one. Validated across 16 harnesses, each +cross-checked against the harness's own trace. +""" + +from .contract import measure_retokenization_skew, to_trace_entries, to_turn_records +from .detection import APIType, detect +from .graph import RolloutGraph, TurnNode +from .runner import CaptureServer +from .upstream import InferenceClient, UpstreamError +from .validate_llm import LLMReport, require_llm, validate_llm + +__all__ = [ + "CaptureServer", + "to_turn_records", + "to_trace_entries", + "measure_retokenization_skew", + "APIType", + "detect", + "RolloutGraph", + "TurnNode", + "InferenceClient", + "UpstreamError", + "LLMReport", + "validate_llm", + "require_llm", +] diff --git a/src/openenv/core/harness/capture/compat.py b/src/openenv/core/harness/capture/compat.py new file mode 100644 index 0000000000..9718177e01 --- /dev/null +++ b/src/openenv/core/harness/capture/compat.py @@ -0,0 +1,287 @@ +"""Turn a provider's 400 into the one edit that would have made the request work. + +Hosted providers reject parameters a local vLLM accepts, and they reject them per *model* rather +than per endpoint, so no static table can be right. What they do give is a machine-readable reason, +and it names the offending field: + + max_tokens Unsupported parameter: 'max_tokens' is not supported with this model. + Use 'max_completion_tokens' instead. (code: unsupported_parameter) + temperature Unsupported value: 'temperature' does not support 0 with this model. Only the + default (1) value is supported. (code: unsupported_value) + logprobs Unsupported parameter: 'logprobs' is not supported with this model. + return_token_ids + Unrecognized request argument supplied: return_token_ids (code: null) + Unknown parameter: 'return_token_ids'. (code: unknown_parameter) + reasoning_effort + Function tools with reasoning_effort are not supported for gpt-5.6-sol in + /v1/chat/completions. To use function tools, use /v1/responses or set + reasoning_effort to 'none'. (param: null, code: null) + +All were observed live: the first three on `gpt-5.5` and every `gpt-5.6-*`, the last on `gpt-4o-mini` +and `gpt-5.6-*` respectively. Note the last two — the *same* rejection of the *same* parameter, +phrased two ways by two models of the same vendor, one of them not populating `error.param` at all. +That is the reason this reads several shapes rather than matching one string, and the reason +`error.param` is preferred but not required. + +It matters because the agent, not this layer, chooses most of these params — opencode, codex and +qwen-coder all send `max_tokens` and a temperature — so a harness that works against vLLM would +otherwise 400 on every single call against a current OpenAI model. + +The last one is the sharpest, and the only fix here that changes model *behaviour* rather than +spelling: `reasoning_effort: "none"` turns reasoning off, so what gets evaluated is not quite what the +harness asked for. It is applied anyway because the alternative is that every tools-bearing call fails +and the agent does nothing at all — but for that reason every applied fix is recorded on the result, +in the startup report and in the UI, never silently. The better answer is the Responses route, which +the provider's own message recommends. + +`diagnose` is deliberately conservative. It returns a fix only when it can name the parameter, it +never touches a field the request cannot lose (`model`, `messages`, `input`), and a caller is +expected to bound how many fixes it will accept before giving up. Guessing wrong here turns a clear +400 into a silently different request, which is worse than the 400. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +# Fields whose removal would change the request into a different request, or make it invalid. A +# provider complaining about one of these is telling us something real, and papering over it would +# replace a loud failure with a wrong result. +PROTECTED = frozenset({"model", "messages", "input", "stream", "tools", "tool_choice"}) + +# A ceiling on how many distinct fixes one endpoint may need. Six covers every provider seen (three +# on gpt-5.6 plus `return_token_ids`); beyond that the disagreement is not about parameter spelling +# and retrying is just spending money on the same 400. +MAX_FIXES = 6 + +_UNRECOGNISED = re.compile( + r"[Uu]nrecognized request argument supplied:\s*([A-Za-z0-9_.\[\]]+)" +) +# A parameter name as providers quote it. Anthropic uses backticks where OpenAI uses single quotes, +# for the same job, so both count. +_QUOTED = re.compile(r"['`]([A-Za-z0-9_.]+)['`]") +_USE_INSTEAD = re.compile(r"[Uu]se ['`]([A-Za-z0-9_.]+)['`] instead") +# "... or set reasoning_effort to 'none'." — a provider naming the value that makes the request legal. +_SET_TO = re.compile(r"set ([A-Za-z0-9_.]+) to ['`]([A-Za-z0-9_.-]+)['`]") +# "`temperature` is deprecated for this model." — Anthropic's phrasing, under a generic +# `invalid_request_error` code with a null `param`, so neither the code nor the structured field +# identifies it. The vocabulary is the only signal. +# +# The quotes are REQUIRED, not optional. An earlier version made them optional and matched the bare +# word before "is not supported", which on +# +# Setting 'max_tokens' and 'max_completion_tokens' at the same time is not supported. +# +# extracted the parameter name **"time"**. Inventing a field name out of prose is how a compat layer +# starts deleting things nobody asked it to; a provider that means a parameter always quotes it. +_REJECTED_WORDING = re.compile( + r"['`]([A-Za-z0-9_.]+)['`]\s+is\s+(?:deprecated|not supported|unsupported)" +) +# "Setting 'max_tokens' and 'max_completion_tokens' at the same time is not supported." — two params +# that are individually fine and mutually exclusive. Observed on Anthropic's compat route with +# openclaw and openhands-sdk, which send both; it failed every call of those rollouts. +_CONFLICT = re.compile( + r"[Ss]etting ['`]([A-Za-z0-9_.]+)['`] and ['`]([A-Za-z0-9_.]+)['`].*?" + r"(?:not supported|cannot|at the same time)" +) +# Fixes that change how the MODEL BEHAVES, as opposed to how a field is spelled. Dropping a +# temperature changes the sampling distribution; forcing `reasoning_effort` off turns a reasoning +# model into a non-reasoning one. Both are applied because the alternative is a request that fails +# outright, but a caller has to be told, because the consequences are not cosmetic — see +# `BEHAVIOUR_WARNINGS`. +BEHAVIOUR_ALTERING = frozenset({"reasoning_effort", "temperature", "top_p", "top_k"}) + +# What each one costs, in the terms a user cares about. Keyed by parameter. +BEHAVIOUR_WARNINGS = { + "reasoning_effort": ( + "reasoning has been turned OFF for this model, because it refuses function tools on " + "/v1/chat/completions otherwise. Measured consequence: agentic harnesses may make a single " + "model call and stop — goose and codex both did, 0/3 tasks each, while the same harnesses " + "scored 3/3 against a non-reasoning model on the same endpoint. Prefer a non-reasoning " + "model for agent rollouts, or use the Responses route, which keeps reasoning on." + ), + "temperature": ( + "the requested temperature was rejected and dropped, so sampling uses the provider default. " + "The policy being evaluated is not quite the one the harness asked for, which makes an eval " + "number hard to reproduce." + ), + "top_p": "the requested top_p was dropped; sampling uses the provider default.", + "top_k": "the requested top_k was dropped; sampling uses the provider default.", +} + + +def behaviour_warnings(fixes) -> list[str]: + """Human-readable consequences of any fix that changes model behaviour, not just field names. + + Separated from the fix list itself because "renamed max_tokens" and "reasoning turned off" are + both `param_fixes` entries and only one of them changes what you are measuring. + """ + out = [] + for fix in fixes or []: + param = getattr(fix, "param", None) or str(fix) + for known in BEHAVIOUR_ALTERING: + if known in str(param) and known in BEHAVIOUR_WARNINGS: + out.append(f"{known}: {BEHAVIOUR_WARNINGS[known]}") + break + return out + + +# Legacy spelling -> the modern one it collides with. Used only to decide which of a conflicting PAIR +# to drop, and deliberately tiny: guessing wrong here silently changes the request. +LEGACY_ALIASES = { + "max_tokens": "max_completion_tokens", + "functions": "tools", + "function_call": "tool_choice", +} + + +@dataclass(frozen=True) +class ParamFix: + """One edit to a request body: drop a parameter, rename it, or set it to a demanded value.""" + + param: str + replacement: str = "" + value: str | None = None + + @property + def action(self) -> str: + if self.value is not None: + return "set" + return "rename" if self.replacement else "drop" + + def apply(self, body: dict[str, Any]) -> bool: + """Edit `body` in place. Returns whether anything changed. + + A rename carries the value across rather than dropping it, because `max_tokens` -> + `max_completion_tokens` is the same instruction under a different name; losing the value + would silently uncap the completion length. + + A `set` is the only fix that ADDS a key, so it is the only one that applies to a body which + does not already contain the parameter — that is the point of it: the request was rejected + for lacking a value the provider requires, not for carrying a bad one. + """ + if self.value is not None: + if body.get(self.param) == self.value: + return False + body[self.param] = self.value + return True + if self.param == "logprobs" and not self.replacement: + changed = "logprobs" in body or "top_logprobs" in body + body.pop("logprobs", None) + body.pop("top_logprobs", None) + return changed + if self.param not in body: + return False + value = body.pop(self.param) + if self.replacement: + body[self.replacement] = value + return True + + def __str__(self) -> str: + if self.value is not None: + return f"set {self.param}={self.value}" + if self.replacement: + return f"renamed {self.param} -> {self.replacement}" + return f"dropped {self.param}" + + +def diagnose(body: dict[str, Any] | str | None) -> ParamFix | None: + """The fix a 400 response body is asking for, or `None` if it is not asking for one. + + Args: + body (`dict` or `str`, *optional*): + The parsed error payload, as [`UpstreamHTTPError`] carries it. + + Returns: + [`ParamFix`] or `None`: The single edit to retry with. + """ + error: Any = body + if isinstance(body, dict): + error = body.get("error", body) + if isinstance(error, dict): + param = error.get("param") + message = str(error.get("message") or "") + code = str(error.get("code") or "") + elif isinstance(error, str): + param, message, code = None, error, "" + else: + return None + + if ( + message.strip().lower() + == "you are not allowed to request logprobs from this model" + ): + return ParamFix(param="logprobs") + + # Two parameters that conflict. Resolved before anything else because the message names both and + # neither is individually wrong, so every other rule here would either miss it or pick a field out + # of the surrounding prose. + conflict = _CONFLICT.search(message) + if conflict: + a, b = conflict.group(1), conflict.group(2) + for legacy, modern in ((a, b), (b, a)): + if LEGACY_ALIASES.get(legacy) == modern and legacy not in PROTECTED: + return ParamFix(param=legacy) + # An unknown pair. Refuse rather than guess which one the caller meant to keep: dropping the + # wrong half of a conflict is a silently different request, and the 400 is at least honest. + return None + + # An explicit remediation, checked first because it is the provider telling us the answer rather + # than us inferring one. gpt-5.6 refuses function tools on /v1/chat/completions with: + # + # Function tools with reasoning_effort are not supported for gpt-5.6-sol in + # /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort + # to 'none'. + # + # Every tools-bearing call fails, which for a coding agent is every call that matters: observed as + # a rollout that made one model call and then sat idle until the 900s agent timeout, with an empty + # agent log. `/v1/responses` is the better answer and is a separate piece of work; this keeps the + # newest OpenAI models usable for eval on the chat route in the meantime. + set_to = _SET_TO.search(message) + if set_to and set_to.group(1) not in PROTECTED: + return ParamFix(param=set_to.group(1), value=set_to.group(2)) + + # `Unrecognized request argument supplied: X` names the field in the message and leaves `param` + # null, which is how OpenAI reports a parameter it has never heard of (our `return_token_ids`). + unrecognised = _UNRECOGNISED.search(message) + if unrecognised: + return _fix(unrecognised.group(1), message) + + if code in { + "unsupported_parameter", + "unsupported_value", + "unknown_parameter", + } or message.startswith( + ("Unsupported parameter", "Unsupported value", "Unknown parameter") + ): + # `param` is authoritative when present; the quoted name in the message is the fallback for + # providers that copy OpenAI's prose but not its structured fields. + name = param or ( + _QUOTED.search(message).group(1) if _QUOTED.search(message) else "" + ) + return _fix(name, message) + + # Last resort: the provider used a generic code and told us in prose. Gated on explicit rejection + # vocabulary rather than on any mention of a parameter, because an arbitrary + # `invalid_request_error` that happens to name a field ("`messages` must not be empty") is a real + # error to surface, not a parameter to quietly delete. + rejected = _REJECTED_WORDING.search(message) + if rejected: + return _fix(rejected.group(1), message) + + return None + + +def _fix(name: str, message: str) -> ParamFix | None: + if not name or name in PROTECTED: + return None + # Only honour the suggested replacement when it is a *different* field. "Use 'temperature' + # instead" in a message about temperature means "use another value", not "rename", and renaming + # a field to itself would loop forever on the same 400. + suggested = _USE_INSTEAD.search(message) + replacement = suggested.group(1) if suggested else "" + if replacement in {name, *PROTECTED}: + replacement = "" + return ParamFix(param=name, replacement=replacement) diff --git a/src/openenv/core/harness/capture/contract.py b/src/openenv/core/harness/capture/contract.py new file mode 100644 index 0000000000..b3a3539ed4 --- /dev/null +++ b/src/openenv/core/harness/capture/contract.py @@ -0,0 +1,331 @@ +"""What a rollout hands a trainer. + +The mask-aware training contract is `to_trace_entries`: one engine prompt, sampled completion, +behavior logprobs, and a loss mask over prompt+completion per retained model call. Prompt ids and +sampled ids are never reconstructed from text. Masks are authoritative after reconciliation. + +`to_turn_records` provides the older three-array tuple for fully supervised completions. It skips +fully masked turns and rejects partial masks because a tuple cannot represent their eligibility. + +`measure_retokenization_skew` stays, not as a cost estimate for a re-render you were going to do +anyway, but as the measurement that catches a producer which has quietly stopped emitting ids. + +WHAT THIS MODULE CANNOT FILL IN. `TraceEntry` also declares `reward`, and nothing here emits it: a +capture proxy sees model calls, not task outcomes, and inventing a number would be worse than +omitting one. An ENVIRONMENT fills it in from its own `verify()`. `TraceEntry` is `total=False`, so +read it with `.get("reward")` — indexing it raises on every entry this module produces. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from .graph import RolloutGraph, TurnNode +from .validate import check_turn, validate_training_turn + +logger = logging.getLogger(__name__) + +# Control text emitted by the proxy, never sampled or eligible for training. +BUDGET_STOP_MESSAGE = "Step budget exhausted; stopping." + + +def _usable(node: TurnNode) -> bool: + """Whether this turn's sampled ids and logprobs describe the same tokens. + + The same guard `sequence_for` applies before masking a turn in, applied here too. These adapters + walk the graph directly, so they used to emit `completion_token_ids` with every sampled id and + `per_token_logps` as `[]` whenever ingest had rejected the logprobs — an unequal pair with no + assertion anywhere, which a consumer zipping the two silently misattributes across every token of + the turn. + + A turn that fails this is skipped rather than emitted short, and the caller logs how many, because + `n_trainable_tokens` already excludes them and dropping them keeps the two counts consistent. + """ + logprobs = node.sampled_logprobs + return ( + bool(node.sampled_ids) + and bool(logprobs) + and len(logprobs) == len(node.sampled_ids) + and check_turn(node.prompt_ids, node.sampled_ids, logprobs).ok + ) + + +def _require_trainable(document: dict[str, Any]) -> None: + """Refuse to build a training contract out of an eval rollout. + + An eval document has an empty `sequences` list, so every converter here would return `[]` and the + caller would receive a well-formed, empty contract — the exact silent-nothing failure this whole + layer exists to prevent. Raising is the only honest answer: the data a trainer is asking for was + never captured, and no amount of downstream care can reconstruct it. + + Raises: + ValueError: If the document came from an endpoint that could not return token ids. + """ + if document.get("rollout_type", "train") == "eval": + level = document.get("capture_level") or "unknown" + raise ValueError( + f"this is an EVAL rollout (capture_level={level!r}): it carries the reward and the full " + "trace, but no token ids or logprobs, so there is no training contract to build. Point " + "the capture proxy at vLLM (--return-tokens-as-token-ids --logprobs-mode " + "processed_logprobs) or SGLang built from main to get trainable rollouts." + ) + + +def _warn_skipped(nodes: list[TurnNode]) -> None: + """Say out loud how many turns are being left out, and why.""" + skipped = [n.node_id for n in nodes if not _usable(n)] + if skipped: + logger.warning( + "omitting %d of %d agent turn(s) from the training contract: their logprobs were " + "rejected on ingest, so ids and logprobs do not describe the same tokens (%s)", + len(skipped), + len(nodes), + ", ".join(skipped[:5]), + ) + + +def _agent_nodes(graph: RolloutGraph, document: dict[str, Any]) -> list[TurnNode]: + """Every agent turn, in arrival order, excluding auxiliary calls and discarded retries. + + All agent sequences, not just the first. A rollout can have several: a harness that rewrites its + system prompt mid-run starts a new root, and a fork produces several paths that share a prefix. + Taking `agent_rows[0]` dropped the rest, so `to_turn_records` and `to_trace_entries` silently + returned part of the rollout while reporting nothing wrong. + + Nodes are deduplicated because forked paths share their common prefix, and ordered by arrival so + a turn's position matches the order the model produced it in. + """ + keep: set[str] = set() + for row in document["sequences"]: + if row["role"] == "agent": + keep.update(row["node_ids"]) + if not keep: + return [] + return [n for n in graph.nodes() if n.node_id in keep] + + +def to_trace_entries( + graph: RolloutGraph, document: dict[str, Any] +) -> list[dict[str, Any]]: + """Rollout graph -> `list[TraceEntry]`, carrying the ENGINE's own prompt tokenization. + + Every entry includes `prompt_token_ids` and `loss_mask`, so a consumer never re-tokenizes. This + used to emit neither, on the reasoning that a consumer could re-derive the prompt from + `request`. It cannot: measured on Qwen3.5-4B over 28 live turns, a local re-render matched the + engine on ZERO of them, and training on the difference collapsed a run at its first weight + update. The ids were always here in `node.prompt_ids`; they were simply dropped at this + boundary. + + `loss_mask` spans `prompt_token_ids + completion_token_ids`: 0 across the prompt (context) and 1 + at eligible sampled positions. Zero bits from reconciliation remain zero, including partial + completion masks; eligibility is not inferable downstream. + + Auxiliary roots and discarded retries are already excluded here, so the caller does not need an + `agent_turn_fn`. That hook exists because a flat trace cannot tell an aux call from an agent + turn; a graph can, structurally. + + Raises: + ValueError: If `document` is an eval rollout. See `_require_trainable`. + """ + _require_trainable(document) + entries = [] + nodes = _agent_nodes(graph, document) + masks = _completion_masks(graph, document) + _warn_skipped(nodes) + for node in nodes: + if not _usable(node): + continue + mask = [0] * len(node.prompt_ids) + masks[node.node_id] + validate_training_turn( + node.prompt_ids, node.sampled_ids, node.sampled_logprobs, mask + ) + entries.append( + { + "request": { + "messages": node.request_messages, + "tools": node.request_tools, + }, + "response": { + "choices": [ + { + "message": node.response_message, + "finish_reason": node.finish_reason, + } + ] + }, + "prompt_token_ids": node.prompt_ids, + "completion_token_ids": node.sampled_ids, + "per_token_logps": node.sampled_logprobs or [], + # Deterministic here because `_usable` already skipped every turn whose logprobs + # were rejected on ingest -- so anything that reaches this line is fully trainable, + # and the mask is simply context across the prompt, train across the sample. The + # field is emitted anyway rather than left for the consumer to synthesise: a + # consumer that assumes "all sampled tokens are trainable" is right only because of + # a filter it cannot see from here. + "loss_mask": mask, + "metadata": { + "node_id": node.node_id, + "index": node.index, + "model": node.model, + "n_tools": node.n_tools, + "harness_session_id": node.harness_session_id, + "sampling_params": dict(node.sampling_params), + "requested_sampling_params": dict(node.requested_sampling_params), + }, + } + ) + return entries + + +def _completion_masks( + graph: RolloutGraph, document: dict[str, Any] +) -> dict[str, list[int]]: + """Read authoritative masks after reconciliation, deduplicating shared nodes.""" + masks: dict[str, list[int]] = {} + for row in document["sequences"]: + if row["role"] != "agent": + continue + mask = row.get("loss_mask") + if mask is None: + # Older callers supply only graph membership; use the graph's validated mask. + mask = graph.sequence_for(row["node_ids"][-1]).loss_mask + for node_id in row["node_ids"]: + node = graph.get(node_id) + start = len(node.prompt_ids) + span = list(mask[start : start + len(node.sampled_ids)]) + if len(span) != len(node.sampled_ids): + raise ValueError(f"incomplete completion mask for node {node_id}") + if node_id in masks and masks[node_id] != span: + raise ValueError( + f"inconsistent completion masks for shared node {node_id}" + ) + masks[node_id] = span + return masks + + +def to_turn_records( + graph: RolloutGraph, document: dict[str, Any] +) -> list[tuple[list[int], list[int], list[float]]]: + """Rollout graph -> `(prompt_ids, output_ids, output_log_probs)` per turn, losslessly. + + For fully supervised completions only. Fully masked calls are skipped; partial masks raise + rather than silently restoring supervision. Use `to_trace_entries` for the mask-aware contract. + + Raises: + ValueError: If `document` is an eval rollout. See `_require_trainable`. + """ + _require_trainable(document) + nodes = _agent_nodes(graph, document) + _warn_skipped(nodes) + masks = _completion_masks(graph, document) + if any(any(mask) and not all(mask) for mask in masks.values()): + raise ValueError( + "to_turn_records cannot represent partial masks; use to_trace_entries" + ) + return [ + (node.prompt_ids, node.sampled_ids, node.sampled_logprobs or []) + for node in nodes + if _usable(node) and any(masks[node.node_id]) + ] + + +def measure_retokenization_skew( + graph: RolloutGraph, + document: dict[str, Any], + tokenizer, + *, + chat_template: str | None = None, + chat_template_kwargs: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Compare TRL's re-tokenized prompt against the engine's, per turn. The number nobody has had. + + Reproduces `_turns_from_trace` exactly, including the `_decode_tool_call_arguments` step (the + trace stores tool-call `arguments` as a JSON string; XML-style templates such as Qwen3.5's + iterate it and raise on a string). + + Returns per-turn exact-match, common-prefix length and length delta. `exact_match_frac == 1.0` + means re-tokenization is safe for this model+harness pair and the hook buys nothing. Anything + less means TRL is training on a prompt the model never saw, and `_chain_to_sequences` will fork + the conversation at the first divergence. + """ + import json as _json + + def decode_arguments(messages): + out = [] + for message in messages: + calls = message.get("tool_calls") + if not calls: + out.append(message) + continue + new = [] + for call in calls: + function = call.get("function") + arguments = (function or call).get("arguments") + if not isinstance(arguments, str): + new.append(call) + continue + try: + arguments = _json.loads(arguments) + except _json.JSONDecodeError: + arguments = {} + new.append( + {**call, "function": {**function, "arguments": arguments}} + if function + else {**call, "arguments": arguments} + ) + out.append({**message, "tool_calls": new}) + return out + + def prefix_len(a, b): + n = min(len(a), len(b)) + i = 0 + while i < n and a[i] == b[i]: + i += 1 + return i + + turns = [] + for i, node in enumerate(_agent_nodes(graph, document)): + try: + rebuilt = tokenizer.apply_chat_template( + decode_arguments(node.request_messages), + tools=node.request_tools, + add_generation_prompt=True, + tokenize=True, + return_dict=False, + chat_template=chat_template, + **(chat_template_kwargs or {}), + ) + except Exception as exc: # noqa: BLE001 - a template that raises IS the finding + turns.append( + { + "turn": i, + "error": f"{type(exc).__name__}: {str(exc)[:160]}", + "engine_len": len(node.prompt_ids), + } + ) + continue + turns.append( + { + "turn": i, + "engine_len": len(node.prompt_ids), + "rebuilt_len": len(rebuilt), + "delta": len(rebuilt) - len(node.prompt_ids), + "prefix_match": prefix_len(node.prompt_ids, rebuilt), + "exact": list(rebuilt) == list(node.prompt_ids), + } + ) + + scored = [t for t in turns if "exact" in t] + return { + "n_turns": len(turns), + "n_errors": len(turns) - len(scored), + "exact_match_frac": (sum(t["exact"] for t in scored) / len(scored)) + if scored + else 0.0, + "max_abs_delta": max((abs(t["delta"]) for t in scored), default=0), + "min_prefix_match_frac": min( + (t["prefix_match"] / max(t["engine_len"], 1) for t in scored), default=0.0 + ), + "turns": turns, + } diff --git a/src/openenv/core/harness/capture/detection.py b/src/openenv/core/harness/capture/detection.py new file mode 100644 index 0000000000..0765245414 --- /dev/null +++ b/src/openenv/core/harness/capture/detection.py @@ -0,0 +1,65 @@ +"""Which wire dialect is this request written in? + +Four dialects reach the intercept, because coding agents did not agree on one: + + openai_chat opencode, qwen-coder, goose, swe-agent, mini-swe-agent, terminus-2, ... + openai_responses codex, trae-agent + anthropic claude-code + google gemini-cli, antigravity-sdk + +Detection is by path first, then headers, then body shape — strongest signal to weakest. Path is +unambiguous when present; a header is a deliberate client declaration; body shape is a last resort +and can be coincidental, so it is only consulted when nothing better exists. + +Getting this wrong is not subtle: a Google request parsed as chat-completions produces a 400 and the +agent silently does nothing, which reads as "captured nothing" rather than as a routing bug. That is +why trae-agent looked like a chat harness for a full night — its config says `provider: openai`, but +the access log showed exactly one `POST /v1/responses` against 465 chat calls. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + + +class APIType(str, Enum): + ANTHROPIC = "anthropic" + OPENAI_CHAT = "openai_chat" + OPENAI_RESPONSES = "openai_responses" + GOOGLE = "google" + + +def detect(path: str, headers: dict[str, str], body: dict[str, Any]) -> APIType: + """Classify one request. Defaults to chat-completions, the most common dialect.""" + if "/v1/messages" in path: + return APIType.ANTHROPIC + if "/v1/chat/completions" in path: + return APIType.OPENAI_CHAT + if "/v1/responses" in path: + return APIType.OPENAI_RESPONSES + # Google puts the method in the path: `/v1beta/models/{model}:generateContent`, and its + # streaming variant `:streamGenerateContent`. The comparison must be case-insensitive: the + # streaming form capitalises the G, so a literal `"generateContent" in path` matches the + # non-streaming route and misses every streaming one. A missed Google request is then handed to + # the chat-completions transformer, which finds no `messages` and produces a valid-looking + # response in the wrong envelope, and gemini-cli reports that as nothing at all. + if "generatecontent" in path.lower(): + return APIType.GOOGLE + + if "anthropic-version" in {k.lower() for k in headers}: + return APIType.ANTHROPIC + + if "contents" in body: + return APIType.GOOGLE + if "input" in body and "instructions" in body: + return APIType.OPENAI_RESPONSES + + return APIType.OPENAI_CHAT + + +def extract_model(api_type: APIType, body: dict[str, Any]) -> str: + """The model name the client asked for, whatever the dialect calls it.""" + if api_type is APIType.GOOGLE: + return body.get("model", "gemini-pro") + return body.get("model", "unknown") diff --git a/src/openenv/core/harness/capture/dialects/README.md b/src/openenv/core/harness/capture/dialects/README.md new file mode 100644 index 0000000000..e10e8cc7de --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/README.md @@ -0,0 +1,36 @@ +# Dialect transformers + +Translation between the four wire dialects coding agents speak and the OpenAI chat-completions +shape a vLLM server understands. + +| dialect | spoken by | +|---|---| +| `openai_chat` | opencode, qwen-coder, goose, swe-agent, mini-swe-agent, terminus-2, vibe, mimo, kimi-cli, hermes, openclaw, openhands-sdk, pi | +| `openai_responses` | codex, trae-agent | +| `anthropic` | claude-code | +| `google` | gemini-cli, antigravity-sdk | + +Without this layer the intercept only works for chat-completions agents, which is 13 of the 16 +validated harnesses — but the three it loses (claude-code, codex, gemini-cli) are the ones whose +capture is hardest to get right, so they are also the ones most worth having covered. + +## Provenance + +Adapted from the Polar gateway (`polar/gateway/transform/`, Apache-2.0). +Upstream: https://github.com/NVIDIA-NeMo/ProRL-Agent-Server (paper: https://arxiv.org/abs/2605.24220). +Named here because the package called `polar` on PyPI is an unrelated project, which is the +reason this is vendored rather than depended on. + +Changes made when +vendoring: + +- import paths rewritten to be relative and self-contained; no dependency on Polar remains +- the internal request marker `_polar_model_served` renamed to `_served_model` +- reasoning-signature wire prefixes `polar:` / `sg_polar_` renamed to `oe:` / `sg_oe_` + (an opaque, symmetric encode/decode pair — the value is arbitrary as long as both sides agree) +- `engine.py` and `proxy.py` were **not** vendored. They carried an SGLang backend that cannot + support token capture at all, so they were replaced by `capture/upstream.py`, a vLLM-only client + in ~160 lines. + +`images.py` and `reasoning.py` are required: the anthropic, google and responses transformers all +import them for multimodal content blocks and thinking-block round-tripping respectively. diff --git a/src/openenv/core/harness/capture/dialects/__init__.py b/src/openenv/core/harness/capture/dialects/__init__.py new file mode 100644 index 0000000000..41b9183af4 --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/__init__.py @@ -0,0 +1,23 @@ +"""Transform manager — dispatches to the right transformer by API type.""" + +from ..detection import APIType +from .anthropic import AnthropicTransformer +from .base import BaseTransformer +from .google import GoogleTransformer +from .openai_chat import OpenAIChatTransformer +from .openai_responses import OpenAIResponsesTransformer + + +class TransformManager: + """Route to the correct transformer based on detected API type.""" + + def __init__(self): + self._transformers: dict[APIType, BaseTransformer] = { + APIType.ANTHROPIC: AnthropicTransformer(), + APIType.OPENAI_CHAT: OpenAIChatTransformer(), + APIType.OPENAI_RESPONSES: OpenAIResponsesTransformer(), + APIType.GOOGLE: GoogleTransformer(), + } + + def get(self, api_type: APIType) -> BaseTransformer: + return self._transformers[api_type] diff --git a/src/openenv/core/harness/capture/dialects/anthropic.py b/src/openenv/core/harness/capture/dialects/anthropic.py new file mode 100644 index 0000000000..6ff704b260 --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/anthropic.py @@ -0,0 +1,705 @@ +"""Anthropic Messages API transformer. + +Transforms between Anthropic Messages API and OpenAI Chat Completions API. +Aligned with agent-harness-proxy/src/harness_proxy/transform/anthropic.py. +""" + +from __future__ import annotations + +import json +import re +import uuid +from dataclasses import dataclass +from typing import Any, Optional + +from ..upstream import UpstreamRequestError +from .base import BaseTransformer +from .images import ( + anthropic_content_to_openai_chat, + openai_chat_content_to_anthropic_blocks, +) +from .reasoning import extract_reasoning_from_anthropic_content, make_signature + +# Claude Code SDK leaks `x-anthropic-billing-header: ...cch=;` as the +# first line of the system prompt. The cch= hash changes per request, so +# rendered prompt tokens drift every turn and prefix_merging can't chain +# multi-turn traces. Strip the line before forwarding to SGLang. +_CLAUDE_CODE_BILLING_HEADER_RE = re.compile( + r"^\s*x-anthropic-billing-header:[^\n]*\n?", re.IGNORECASE +) + + +@dataclass +class _AnthropicToolCallState: + id: str + name: str = "" + anthropic_index: int | None = None + buffered_arguments: str = "" + started: bool = False + + +class AnthropicStreamState: + """Per-request Anthropic streaming state. + + Anthropic SSE blocks are stateful across chunks: content blocks must be + explicitly started, optionally receive multiple deltas, and then be closed + before the final message delta. This helper tracks those open blocks for a + single upstream OpenAI/SGLang stream. + """ + + def __init__(self, model: str, finish_to_stop_reason: dict[str, str]): + self.model = model + self.finish_to_stop_reason = finish_to_stop_reason + self.message_id = f"msg_{uuid.uuid4().hex}" + self.next_block_index = 0 + self.text_block_index: int | None = None + self.text_block_started = False + self.thinking_block_index: int | None = None + self.thinking_block_started = False + self.thinking_buffer = "" + self.tool_calls: dict[int, _AnthropicToolCallState] = {} + self.stop_reason = "end_turn" + self.output_tokens = 0 + self.any_block_started = False + self.completed = False + + def process_chunk( + self, chunk: dict[str, Any], is_first: bool = False + ) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + + if is_first: + events.append( + { + "type": "message_start", + "message": { + "id": self.message_id, + "type": "message", + "role": "assistant", + "content": [], + "model": self.model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 0, "output_tokens": 0}, + }, + } + ) + + usage = chunk.get("usage", {}) + if usage: + self.output_tokens = usage.get("completion_tokens", self.output_tokens) + + choices = chunk.get("choices", []) + if not choices: + return events + + choice = choices[0] + delta = choice.get("delta", {}) or {} + finish_reason = choice.get("finish_reason") + if finish_reason: + self.stop_reason = self.finish_to_stop_reason.get(finish_reason, "end_turn") + + # Thinking blocks must precede text and tool_use per Anthropic spec. + reasoning = delta.get("reasoning_content") + if reasoning: + if not self.thinking_block_started: + events.append(self._open_thinking_block()) + events.append( + { + "type": "content_block_delta", + "index": self.thinking_block_index, + "delta": {"type": "thinking_delta", "thinking": reasoning}, + } + ) + self.thinking_buffer += reasoning + + content = delta.get("content") + if content: + thinking_stop = self._close_thinking_block() + if thinking_stop: + events.extend(thinking_stop) + if not self.text_block_started: + events.append(self._open_text_block()) + events.append( + { + "type": "content_block_delta", + "index": self.text_block_index, + "delta": {"type": "text_delta", "text": content}, + } + ) + + tool_call_deltas = delta.get("tool_calls") or [] + if not isinstance(tool_call_deltas, list): + tool_call_deltas = [tool_call_deltas] + for tool_call_delta in tool_call_deltas: + if isinstance(tool_call_delta, dict): + events.extend(self._process_tool_call(tool_call_delta)) + + return events + + def finalize(self) -> list[dict[str, Any]]: + if self.completed: + return [] + + events: list[dict[str, Any]] = [] + + thinking_stop = self._close_thinking_block() + if thinking_stop: + events.extend(thinking_stop) + + text_stop = self._close_text_block() + if text_stop: + events.append(text_stop) + + for tool_index in sorted(self.tool_calls): + tool_state = self.tool_calls[tool_index] + if tool_state.started and tool_state.anthropic_index is not None: + events.append( + { + "type": "content_block_stop", + "index": tool_state.anthropic_index, + } + ) + + if not self.any_block_started: + empty_index = self.next_block_index + events.append( + { + "type": "content_block_start", + "index": empty_index, + "content_block": {"type": "text", "text": ""}, + } + ) + events.append({"type": "content_block_stop", "index": empty_index}) + + events.append( + { + "type": "message_delta", + "delta": {"stop_reason": self.stop_reason, "stop_sequence": None}, + "usage": {"output_tokens": self.output_tokens}, + } + ) + events.append({"type": "message_stop"}) + + self.completed = True + return events + + def _open_text_block(self) -> dict[str, Any]: + self.text_block_started = True + self.text_block_index = self.next_block_index + self.next_block_index += 1 + self.any_block_started = True + return { + "type": "content_block_start", + "index": self.text_block_index, + "content_block": {"type": "text", "text": ""}, + } + + def _close_text_block(self) -> dict[str, Any] | None: + if not self.text_block_started or self.text_block_index is None: + return None + + event = {"type": "content_block_stop", "index": self.text_block_index} + self.text_block_started = False + self.text_block_index = None + return event + + def _open_thinking_block(self) -> dict[str, Any]: + self.thinking_block_started = True + self.thinking_block_index = self.next_block_index + self.next_block_index += 1 + self.any_block_started = True + return { + "type": "content_block_start", + "index": self.thinking_block_index, + "content_block": {"type": "thinking", "thinking": "", "signature": ""}, + } + + def _close_thinking_block(self) -> list[dict[str, Any]] | None: + if not self.thinking_block_started or self.thinking_block_index is None: + return None + idx = self.thinking_block_index + events = [ + { + "type": "content_block_delta", + "index": idx, + "delta": { + "type": "signature_delta", + "signature": make_signature(self.thinking_buffer), + }, + }, + {"type": "content_block_stop", "index": idx}, + ] + self.thinking_block_started = False + self.thinking_block_index = None + return events + + def _process_tool_call( + self, tool_call_delta: dict[str, Any] + ) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + + tool_index = tool_call_delta.get("index", 0) + if not isinstance(tool_index, int): + tool_index = 0 + + tool_state = self.tool_calls.get(tool_index) + if tool_state is None: + tool_state = _AnthropicToolCallState( + # `or`, not a get() default: a present-but-null id returns None and defeats the fallback. + id=tool_call_delta.get("id") or f"toolu_{uuid.uuid4().hex[:24]}", + ) + self.tool_calls[tool_index] = tool_state + elif tool_call_delta.get("id"): + tool_state.id = tool_call_delta["id"] + + function = tool_call_delta.get("function", {}) + name = function.get("name") + if isinstance(name, str) and name: + tool_state.name += name + + args = function.get("arguments") + args_str = "" + if isinstance(args, str) and args: + args_str = args + elif args not in (None, ""): + args_str = json.dumps(args) + + if args_str: + tool_state.buffered_arguments += args_str + + if tool_state.name and not tool_state.started: + thinking_stop = self._close_thinking_block() + if thinking_stop: + events.extend(thinking_stop) + + text_stop = self._close_text_block() + if text_stop: + events.append(text_stop) + + tool_state.started = True + tool_state.anthropic_index = self.next_block_index + self.next_block_index += 1 + self.any_block_started = True + + events.append( + { + "type": "content_block_start", + "index": tool_state.anthropic_index, + "content_block": { + "type": "tool_use", + "id": tool_state.id, + "name": tool_state.name, + "input": {}, + }, + } + ) + + if tool_state.buffered_arguments: + events.append( + { + "type": "content_block_delta", + "index": tool_state.anthropic_index, + "delta": { + "type": "input_json_delta", + "partial_json": tool_state.buffered_arguments, + }, + } + ) + tool_state.buffered_arguments = "" + elif tool_state.started and args_str and tool_state.anthropic_index is not None: + events.append( + { + "type": "content_block_delta", + "index": tool_state.anthropic_index, + "delta": { + "type": "input_json_delta", + "partial_json": args_str, + }, + } + ) + + return events + + +class AnthropicTransformer(BaseTransformer): + """Transform between Anthropic and OpenAI API formats.""" + + FINISH_TO_STOP_REASON: dict[str, str] = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + "content_filter": "refusal", + "stop_sequence": "stop_sequence", + } + + def transform_request(self, body: dict[str, Any]) -> dict[str, Any]: + messages = [] + + # Handle system message + system = body.get("system") + if system: + system_content = self._flatten_content(system) + # Drop Claude Code's per-request billing header line (breaks + # prefix_merging because cch= changes every turn). + system_content = _CLAUDE_CODE_BILLING_HEADER_RE.sub("", system_content) + if system_content: + messages.append({"role": "system", "content": system_content}) + + # Transform messages + request_messages = body.get("messages", []) + if not isinstance(request_messages, list): + raise UpstreamRequestError("Anthropic messages must be an array") + if any(not isinstance(message, dict) for message in request_messages): + raise UpstreamRequestError("Anthropic messages must be objects") + for msg in request_messages: + transformed = self._transform_message(msg) + if transformed: + if isinstance(transformed, list): + messages.extend(transformed) + else: + messages.append(transformed) + + result: dict[str, Any] = { + "messages": messages, + "max_tokens": body.get("max_tokens", 4096), + } + if "model" in body: + result["model"] = body["model"] + + if "temperature" in body: + result["temperature"] = body["temperature"] + if "top_p" in body: + result["top_p"] = body["top_p"] + if "top_k" in body: + result["top_k"] = body["top_k"] + if "stop_sequences" in body: + result["stop"] = body["stop_sequences"] + if body.get("stream", False): + result["stream"] = True + + # Anthropic `thinking` request param → enable_thinking on chat template. + thinking_cfg = body.get("thinking") + if isinstance(thinking_cfg, dict) and thinking_cfg.get("type") in { + "enabled", + "adaptive", + }: + chat_template_kwargs = dict(result.get("chat_template_kwargs") or {}) + chat_template_kwargs["enable_thinking"] = True + result["chat_template_kwargs"] = chat_template_kwargs + + # Tools. Claude Code sometimes sends tools=[] on compaction/summary + # turns; forwarding tool_choice without a non-empty tools list makes + # SGLang reject with "tool_choice only allowed when tools specified". + if "tools" in body: + declared_tools = body["tools"] + if not isinstance(declared_tools, list): + raise UpstreamRequestError("Anthropic tools must be an array") + if any(not isinstance(tool, dict) for tool in declared_tools): + raise UpstreamRequestError("Anthropic tools must be objects") + tools = self._transform_tools_to_openai(declared_tools) + if tools: + result["tools"] = tools + result["tool_choice"] = self._transform_tool_choice_to_openai( + body.get("tool_choice", {"type": "auto"}) + ) + + return self._normalize_request( + result, + body.get("_served_model"), + ) + + def transform_response( + self, + response: dict[str, Any], + original_request: dict[str, Any], + ) -> dict[str, Any]: + choices = response.get("choices", []) + if not choices: + return self._error_response("No choices in response") + + choice = choices[0] + message = choice.get("message", {}) + + content = [] + reasoning = message.get("reasoning_content") + if isinstance(reasoning, str) and reasoning: + content.append( + { + "type": "thinking", + "thinking": reasoning, + "signature": make_signature(reasoning), + } + ) + + text = message.get("content") + if text or (isinstance(text, list) and text): + content.extend(openai_chat_content_to_anthropic_blocks(text)) + + for tool_call in message.get("tool_calls") or []: + content.append( + { + "type": "tool_use", + "id": tool_call.get("id", f"toolu_{uuid.uuid4().hex[:24]}"), + "name": tool_call.get("function", {}).get("name", ""), + "input": self._parse_json_safe( + tool_call.get("function", {}).get("arguments", "{}") + ), + } + ) + + finish_reason = choice.get("finish_reason", "stop") + stop_reason = self.FINISH_TO_STOP_REASON.get(finish_reason, "end_turn") + usage = response.get("usage", {}) + anthropic_usage = self._usage_to_anthropic(usage) + + if not content: + content.append({"type": "text", "text": ""}) + + return { + "id": f"msg_{response.get('id', uuid.uuid4().hex)}", + "type": "message", + "role": "assistant", + "content": content, + "model": original_request.get("model", "claude-3"), + "stop_reason": stop_reason, + "stop_sequence": None, + "usage": anthropic_usage, + } + + def create_stream_state( + self, original_request: dict[str, Any] + ) -> AnthropicStreamState: + return AnthropicStreamState( + model=original_request.get("model", "claude-3"), + finish_to_stop_reason=self.FINISH_TO_STOP_REASON, + ) + + def transform_stream_chunk( + self, + chunk: dict[str, Any], + original_request: dict[str, Any], + is_first: bool = False, + ) -> list[dict[str, Any]]: + """Best-effort single-chunk Anthropic transform. + + The server uses `create_stream_state()` for request-scoped streaming. + This fallback keeps direct callers working for simple single-chunk cases. + """ + state = self.create_stream_state(original_request) + events = state.process_chunk(chunk, is_first=is_first) + choices = chunk.get("choices", []) + if choices and choices[0].get("finish_reason"): + events.extend(state.finalize()) + return events + + def _transform_message(self, msg: dict[str, Any]) -> Optional[dict | list]: + """Transform a single Anthropic message to OpenAI format.""" + role = msg.get("role", "user") + content = msg.get("content", "") + + if isinstance(content, str): + return {"role": role, "content": content} + + if not isinstance(content, list): + return {"role": role, "content": str(content)} + + # Check for mixed content: tool_result blocks + other content + tool_results = [ + c for c in content if isinstance(c, dict) and c.get("type") == "tool_result" + ] + tool_uses = [ + c for c in content if isinstance(c, dict) and c.get("type") == "tool_use" + ] + text_blocks = [ + c for c in content if isinstance(c, dict) and c.get("type") == "text" + ] + + messages = [] + + # Assistant `thinking` blocks → reasoning_content (kept for replay). + reasoning_text = "" + if role == "assistant": + reasoning_text = extract_reasoning_from_anthropic_content(content) + + # Handle assistant messages with tool_use blocks + if role == "assistant" and tool_uses: + tool_calls = [] + text_parts = [] + for block in content: + if isinstance(block, dict): + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif block.get("type") == "tool_use": + tool_calls.append( + { + "id": block.get("id", f"call_{uuid.uuid4().hex[:24]}"), + "type": "function", + "function": { + "name": block.get("name", ""), + "arguments": json.dumps(block.get("input", {})), + }, + } + ) + msg_dict: dict[str, Any] = { + "role": "assistant", + "content": "\n".join(text_parts) if text_parts else None, + } + if reasoning_text: + msg_dict["reasoning_content"] = reasoning_text + if tool_calls: + msg_dict["tool_calls"] = tool_calls + return msg_dict + + # Handle user messages with tool_result blocks + if role == "user" and tool_results: + # Each tool_result becomes a tool message + for tr in tool_results: + tool_content = tr.get("content", "") + converted_content = anthropic_content_to_openai_chat(tool_content) + text_content = self._flatten_content(converted_content) + # Anthropic marks failed tool results with is_error=true. + # Surface this to the model so it can see the call failed + # rather than treating the payload as normal output. + if tr.get("is_error"): + text_content = ( + f"[Tool Error] {text_content}" + if text_content + else "[Tool Error]" + ) + messages.append( + { + "role": "tool", + "tool_call_id": tr.get("tool_use_id", ""), + "content": text_content, + } + ) + # OpenAI tool messages stay text-only; images are sent as a + # follow-up user message, so mixed text/image order is not preserved. + image_parts = self._image_parts(converted_content) + if image_parts: + messages.append({"role": "user", "content": image_parts}) + + # Any extra user text should come after the tool results. + text_parts = [b.get("text", "") for b in text_blocks if b.get("text")] + if text_parts: + messages.append({"role": "user", "content": "\n".join(text_parts)}) + return messages if messages else None + + # Regular content blocks — keep images when present. + result: dict[str, Any] = { + "role": role, + "content": anthropic_content_to_openai_chat(content), + } + if role == "assistant" and reasoning_text: + result["reasoning_content"] = reasoning_text + return result + + def _flatten_content(self, content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict): + if block.get("type") == "text": + parts.append(block.get("text", "")) + elif block.get("type") == "tool_result": + parts.append(self._flatten_content(block.get("content", ""))) + return "\n".join(parts) + return str(content) + + def _image_parts(self, content: Any) -> list[dict[str, Any]]: + if not isinstance(content, list): + return [] + return [ + part + for part in content + if isinstance(part, dict) and part.get("type") == "image_url" + ] + + def _transform_tools_to_openai(self, tools: list[dict]) -> list[dict]: + result = [] + for tool in tools: + # Anthropic server tools (web_search_*, code_execution_*) carry an + # explicit `type` and have no `input_schema`. SGLang can't dispatch + # them, so drop rather than forwarding a stub function tool. + tool_type = tool.get("type") + if ( + tool_type + and tool_type not in ("custom", "function") + and "input_schema" not in tool + ): + continue + name = tool.get("name") + if not isinstance(name, str) or not name: + continue + result.append( + { + "type": "function", + "function": { + "name": name, + "description": tool.get("description", ""), + "parameters": tool.get("input_schema", {}), + }, + } + ) + return result + + def _transform_tool_choice_to_openai(self, tool_choice: Any) -> Any: + if isinstance(tool_choice, dict): + tc_type = tool_choice.get("type") + if tc_type == "auto": + return "auto" + elif tc_type == "any": + return "required" + elif tc_type == "none": + return "none" + elif tc_type == "tool": + return { + "type": "function", + "function": {"name": tool_choice.get("name", "")}, + } + return "auto" + + def _parse_json_safe(self, s: str) -> dict: + try: + return json.loads(s) + except (json.JSONDecodeError, TypeError): + return {} + + def _usage_to_anthropic(self, usage: dict[str, Any]) -> dict[str, Any]: + prompt_tokens = usage.get("prompt_tokens", 0) + completion_tokens = usage.get("completion_tokens", 0) + cache_read = self._cached_prompt_tokens(usage) + input_tokens = ( + max(prompt_tokens - cache_read, 0) if cache_read else prompt_tokens + ) + + result: dict[str, Any] = { + "input_tokens": input_tokens, + "output_tokens": completion_tokens, + } + if cache_read: + result["cache_read_input_tokens"] = cache_read + cache_creation = usage.get("cache_creation_input_tokens") + if isinstance(cache_creation, int) and cache_creation: + result["cache_creation_input_tokens"] = cache_creation + return result + + def _cached_prompt_tokens(self, usage: dict[str, Any]) -> int: + details = usage.get("prompt_tokens_details") + if isinstance(details, dict): + cached = details.get("cached_tokens") + if isinstance(cached, int): + return cached + cached = usage.get("cached_tokens") + return cached if isinstance(cached, int) else 0 + + def _error_response(self, message: str) -> dict[str, Any]: + return { + "type": "error", + "error": {"type": "api_error", "message": message}, + } diff --git a/src/openenv/core/harness/capture/dialects/base.py b/src/openenv/core/harness/capture/dialects/base.py new file mode 100644 index 0000000000..b03193a273 --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/base.py @@ -0,0 +1,134 @@ +"""Base transformer interface with inference-backend request enhancement.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class BaseTransformer(ABC): + """Abstract base class for API transformers. + + Transforms requests from source API format to OpenAI format (for the + inference backend), and transforms responses back to source API format. + """ + + @abstractmethod + def transform_request(self, body: dict[str, Any]) -> dict[str, Any]: + """Transform request body to OpenAI format for the inference backend.""" + pass + + @abstractmethod + def transform_response( + self, + response: dict[str, Any], + original_request: dict[str, Any], + ) -> dict[str, Any]: + """Transform response back to source API format.""" + pass + + @abstractmethod + def transform_stream_chunk( + self, + chunk: dict[str, Any], + original_request: dict[str, Any], + is_first: bool = False, + ) -> dict[str, Any] | list[dict[str, Any]]: + """Transform a streaming chunk to source API format.""" + pass + + def is_streaming_request(self, body: dict[str, Any]) -> bool: + """Check if request is for streaming response.""" + return body.get("stream", False) + + def create_stream_state(self, original_request: dict[str, Any]) -> Any | None: + """Create per-request stream state when chunk transforms need memory.""" + return None + + @staticmethod + def _is_qwen35_model(model_name: str | None) -> bool: + if not model_name: + return False + return "qwen3.5" in model_name.lower() + + @staticmethod + def _content_to_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict): + text = block.get("text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + return str(content) if content else "" + + @classmethod + def _merge_developer_role(cls, request: dict[str, Any]) -> dict[str, Any]: + """Rename 'developer' role to 'system' and merge all system messages into one.""" + messages = request.get("messages") + if not isinstance(messages, list): + return request + + # Rename developer -> system + normalized = [ + {**msg, "role": "system"} + if isinstance(msg, dict) and msg.get("role") == "developer" + else msg + for msg in messages + ] + + # Merge multiple system messages into one at the top + system_parts: list[str] = [] + non_system: list[Any] = [] + for msg in normalized: + if isinstance(msg, dict) and msg.get("role") == "system": + content = msg.get("content", "") + text = cls._content_to_text(content) + if text: + system_parts.append(text) + elif content: + # A system message whose content is not reducible to text — an image part, an + # unfamiliar content type — used to vanish here: `_content_to_text` returned "", + # the falsy check skipped it, and the merged system prompt silently lost it. The + # prompt then differs from what the harness sent, which for a captured turn means + # the recorded prompt is not the one the model saw. Kept as its own message + # instead of merged, since it cannot be concatenated into the text block. + non_system.append(msg) + else: + non_system.append(msg) + + if system_parts: + request["messages"] = [ + {"role": "system", "content": "\n\n".join(system_parts)}, + *non_system, + ] + else: + request["messages"] = non_system + return request + + def _normalize_request( + self, + request: dict[str, Any], + model_name: str | None = None, + ) -> dict[str, Any]: + """Normalize the OpenAI request: drop internal keys, merge system roles, + and apply per-model template fixes. Training-signal params (logprobs, + token ids) are added later by the inference engine. + """ + request.pop("_served_model", None) + + request = self._merge_developer_role(request) + + if self._is_qwen35_model(model_name): + # Qwen3.5 outputs tool calls inside thinking; disable thinking. + # https://www.reddit.com/r/LocalLLaMA/comments/1sccqt2/i_think_i_got_solutions_for_qwen_35_tool_call_in/ + chat_template_kwargs = dict(request.get("chat_template_kwargs") or {}) + chat_template_kwargs.setdefault("enable_thinking", False) + request["chat_template_kwargs"] = chat_template_kwargs + + return request diff --git a/src/openenv/core/harness/capture/dialects/google.py b/src/openenv/core/harness/capture/dialects/google.py new file mode 100644 index 0000000000..8caa2810cf --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/google.py @@ -0,0 +1,729 @@ +"""Google Generative AI API transformer with tool-call support.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from .base import BaseTransformer +from .images import ( + google_content_parts_to_openai_chat, + google_part_to_openai_chat, + openai_chat_content_to_google_parts, +) +from .reasoning import extract_reasoning_from_gemini_parts, make_google_signature + + +@dataclass +class _GoogleToolCallState: + call_id: str = "" + name: str = "" + arguments: str = "" + + +class _GoogleStreamState: + """Accumulate streamed OpenAI tool-call deltas into Google response parts.""" + + def __init__(self, transformer: "GoogleTransformer") -> None: + self._transformer = transformer + self._tool_calls: dict[int, _GoogleToolCallState] = {} + self._finish_reason: str | None = None + self._usage: dict[str, Any] | None = None + self._emitted_tool_calls = False + self._emitted_finish_reason = False + + def process_chunk( + self, + chunk: dict[str, Any], + *, + is_first: bool = False, + ) -> list[dict[str, Any]]: + del is_first + + choices = chunk.get("choices", []) + if not choices: + usage = chunk.get("usage") + if isinstance(usage, dict): + self._usage = usage + return [] + + choice = choices[0] + delta = choice.get("delta", {}) or {} + usage = chunk.get("usage") + if isinstance(usage, dict): + self._usage = usage + + parts: list[dict[str, Any]] = [] + reasoning = delta.get("reasoning_content") + if isinstance(reasoning, str) and reasoning: + parts.append( + { + "thought": True, + "text": reasoning, + "thoughtSignature": make_google_signature(reasoning), + } + ) + content = delta.get("content") + if isinstance(content, str) and content: + parts.append({"text": content}) + + for tool_call in self._transformer._normalize_tool_call_deltas( + delta.get("tool_calls") + ): + tool_index = tool_call.get("index", 0) + if not isinstance(tool_index, int): + tool_index = 0 + state = self._tool_calls.setdefault(tool_index, _GoogleToolCallState()) + + if isinstance(tool_call.get("id"), str) and tool_call["id"]: + state.call_id = tool_call["id"] + + function = tool_call.get("function", {}) + if isinstance(function, dict): + name = function.get("name") + if isinstance(name, str) and name: + state.name += name + + arguments = function.get("arguments") + if isinstance(arguments, str) and arguments: + state.arguments += arguments + elif arguments not in (None, ""): + state.arguments += json.dumps(arguments) + + finish_reason = choice.get("finish_reason") + current_finish_reason = ( + finish_reason if isinstance(finish_reason, str) and finish_reason else None + ) + if current_finish_reason: + self._finish_reason = current_finish_reason + + if parts: + if current_finish_reason: + self._emitted_finish_reason = True + return [ + self._transformer._build_stream_response( + parts, + finish_reason=current_finish_reason, + usage=self._usage, + ) + ] + + if self._finish_reason and self._tool_calls and not self._emitted_tool_calls: + self._emitted_tool_calls = True + tool_parts = [ + self._transformer._tool_call_part( + name=state.name, + arguments=state.arguments, + call_id=state.call_id, + ) + for _, state in sorted(self._tool_calls.items()) + if state.name + ] + if tool_parts: + if self._finish_reason: + self._emitted_finish_reason = True + return [ + self._transformer._build_stream_response( + tool_parts, + finish_reason=self._finish_reason, + usage=self._usage, + ) + ] + + if current_finish_reason and not self._emitted_finish_reason: + self._emitted_finish_reason = True + return [ + self._transformer._build_stream_response( + [], + finish_reason=current_finish_reason, + usage=self._usage, + ) + ] + + return [] + + def finalize(self) -> list[dict[str, Any]]: + if self._tool_calls and not self._emitted_tool_calls: + self._emitted_tool_calls = True + tool_parts = [ + self._transformer._tool_call_part( + name=state.name, + arguments=state.arguments, + call_id=state.call_id, + ) + for _, state in sorted(self._tool_calls.items()) + if state.name + ] + if tool_parts: + if self._finish_reason: + self._emitted_finish_reason = True + return [ + self._transformer._build_stream_response( + tool_parts, + finish_reason=self._finish_reason, + usage=self._usage, + ) + ] + return [] + + +class GoogleTransformer(BaseTransformer): + """Transform between Google Generative AI and OpenAI API formats.""" + + ROLE_MAP = { + "user": "user", + "model": "assistant", + "system": "system", + "developer": "system", + } + FINISH_REASON_MAP_REVERSE = { + "stop": "STOP", + "length": "MAX_TOKENS", + "content_filter": "SAFETY", + "tool_calls": "STOP", + "stop_sequence": "STOP", + } + + def transform_request(self, body: dict[str, Any]) -> dict[str, Any]: + messages: list[dict[str, Any]] = [] + config = body.get("config") + config_section = config if isinstance(config, dict) else {} + + system_instruction = ( + body.get("systemInstruction") + or body.get("system_instruction") + or config_section.get("systemInstruction") + or config_section.get("system_instruction") + ) + system_text = self._extract_system_instruction_text(system_instruction) + if system_text: + messages.append({"role": "system", "content": system_text}) + + for content in body.get("contents", []): + messages.extend(self._convert_content_to_messages(content)) + + result: dict[str, Any] = {"messages": messages} + if "model" in body: + result["model"] = body["model"] + + gen_config: dict[str, Any] = {} + for source in ( + config_section.get("generationConfig"), + body.get("generationConfig"), + ): + if isinstance(source, dict): + gen_config.update(source) + if not gen_config and config_section: + gen_config = config_section + + if "maxOutputTokens" in gen_config: + result["max_tokens"] = gen_config["maxOutputTokens"] + if "temperature" in gen_config: + result["temperature"] = gen_config["temperature"] + if "topP" in gen_config: + result["top_p"] = gen_config["topP"] + if "topK" in gen_config: + result["top_k"] = gen_config["topK"] + if "stopSequences" in gen_config: + result["stop"] = gen_config["stopSequences"] + if "candidateCount" in gen_config: + result["n"] = gen_config["candidateCount"] + if "presencePenalty" in gen_config: + result["presence_penalty"] = gen_config["presencePenalty"] + if "frequencyPenalty" in gen_config: + result["frequency_penalty"] = gen_config["frequencyPenalty"] + if "seed" in gen_config: + result["seed"] = gen_config["seed"] + if "logprobs" in gen_config: + result["top_logprobs"] = gen_config["logprobs"] + + response_format = self._convert_response_format(gen_config) + if response_format is not None: + result["response_format"] = response_format + + # Gemini `thinkingConfig.includeThoughts: true` → enable_thinking. + thinking_cfg = gen_config.get("thinkingConfig") or config_section.get( + "thinkingConfig" + ) + if isinstance(thinking_cfg, dict) and thinking_cfg.get("includeThoughts"): + chat_template_kwargs = dict(result.get("chat_template_kwargs") or {}) + chat_template_kwargs["enable_thinking"] = True + result["chat_template_kwargs"] = chat_template_kwargs + + # SGLang rejects tool_choice without a non-empty tools list; bind + # the pair so one can't be forwarded without the other. + tools = self._convert_tools( + body.get("tools") or config_section.get("tools") or [] + ) + if tools: + result["tools"] = tools + tool_choice = self._convert_tool_choice( + body.get("toolConfig") or config_section.get("toolConfig") or {} + ) + if tool_choice is not None: + result["tool_choice"] = tool_choice + + return self._normalize_request( + result, + body.get("_served_model"), + ) + + def _convert_response_format( + self, gen_config: dict[str, Any] + ) -> dict[str, Any] | None: + response_format_cfg = gen_config.get("responseFormat") + if isinstance(response_format_cfg, dict): + text_format = response_format_cfg.get("text") + if isinstance(text_format, dict): + mime_type = text_format.get("mimeType") + if self._is_json_mime_type(mime_type): + schema = text_format.get("schema") + return self._chat_response_format_from_schema(schema) + + mime_type = gen_config.get("responseMimeType") + if not self._is_json_mime_type(mime_type): + return None + + schema = ( + gen_config.get("responseJsonSchema") + or gen_config.get("_responseJsonSchema") + or gen_config.get("responseSchema") + ) + return self._chat_response_format_from_schema(schema) + + @staticmethod + def _is_json_mime_type(value: Any) -> bool: + if not isinstance(value, str): + return False + return ( + value.lower() == "application/json" or value.upper() == "APPLICATION_JSON" + ) + + def _chat_response_format_from_schema(self, schema: Any) -> dict[str, Any]: + if isinstance(schema, dict) and schema: + return { + "type": "json_schema", + "json_schema": { + "name": "google_response", + "schema": self._normalize_google_schema(schema), + }, + } + return {"type": "json_object"} + + def _normalize_google_schema(self, schema: dict[str, Any]) -> dict[str, Any]: + normalized: dict[str, Any] = {} + for key, value in schema.items(): + if key == "type" and isinstance(value, str): + normalized[key] = value.lower() + elif key == "properties" and isinstance(value, dict): + normalized[key] = { + prop_name: self._normalize_google_schema(prop_schema) + if isinstance(prop_schema, dict) + else prop_schema + for prop_name, prop_schema in value.items() + } + elif key == "items" and isinstance(value, dict): + normalized[key] = self._normalize_google_schema(value) + elif key in {"anyOf", "oneOf", "allOf"} and isinstance(value, list): + normalized[key] = [ + self._normalize_google_schema(item) + if isinstance(item, dict) + else item + for item in value + ] + else: + normalized[key] = value + return normalized + + def transform_response( + self, + response: dict[str, Any], + original_request: dict[str, Any], + ) -> dict[str, Any]: + del original_request + + candidates = [] + for i, choice in enumerate(response.get("choices", [])): + message = choice.get("message", {}) + parts = [] + reasoning = message.get("reasoning_content") + if isinstance(reasoning, str) and reasoning: + parts.append( + { + "thought": True, + "text": reasoning, + "thoughtSignature": make_google_signature(reasoning), + } + ) + content = message.get("content") + if content or isinstance(content, list): + parts.extend(openai_chat_content_to_google_parts(content)) + parts.extend(self._tool_call_parts_from_message(message)) + + finish_reason = choice.get("finish_reason", "stop") + google_finish = self.FINISH_REASON_MAP_REVERSE.get(finish_reason, "STOP") + + candidates.append( + { + "content": {"parts": parts, "role": "model"}, + "finishReason": google_finish, + "index": i, + "safetyRatings": [], + } + ) + + usage = response.get("usage", {}) + usage_metadata = { + "promptTokenCount": usage.get("prompt_tokens", 0), + "candidatesTokenCount": usage.get("completion_tokens", 0), + "totalTokenCount": usage.get("total_tokens", 0), + } + cached_tokens = self._cached_prompt_tokens(usage) + if cached_tokens: + usage_metadata["cachedContentTokenCount"] = cached_tokens + result = { + "candidates": candidates, + "usageMetadata": usage_metadata, + } + function_calls = self._response_function_calls(candidates) + if function_calls: + result["functionCalls"] = function_calls + return result + + def transform_stream_chunk( + self, + chunk: dict[str, Any], + original_request: dict[str, Any], + is_first: bool = False, + ) -> dict[str, Any]: + del original_request, is_first + + candidates = [] + for choice in chunk.get("choices", []): + delta = choice.get("delta", {}) or {} + parts = [] + reasoning_chunk = delta.get("reasoning_content") + if isinstance(reasoning_chunk, str) and reasoning_chunk: + parts.append( + { + "thought": True, + "text": reasoning_chunk, + "thoughtSignature": make_google_signature(reasoning_chunk), + } + ) + content = delta.get("content") + if content: + parts.append({"text": content}) + for tool_call in self._normalize_tool_call_deltas(delta.get("tool_calls")): + function = tool_call.get("function", {}) + parts.append( + self._tool_call_part( + name=str(function.get("name") or ""), + arguments=function.get("arguments", ""), + call_id=str(tool_call.get("id") or ""), + ) + ) + + candidate: dict[str, Any] = { + "content": {"parts": parts, "role": "model"}, + "index": choice.get("index", 0), + } + finish_reason = choice.get("finish_reason") + if finish_reason: + candidate["finishReason"] = self.FINISH_REASON_MAP_REVERSE.get( + finish_reason, "STOP" + ) + candidates.append(candidate) + + result: dict[str, Any] = {"candidates": candidates} + usage = chunk.get("usage") + if usage: + result["usageMetadata"] = { + "promptTokenCount": usage.get("prompt_tokens", 0), + "candidatesTokenCount": usage.get("completion_tokens", 0), + "totalTokenCount": usage.get("total_tokens", 0), + } + function_calls = self._response_function_calls(candidates) + if function_calls: + result["functionCalls"] = function_calls + return result + + def create_stream_state( + self, original_request: dict[str, Any] + ) -> _GoogleStreamState: + del original_request + return _GoogleStreamState(self) + + def is_streaming_request(self, body: dict[str, Any]) -> bool: + """Part of the transformer interface, but NOT how the proxy decides. + + Google signals streaming in the URL (`:streamGenerateContent`, `?alt=sse`) rather than the + body, so `server.wants_stream` inspects the target path and this never sees the information it + would need. It returns False rather than guessing, and the dead `_streaming` body flag it used + to read was removed: nothing set it, and `normalise_for_capture` forces `stream=False` on every + upstream call regardless, so the branch could not have taken effect even if something had. + """ + return False + + def _convert_content_to_messages(self, content: Any) -> list[dict[str, Any]]: + if not isinstance(content, dict): + return [] + + parts = content.get("parts", []) + role = content.get("role", "user") + openai_role = self.ROLE_MAP.get(role, "user") + messages: list[dict[str, Any]] = [] + user_parts: list[dict[str, Any]] = [] + tool_calls: list[dict[str, Any]] = [] + tool_messages: list[dict[str, Any]] = [] + + for part in parts: + if isinstance(part, str): + user_parts.append({"type": "text", "text": part}) + continue + if not isinstance(part, dict): + continue + if "text" in part and isinstance(part["text"], str): + user_parts.append({"type": "text", "text": part["text"]}) + continue + image_part = google_part_to_openai_chat(part) + if image_part: + user_parts.append(image_part) + continue + if "functionCall" in part and isinstance(part["functionCall"], dict): + function_call = part["functionCall"] + tool_calls.append( + { + "id": function_call.get("id") + or function_call.get("call_id") + or "", + "type": "function", + "function": { + "name": function_call.get("name", ""), + "arguments": json.dumps(function_call.get("args", {})), + }, + } + ) + continue + if "functionResponse" in part and isinstance( + part["functionResponse"], dict + ): + function_response = part["functionResponse"] + tool_messages.append( + { + "role": "tool", + "tool_call_id": function_response.get("id") + or function_response.get("call_id") + or function_response.get("name", ""), + "content": json.dumps(function_response.get("response", {})), + } + ) + + if openai_role == "assistant": + message_content = google_content_parts_to_openai_chat(parts) + reasoning_text = extract_reasoning_from_gemini_parts(parts) + if message_content or tool_calls or reasoning_text: + assistant_message: dict[str, Any] = { + "role": "assistant", + "content": message_content, + } + if reasoning_text: + assistant_message["reasoning_content"] = reasoning_text + if tool_calls: + assistant_message["tool_calls"] = tool_calls + messages.append(assistant_message) + elif openai_role == "system": + system_text = self._extract_text_from_parts(parts) + if system_text: + messages.append({"role": "system", "content": system_text}) + else: + if user_parts: + messages.append( + { + "role": "user", + "content": google_content_parts_to_openai_chat(parts), + } + ) + messages.extend(tool_messages) + + return messages + + def _convert_tools(self, google_tools: list[Any]) -> list[dict[str, Any]]: + openai_tools: list[dict[str, Any]] = [] + for tool in google_tools: + if not isinstance(tool, dict): + continue + declarations = tool.get("functionDeclarations") or tool.get( + "function_declarations" + ) + if not isinstance(declarations, list): + continue + for declaration in declarations: + if not isinstance(declaration, dict): + continue + name = declaration.get("name") + if not isinstance(name, str) or not name: + continue + function: dict[str, Any] = {"name": name} + description = declaration.get("description") + if isinstance(description, str) and description: + function["description"] = description + parameters = ( + declaration.get("parameters") + or declaration.get("parametersJsonSchema") + or declaration.get("parameters_json_schema") + ) + if not isinstance(parameters, dict): + parameters = {"type": "object", "properties": {}} + function["parameters"] = parameters + openai_tools.append({"type": "function", "function": function}) + return openai_tools + + def _convert_tool_choice(self, tool_config: Any) -> Any | None: + if not isinstance(tool_config, dict): + return None + function_calling_config = tool_config.get( + "functionCallingConfig" + ) or tool_config.get("function_calling_config") + if not isinstance(function_calling_config, dict): + return None + + mode = str(function_calling_config.get("mode", "")).upper() + allowed_names = function_calling_config.get( + "allowedFunctionNames" + ) or function_calling_config.get("allowed_function_names") + if mode == "NONE": + return "none" + if mode in {"ANY", "VALIDATED"}: + if isinstance(allowed_names, list) and len(allowed_names) == 1: + allowed_name = allowed_names[0] + if isinstance(allowed_name, str) and allowed_name: + return {"type": "function", "function": {"name": allowed_name}} + return "required" + return None + + def _tool_call_parts_from_message( + self, message: dict[str, Any] + ) -> list[dict[str, Any]]: + parts: list[dict[str, Any]] = [] + for tool_call in message.get("tool_calls", []) or []: + if not isinstance(tool_call, dict): + continue + function = tool_call.get("function", {}) + if not isinstance(function, dict): + continue + name = function.get("name") + if not isinstance(name, str) or not name: + continue + parts.append( + self._tool_call_part( + name=name, + arguments=function.get("arguments", ""), + call_id=str(tool_call.get("id") or ""), + ) + ) + return parts + + def _tool_call_part( + self, *, name: str, arguments: Any, call_id: str + ) -> dict[str, Any]: + function_call: dict[str, Any] = { + "name": name, + "args": self._parse_arguments(arguments), + } + if call_id: + function_call["id"] = call_id + return {"functionCall": function_call} + + def _parse_arguments(self, arguments: Any) -> Any: + if isinstance(arguments, (dict, list, int, float, bool)) or arguments is None: + return arguments if arguments is not None else {} + if not isinstance(arguments, str): + return {"value": arguments} + stripped = arguments.strip() + if not stripped: + return {} + try: + return json.loads(stripped) + except json.JSONDecodeError: + return {"raw": stripped} + + def _normalize_tool_call_deltas(self, tool_calls: Any) -> list[dict[str, Any]]: + if isinstance(tool_calls, list): + return [ + tool_call for tool_call in tool_calls if isinstance(tool_call, dict) + ] + if isinstance(tool_calls, dict): + return [tool_calls] + return [] + + def _build_stream_response( + self, + parts: list[dict[str, Any]], + *, + finish_reason: str | None = None, + usage: dict[str, Any] | None = None, + ) -> dict[str, Any]: + candidate: dict[str, Any] = { + "content": {"parts": parts, "role": "model"}, + "index": 0, + } + if finish_reason: + candidate["finishReason"] = self.FINISH_REASON_MAP_REVERSE.get( + finish_reason, "STOP" + ) + result: dict[str, Any] = {"candidates": [candidate]} + if usage: + result["usageMetadata"] = { + "promptTokenCount": usage.get("prompt_tokens", 0), + "candidatesTokenCount": usage.get("completion_tokens", 0), + "totalTokenCount": usage.get("total_tokens", 0), + } + function_calls = self._response_function_calls(result["candidates"]) + if function_calls: + result["functionCalls"] = function_calls + return result + + def _response_function_calls( + self, candidates: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + function_calls: list[dict[str, Any]] = [] + for candidate in candidates: + content = candidate.get("content", {}) + if not isinstance(content, dict): + continue + for part in content.get("parts", []) or []: + if not isinstance(part, dict): + continue + function_call = part.get("functionCall") + if isinstance(function_call, dict): + function_calls.append(function_call) + return function_calls + + def _extract_text_from_parts(self, parts: list) -> str: + texts = [] + for part in parts: + if isinstance(part, dict) and "text" in part: + texts.append(part["text"]) + elif isinstance(part, str): + texts.append(part) + return "\n".join(texts) + + def _extract_system_instruction_text(self, system_instruction: Any) -> str: + if isinstance(system_instruction, str): + return system_instruction + if isinstance(system_instruction, dict): + return self._extract_text_from_parts(system_instruction.get("parts", [])) + if isinstance(system_instruction, list): + return self._extract_text_from_parts(system_instruction) + return "" + + def _cached_prompt_tokens(self, usage: dict[str, Any]) -> int: + details = usage.get("prompt_tokens_details") + if isinstance(details, dict): + cached = details.get("cached_tokens") + if isinstance(cached, int): + return cached + cached = usage.get("cached_tokens") + return cached if isinstance(cached, int) else 0 diff --git a/src/openenv/core/harness/capture/dialects/images.py b/src/openenv/core/harness/capture/dialects/images.py new file mode 100644 index 0000000000..16fdeb1e54 --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/images.py @@ -0,0 +1,335 @@ +"""Image content conversion helpers for gateway API transformers.""" + +from __future__ import annotations + +import re +from typing import Any + +_DATA_URL_RE = re.compile( + r"^data:(?P[^;,]+);base64,(?P.*)$", re.DOTALL +) + + +def is_image_mime_type(mime_type: Any) -> bool: + return isinstance(mime_type, str) and mime_type.lower().startswith("image/") + + +def make_data_url(mime_type: str, data: str) -> str: + if data.startswith("data:"): + return data + return f"data:{mime_type};base64,{data}" + + +def parse_data_url(url: str) -> tuple[str, str] | None: + match = _DATA_URL_RE.match(url) + if not match: + return None + mime_type = match.group("mime_type") + if not is_image_mime_type(mime_type): + return None + return mime_type, match.group("data") + + +# OpenAI's image_url.detail only accepts these values; vLLM rejects anything +# else. Harnesses send their own (e.g. codex's "original"), so drop unknowns +# rather than forward a value that 400s the whole image request. +_VALID_IMAGE_DETAILS = frozenset({"auto", "low", "high"}) + + +def openai_image_url_block(url: str, *, detail: Any = None) -> dict[str, Any]: + image_url: dict[str, Any] = {"url": url} + if isinstance(detail, str) and detail in _VALID_IMAGE_DETAILS: + image_url["detail"] = detail + return {"type": "image_url", "image_url": image_url} + + +def openai_text_block(text: str) -> dict[str, Any]: + return {"type": "text", "text": text} + + +def openai_image_url(block: dict[str, Any]) -> str | None: + image_url = block.get("image_url") + if isinstance(image_url, str) and image_url: + return image_url + if isinstance(image_url, dict): + url = image_url.get("url") + if isinstance(url, str) and url: + return url + return None + + +def openai_image_detail(block: dict[str, Any]) -> str | None: + image_url = block.get("image_url") + if isinstance(image_url, dict): + detail = image_url.get("detail") + if isinstance(detail, str) and detail: + return detail + detail = block.get("detail") + if isinstance(detail, str) and detail: + return detail + return None + + +def openai_content_from_text_and_images( + parts: list[dict[str, Any]], + *, + text_separator: str = "\n", +) -> str | list[dict[str, Any]]: + has_image = any(part.get("type") == "image_url" for part in parts) + if has_image: + return parts + return text_separator.join( + part.get("text", "") + for part in parts + if part.get("type") == "text" and isinstance(part.get("text"), str) + ) + + +def openai_responses_input_content_to_chat(content: Any) -> str | list[dict[str, Any]]: + if isinstance(content, str): + return content + if not isinstance(content, list): + return str(content) if content else "" + + parts: list[dict[str, Any]] = [] + for block in content: + if isinstance(block, str): + parts.append(openai_text_block(block)) + continue + if not isinstance(block, dict): + continue + + block_type = block.get("type") + if block_type in ("input_text", "output_text", "text"): + text = block.get("text") + if isinstance(text, str): + parts.append(openai_text_block(text)) + continue + if block_type in ("input_image", "image_url"): + url = _responses_image_url(block) + if url: + parts.append(openai_image_url_block(url, detail=block.get("detail"))) + + return openai_content_from_text_and_images(parts) + + +def _responses_image_url(block: dict[str, Any]) -> str | None: + image_url = block.get("image_url") + if isinstance(image_url, str) and image_url: + return image_url + if isinstance(image_url, dict): + url = image_url.get("url") + if isinstance(url, str) and url: + return url + return None + + +def anthropic_content_to_openai_chat(content: Any) -> str | list[dict[str, Any]]: + if isinstance(content, str): + return content + if not isinstance(content, list): + return str(content) if content else "" + + parts: list[dict[str, Any]] = [] + for block in content: + if isinstance(block, str): + parts.append(openai_text_block(block)) + continue + if not isinstance(block, dict): + continue + + block_type = block.get("type") + if block_type == "text": + text = block.get("text") + if isinstance(text, str): + parts.append(openai_text_block(text)) + continue + if block_type == "image": + image = anthropic_image_to_openai_chat(block) + if image: + parts.append(image) + continue + if block_type == "document": + text = anthropic_document_to_text(block) + if text: + parts.append(openai_text_block(text)) + + return openai_content_from_text_and_images(parts) + + +def anthropic_document_to_text(block: dict[str, Any]) -> str: + """Extract text from an Anthropic `document` block. + + Handles `source.type == "text"` and `source.type == "content"`. Base64 + PDFs are dropped — SGLang can't render binary docs through the chat + template. + """ + source = block.get("source") + if not isinstance(source, dict): + return "" + source_type = source.get("type") + if source_type == "text": + data = source.get("data") + return data if isinstance(data, str) else "" + if source_type == "content": + inner = source.get("content") + if isinstance(inner, list): + pieces: list[str] = [] + for inner_block in inner: + if isinstance(inner_block, dict) and inner_block.get("type") == "text": + text = inner_block.get("text") + if isinstance(text, str): + pieces.append(text) + return "\n".join(pieces) + return "" + + +def anthropic_image_to_openai_chat(block: dict[str, Any]) -> dict[str, Any] | None: + source = block.get("source") + if not isinstance(source, dict): + return None + + source_type = source.get("type") + if source_type == "base64": + mime_type = source.get("media_type") or source.get("mediaType") + data = source.get("data") + if is_image_mime_type(mime_type) and isinstance(data, str) and data: + return openai_image_url_block(make_data_url(mime_type, data)) + if source_type == "url": + url = source.get("url") + if isinstance(url, str) and url: + return openai_image_url_block(url) + return None + + +def google_content_parts_to_openai_chat(parts: Any) -> str | list[dict[str, Any]]: + if not isinstance(parts, list): + return "" + + openai_parts: list[dict[str, Any]] = [] + for part in parts: + if isinstance(part, str): + openai_parts.append(openai_text_block(part)) + continue + if not isinstance(part, dict): + continue + + # Thought parts are reasoning_content, not user-visible content. + if part.get("thought") is True: + continue + + text = part.get("text") + if isinstance(text, str): + openai_parts.append(openai_text_block(text)) + continue + + image = google_part_to_openai_chat(part) + if image: + openai_parts.append(image) + + return openai_content_from_text_and_images(openai_parts) + + +def google_part_to_openai_chat(part: dict[str, Any]) -> dict[str, Any] | None: + inline_data = part.get("inline_data") or part.get("inlineData") + if isinstance(inline_data, dict): + mime_type = inline_data.get("mime_type") or inline_data.get("mimeType") + data = inline_data.get("data") + if is_image_mime_type(mime_type) and isinstance(data, str) and data: + return openai_image_url_block(make_data_url(mime_type, data)) + + file_data = part.get("file_data") or part.get("fileData") + if isinstance(file_data, dict): + mime_type = file_data.get("mime_type") or file_data.get("mimeType") + uri = file_data.get("file_uri") or file_data.get("fileUri") + if is_image_mime_type(mime_type) and isinstance(uri, str) and uri: + return openai_image_url_block(uri) + + return None + + +def openai_chat_content_to_anthropic_blocks(content: Any) -> list[dict[str, Any]]: + if isinstance(content, str): + return [{"type": "text", "text": content}] + if not isinstance(content, list): + return [{"type": "text", "text": str(content) if content else ""}] + + blocks: list[dict[str, Any]] = [] + for part in content: + if isinstance(part, str): + blocks.append({"type": "text", "text": part}) + continue + if not isinstance(part, dict): + continue + part_type = part.get("type") + if part_type == "text": + text = part.get("text") + if isinstance(text, str): + blocks.append({"type": "text", "text": text}) + continue + if part_type == "image_url": + image = openai_chat_image_to_anthropic(part) + if image: + blocks.append(image) + + return blocks or [{"type": "text", "text": ""}] + + +def openai_chat_image_to_anthropic(part: dict[str, Any]) -> dict[str, Any] | None: + url = openai_image_url(part) + if not url: + return None + + parsed = parse_data_url(url) + if parsed: + mime_type, data = parsed + return { + "type": "image", + "source": { + "type": "base64", + "media_type": mime_type, + "data": data, + }, + } + + return {"type": "image", "source": {"type": "url", "url": url}} + + +def openai_chat_content_to_google_parts(content: Any) -> list[dict[str, Any]]: + if isinstance(content, str): + return [{"text": content}] + if not isinstance(content, list): + return [{"text": str(content) if content else ""}] + + parts: list[dict[str, Any]] = [] + for part in content: + if isinstance(part, str): + parts.append({"text": part}) + continue + if not isinstance(part, dict): + continue + part_type = part.get("type") + if part_type == "text": + text = part.get("text") + if isinstance(text, str): + parts.append({"text": text}) + continue + if part_type == "image_url": + image = openai_chat_image_to_google(part) + if image: + parts.append(image) + + return parts or [{"text": ""}] + + +def openai_chat_image_to_google(part: dict[str, Any]) -> dict[str, Any] | None: + url = openai_image_url(part) + if not url: + return None + + parsed = parse_data_url(url) + if parsed: + mime_type, data = parsed + return {"inline_data": {"mime_type": mime_type, "data": data}} + + return {"file_data": {"mime_type": "image/jpeg", "file_uri": url}} diff --git a/src/openenv/core/harness/capture/dialects/openai_chat.py b/src/openenv/core/harness/capture/dialects/openai_chat.py new file mode 100644 index 0000000000..af9297b243 --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/openai_chat.py @@ -0,0 +1,41 @@ +"""OpenAI Chat Completions transformer with SGLang training enhancements.""" + +from __future__ import annotations + +from typing import Any + +from .base import BaseTransformer + + +class OpenAIChatTransformer(BaseTransformer): + """Transform OpenAI Chat requests (passthrough + training params).""" + + def transform_request(self, body: dict[str, Any]) -> dict[str, Any]: + result = body.copy() + if "max_tokens" not in result and "max_completion_tokens" in result: + result["max_tokens"] = result["max_completion_tokens"] + return self._normalize_request( + result, + body.get("_served_model"), + ) + + def transform_response( + self, + response: dict[str, Any], + original_request: dict[str, Any], + ) -> dict[str, Any]: + result = response.copy() + if "model" in original_request: + result["model"] = original_request["model"] + return result + + def transform_stream_chunk( + self, + chunk: dict[str, Any], + original_request: dict[str, Any], + is_first: bool = False, + ) -> dict[str, Any]: + result = chunk.copy() + if "model" in original_request: + result["model"] = original_request["model"] + return result diff --git a/src/openenv/core/harness/capture/dialects/openai_responses.py b/src/openenv/core/harness/capture/dialects/openai_responses.py new file mode 100644 index 0000000000..03d4d1179a --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/openai_responses.py @@ -0,0 +1,1125 @@ +"""OpenAI Responses API transformer. + +Transforms between OpenAI Responses API (Codex CLI) and OpenAI Chat Completions. +Aligned with agent-harness-proxy/src/harness_proxy/transform/openai_responses.py. +""" + +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass +from typing import Any, Optional + +from ..upstream import UpstreamRequestError +from .base import BaseTransformer +from .images import openai_responses_input_content_to_chat +from .reasoning import encrypt_reasoning, extract_reasoning_from_responses_item + + +@dataclass +class _ResponsesToolCallState: + name: str = "" + call_id: str = "" + arguments: str = "" + started: bool = False + fc_id: str = "" + + +class ResponsesStreamState: + """Per-request Responses API streaming state.""" + + def __init__(self, model: str): + self.response_id = f"resp_{uuid.uuid4().hex[:24]}" + self.model = model + self.text_started = False + self.text_content = "" + self.message_output_index = 0 + self.output_index_offset = 0 + self.tool_calls: dict[int, _ResponsesToolCallState] = {} + self.usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + self.reasoning_started = False + self.reasoning_closed = False + self.reasoning_content = "" + self.reasoning_id = "" + self.completed = False + + def process_chunk( + self, chunk: dict[str, Any], is_first: bool = False + ) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + + if is_first: + events.append( + { + "type": "response.created", + "response": { + "id": self.response_id, + "object": "response", + "status": "in_progress", + "model": self.model, + "output": [], + "usage": self.usage.copy(), + }, + } + ) + + usage = chunk.get("usage") + if isinstance(usage, dict): + self.usage["input_tokens"] = usage.get( + "prompt_tokens", self.usage["input_tokens"] + ) + self.usage["output_tokens"] = usage.get( + "completion_tokens", self.usage["output_tokens"] + ) + self.usage["total_tokens"] = usage.get( + "total_tokens", self.usage["total_tokens"] + ) + + choices = chunk.get("choices", []) + if not choices: + return events + + choice = choices[0] + delta = choice.get("delta", {}) or {} + + # Reasoning item must come first so the harness sees the chain-of-thought + # before any output_text or function_call items. + reasoning_delta = delta.get("reasoning_content") + if isinstance(reasoning_delta, str) and reasoning_delta: + if not self.reasoning_started: + self.reasoning_started = True + self.reasoning_id = f"rs_{uuid.uuid4().hex[:24]}" + events.append( + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "reasoning", + "id": self.reasoning_id, + "summary": [], + "content": [], + "status": "in_progress", + }, + } + ) + events.append( + { + "type": "response.reasoning_summary_part.added", + "item_id": self.reasoning_id, + "output_index": 0, + "summary_index": 0, + "part": {"type": "summary_text", "text": ""}, + } + ) + self.reasoning_content += reasoning_delta + events.append( + { + "type": "response.reasoning_summary_text.delta", + "item_id": self.reasoning_id, + "output_index": 0, + "summary_index": 0, + "delta": reasoning_delta, + } + ) + + content = delta.get("content") + if content: + # Close reasoning before opening message. + events.extend(self._close_reasoning()) + if not self.text_started: + self.text_started = True + self.message_output_index = 1 if self.reasoning_started else 0 + self.output_index_offset = self.message_output_index + 1 + message_id = f"msg_{uuid.uuid4().hex[:24]}" + events.append( + { + "type": "response.output_item.added", + "output_index": self.message_output_index, + "item": { + "type": "message", + "id": message_id, + "role": "assistant", + "status": "in_progress", + "content": [], + }, + } + ) + events.append( + { + "type": "response.content_part.added", + "output_index": self.message_output_index, + "content_index": 0, + "part": {"type": "output_text", "text": ""}, + } + ) + + self.text_content += content + events.append( + { + "type": "response.output_text.delta", + "output_index": self.message_output_index, + "content_index": 0, + "delta": content, + } + ) + + tool_calls_delta = delta.get("tool_calls") or [] + if not isinstance(tool_calls_delta, list): + tool_calls_delta = [tool_calls_delta] + + if tool_calls_delta and self.reasoning_started and not self.reasoning_closed: + events.extend(self._close_reasoning()) + if not self.text_started: + # No text — tools come immediately after reasoning. + self.output_index_offset = 1 + + for tool_call in tool_calls_delta: + if not isinstance(tool_call, dict): + continue + + tool_index = tool_call.get("index", 0) + if not isinstance(tool_index, int): + tool_index = 0 + + tool_state = self.tool_calls.get(tool_index) + if tool_state is None: + tool_state = _ResponsesToolCallState( + # `or`, not a get() default: a tool call whose "id" is present but null returns None from + # get(), which defeats the fallback and emits an item with no call_id. + call_id=tool_call.get("id") or f"call_{uuid.uuid4().hex[:24]}", + ) + self.tool_calls[tool_index] = tool_state + elif tool_call.get("id"): + tool_state.call_id = tool_call["id"] + + function = tool_call.get("function", {}) + name = function.get("name") + if isinstance(name, str) and name: + tool_state.name += name + + arguments = function.get("arguments") + arguments_str = "" + if isinstance(arguments, str) and arguments: + arguments_str = arguments + elif arguments not in (None, ""): + arguments_str = json.dumps(arguments) + if arguments_str: + tool_state.arguments += arguments_str + + output_index = self.output_index_offset + tool_index + if tool_state.name and not tool_state.started: + tool_state.started = True + tool_state.fc_id = f"fc_{uuid.uuid4().hex[:24]}" + events.append( + { + "type": "response.output_item.added", + "output_index": output_index, + "item": { + "type": "function_call", + "id": tool_state.fc_id, + "call_id": tool_state.call_id, + "name": tool_state.name, + "arguments": "", + "status": "in_progress", + }, + } + ) + + if tool_state.arguments: + events.append( + { + "type": "response.function_call_arguments.delta", + "output_index": output_index, + "delta": tool_state.arguments, + } + ) + elif tool_state.started and arguments_str: + events.append( + { + "type": "response.function_call_arguments.delta", + "output_index": output_index, + "delta": arguments_str, + } + ) + + return events + + def finalize(self) -> list[dict[str, Any]]: + if self.completed: + return [] + + events: list[dict[str, Any]] = [] + + # Close reasoning if it never got closed by content/tools. + events.extend(self._close_reasoning()) + + if self.text_started: + events.append( + { + "type": "response.content_part.done", + "output_index": self.message_output_index, + "content_index": 0, + "part": {"type": "output_text", "text": self.text_content}, + } + ) + events.append( + { + "type": "response.output_item.done", + "output_index": self.message_output_index, + "item": { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": self.text_content}], + }, + } + ) + + for tool_index in sorted(self.tool_calls): + tool_state = self.tool_calls[tool_index] + if not tool_state.started: + continue + + output_index = self.output_index_offset + tool_index + events.append( + { + "type": "response.function_call_arguments.done", + "output_index": output_index, + "arguments": tool_state.arguments, + } + ) + events.append( + { + "type": "response.output_item.done", + "output_index": output_index, + "item": { + "type": "function_call", + "id": tool_state.fc_id or f"fc_{uuid.uuid4().hex[:24]}", + "call_id": tool_state.call_id, + "name": tool_state.name, + "arguments": tool_state.arguments, + "status": "completed", + }, + } + ) + + output: list[dict[str, Any]] = [] + if self.reasoning_started: + output.append( + { + "type": "reasoning", + "id": self.reasoning_id, + "summary": [ + {"type": "summary_text", "text": self.reasoning_content} + ], + "content": [ + {"type": "reasoning_text", "text": self.reasoning_content} + ], + "encrypted_content": encrypt_reasoning(self.reasoning_content), + "status": "completed", + } + ) + if self.text_started: + output.append( + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": self.text_content}], + } + ) + for tool_index in sorted(self.tool_calls): + tool_state = self.tool_calls[tool_index] + if tool_state.started: + output.append( + { + "type": "function_call", + "id": tool_state.fc_id or f"fc_{uuid.uuid4().hex[:24]}", + "call_id": tool_state.call_id, + "name": tool_state.name, + "arguments": tool_state.arguments, + "status": "completed", + } + ) + + events.append( + { + "type": "response.completed", + "response": { + "id": self.response_id, + "object": "response", + "created_at": int(time.time()), + "status": "completed", + "model": self.model, + "output": output, + "usage": self.usage.copy(), + }, + } + ) + self.completed = True + return events + + def _close_reasoning(self) -> list[dict[str, Any]]: + if not self.reasoning_started or self.reasoning_closed: + return [] + self.reasoning_closed = True + return [ + { + "type": "response.reasoning_summary_text.done", + "item_id": self.reasoning_id, + "output_index": 0, + "summary_index": 0, + "text": self.reasoning_content, + }, + { + "type": "response.reasoning_summary_part.done", + "item_id": self.reasoning_id, + "output_index": 0, + "summary_index": 0, + "part": {"type": "summary_text", "text": self.reasoning_content}, + }, + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "reasoning", + "id": self.reasoning_id, + "summary": [ + {"type": "summary_text", "text": self.reasoning_content} + ], + "content": [ + {"type": "reasoning_text", "text": self.reasoning_content} + ], + "encrypted_content": encrypt_reasoning(self.reasoning_content), + "status": "completed", + }, + }, + ] + + +class OpenAIResponsesTransformer(BaseTransformer): + """Transform OpenAI Responses API to/from SGLang chat completions.""" + + def transform_request(self, body: dict[str, Any]) -> dict[str, Any]: + messages: list[dict[str, Any]] = [] + + instructions = body.get("instructions") + if instructions: + messages.append({"role": "system", "content": instructions}) + + input_data = body.get("input", "") + if isinstance(input_data, str): + messages.append({"role": "user", "content": input_data}) + elif isinstance(input_data, list): + if any(not isinstance(item, dict) for item in input_data): + raise UpstreamRequestError("Responses input items must be objects") + messages.extend(self._convert_input_items_to_messages(input_data)) + else: + raise UpstreamRequestError( + "Responses input must be a string or an array of objects" + ) + + result: dict[str, Any] = {"messages": messages} + if "model" in body: + result["model"] = body["model"] + + if "max_tokens" in body: + result["max_tokens"] = body["max_tokens"] + if "max_output_tokens" in body: + result["max_tokens"] = body["max_output_tokens"] + if "temperature" in body: + result["temperature"] = body["temperature"] + if "top_p" in body: + result["top_p"] = body["top_p"] + if "top_logprobs" in body: + result["top_logprobs"] = body["top_logprobs"] + if "parallel_tool_calls" in body: + result["parallel_tool_calls"] = body["parallel_tool_calls"] + if "stream" in body: + result["stream"] = body["stream"] + + text_cfg = body.get("text") + if isinstance(text_cfg, dict): + response_format = self._response_format_from_text_config(text_cfg) + if response_format is not None: + result["response_format"] = response_format + + # Responses `reasoning` request param → enable_thinking. + reasoning_cfg = body.get("reasoning") + if isinstance(reasoning_cfg, dict) and self._reasoning_config_enables_thinking( + reasoning_cfg + ): + chat_template_kwargs = dict(result.get("chat_template_kwargs") or {}) + chat_template_kwargs["enable_thinking"] = True + result["chat_template_kwargs"] = chat_template_kwargs + + # SGLang rejects tool_choice without a non-empty tools list; bind + # the pair so one can't be forwarded without the other. + declared_tools = body.get("tools", []) + if not isinstance(declared_tools, list): + raise UpstreamRequestError("Responses tools must be an array") + if any(not isinstance(tool, dict) for tool in declared_tools): + raise UpstreamRequestError("Responses tools must be objects") + tools = self._convert_tools(declared_tools) + if tools: + result["tools"] = tools + if "tool_choice" in body: + result["tool_choice"] = self._tool_choice_to_openai_chat( + body["tool_choice"] + ) + + return self._normalize_request( + result, + body.get("_served_model"), + ) + + def _response_format_from_text_config( + self, + text_cfg: dict[str, Any], + ) -> dict[str, Any] | None: + format_cfg = text_cfg.get("format") + if not isinstance(format_cfg, dict): + return None + + format_type = format_cfg.get("type") + if format_type == "text": + return None + if format_type == "json_object": + return {"type": "json_object"} + if format_type != "json_schema": + return None + + json_schema = format_cfg.get("json_schema") + if isinstance(json_schema, dict): + return {"type": "json_schema", "json_schema": json_schema} + + converted = { + key: format_cfg[key] + for key in ("name", "description", "schema", "strict") + if key in format_cfg + } + if not converted: + return None + return {"type": "json_schema", "json_schema": converted} + + def transform_response( + self, + response: dict[str, Any], + original_request: dict[str, Any], + ) -> dict[str, Any]: + choices = response.get("choices", []) + if not choices: + return self._make_error_response("No choices in response") + + choice = choices[0] + message = choice.get("message", {}) + + output_items: list[dict[str, Any]] = [] + + reasoning = message.get("reasoning_content") + if isinstance(reasoning, str) and reasoning: + output_items.append( + { + "type": "reasoning", + "id": f"rs_{uuid.uuid4().hex[:24]}", + "summary": [{"type": "summary_text", "text": reasoning}], + "content": [{"type": "reasoning_text", "text": reasoning}], + "encrypted_content": encrypt_reasoning(reasoning), + "status": "completed", + } + ) + + content = message.get("content") + if content: + output_items.append( + { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": content}], + } + ) + + # Only replay a call as `local_shell_call` when the REQUEST actually declared a local_shell + # tool. The name alone is not evidence: a user-defined function called `execute` or + # `run_command` was being rewritten to a shell call, which diverged the replay from what was + # sampled — turn k recorded `name="execute"`, turn k+1 replayed `name="shell"`, the exact + # token prefix no longer matched, and the turn was orphaned into a new root. codex would also + # have run the caller's own function as a shell command. + shell_declared = self._declares_local_shell(original_request) + for tc in message.get("tool_calls") or []: + func = tc.get("function", {}) + name = func.get("name", "") + if shell_declared and name in ("shell", "execute", "run_command"): + output_items.append(self._local_shell_call_from_tool_call(tc)) + else: + output_items.append( + { + "type": "function_call", + "id": f"fc_{uuid.uuid4().hex[:24]}", + "call_id": tc.get("id", ""), + "name": name, + "arguments": func.get("arguments", "{}"), + "status": "completed", + } + ) + + usage = response.get("usage", {}) + response_usage = { + "input_tokens": usage.get("prompt_tokens", 0), + "output_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + } + cached_tokens = self._cached_prompt_tokens(usage) + if cached_tokens: + response_usage["input_tokens_details"] = {"cached_tokens": cached_tokens} + return { + "id": response.get("id", f"resp_{uuid.uuid4().hex}"), + "object": "response", + "created_at": response.get("created", int(time.time())), + "status": "completed", + "model": original_request.get("model", response.get("model", "unknown")), + "output": output_items, + "usage": response_usage, + } + + def create_stream_state( + self, original_request: dict[str, Any] + ) -> ResponsesStreamState: + return ResponsesStreamState( + model=original_request.get("model", "unknown"), + ) + + def transform_stream_chunk( + self, + chunk: dict[str, Any], + original_request: dict[str, Any], + is_first: bool = False, + ) -> list[dict[str, Any]]: + """Best-effort single-chunk Responses transform.""" + state = self.create_stream_state(original_request) + events = state.process_chunk(chunk, is_first=is_first) + choices = chunk.get("choices", []) + if choices and choices[0].get("finish_reason"): + events.extend(state.finalize()) + return events + + def _convert_input_items_to_messages( + self, + items: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + pending_tool_calls: list[dict[str, Any]] = [] + pending_tool_outputs: list[dict[str, Any]] = [] + pending_input_content: list[dict[str, Any]] = [] + pending_reasoning: str = "" + + for item in items: + item_type = item.get("type") + + if item_type == "reasoning": + # A new reasoning item starts a new turn block. If the prior + # block already has its function_call_output, flush it now so + # this reasoning attaches to the NEXT function_call, not the + # previous one. (Otherwise codex's per-fc reasoning gets + # accumulated and dumped onto the wrong assistant message, + # breaking the prefix_merging chain.) + if pending_tool_outputs: + messages.extend( + self._flush_tool_block( + pending_tool_calls, + pending_tool_outputs, + pending_reasoning, + ) + ) + pending_tool_calls = [] + pending_tool_outputs = [] + pending_reasoning = "" + reasoning_text = extract_reasoning_from_responses_item(item) + if reasoning_text: + pending_reasoning = ( + f"{pending_reasoning}\n{reasoning_text}" + if pending_reasoning + else reasoning_text + ) + continue + + if item_type in {"input_text", "input_image"}: + if pending_tool_calls or pending_tool_outputs: + messages.extend( + self._flush_tool_block( + pending_tool_calls, pending_tool_outputs, pending_reasoning + ) + ) + pending_tool_calls = [] + pending_tool_outputs = [] + pending_reasoning = "" + pending_input_content.append(item) + continue + + if item_type == "message": + if pending_input_content: + messages.extend(self._flush_input_content(pending_input_content)) + pending_input_content = [] + if pending_tool_calls or pending_tool_outputs: + messages.extend( + self._flush_tool_block( + pending_tool_calls, pending_tool_outputs, pending_reasoning + ) + ) + pending_tool_calls = [] + pending_tool_outputs = [] + pending_reasoning = "" + + role = item.get("role", "user") + content = openai_responses_input_content_to_chat( + item.get("content", "") + ) + msg: dict[str, Any] = {"role": role, "content": content} + if role == "assistant" and pending_reasoning: + msg["reasoning_content"] = pending_reasoning + pending_reasoning = "" + messages.append(msg) + + elif item_type == "function_call": + if pending_input_content: + messages.extend(self._flush_input_content(pending_input_content)) + pending_input_content = [] + if pending_tool_outputs: + messages.extend( + self._flush_tool_block( + pending_tool_calls, + pending_tool_outputs, + pending_reasoning, + ) + ) + pending_tool_calls = [] + pending_tool_outputs = [] + pending_reasoning = "" + pending_tool_calls.append( + { + "id": item.get("call_id", f"call_{uuid.uuid4().hex[:24]}"), + "type": "function", + "function": { + "name": item.get("name", ""), + "arguments": item.get("arguments", "{}"), + }, + } + ) + + elif item_type in {"local_shell_call", "shell_call"}: + if pending_input_content: + messages.extend(self._flush_input_content(pending_input_content)) + pending_input_content = [] + if pending_tool_outputs: + messages.extend( + self._flush_tool_block( + pending_tool_calls, + pending_tool_outputs, + pending_reasoning, + ) + ) + pending_tool_calls = [] + pending_tool_outputs = [] + pending_reasoning = "" + pending_tool_calls.append(self._local_shell_call_to_tool_call(item)) + + elif item_type == "function_call_output": + if pending_input_content: + messages.extend(self._flush_input_content(pending_input_content)) + pending_input_content = [] + pending_tool_outputs.extend(self._function_call_output_messages(item)) + + elif item_type in {"local_shell_call_output", "shell_call_output"}: + if pending_input_content: + messages.extend(self._flush_input_content(pending_input_content)) + pending_input_content = [] + pending_tool_outputs.extend(self._local_shell_output_messages(item)) + + else: + if pending_input_content: + messages.extend(self._flush_input_content(pending_input_content)) + pending_input_content = [] + if pending_tool_calls or pending_tool_outputs: + messages.extend( + self._flush_tool_block( + pending_tool_calls, + pending_tool_outputs, + pending_reasoning, + ) + ) + pending_tool_calls = [] + pending_tool_outputs = [] + pending_reasoning = "" + converted = self._convert_response_item_to_message(item) + if isinstance(converted, list): + messages.extend(converted) + elif converted: + messages.append(converted) + + if pending_input_content: + messages.extend(self._flush_input_content(pending_input_content)) + + if pending_tool_calls or pending_tool_outputs: + messages.extend( + self._flush_tool_block( + pending_tool_calls, pending_tool_outputs, pending_reasoning + ) + ) + pending_reasoning = "" + + # Trailing reasoning with no following assistant message: synthesize one. + if pending_reasoning: + messages.append( + { + "role": "assistant", + "content": None, + "reasoning_content": pending_reasoning, + } + ) + + return messages + + def _function_call_output_messages( + self, item: dict[str, Any] + ) -> list[dict[str, Any]]: + output = self._function_call_output_content(item.get("output", "")) + converted_content = openai_responses_input_content_to_chat(output) + messages = [ + { + "role": "tool", + "tool_call_id": item.get("call_id", ""), + "content": self._flatten_function_call_output(output), + } + ] + + image_parts = self._image_parts(converted_content) + if image_parts: + messages.append({"role": "user", "content": image_parts}) + return messages + + def _local_shell_call_to_tool_call(self, item: dict[str, Any]) -> dict[str, Any]: + return { + "id": item.get("call_id") + or item.get("id") + or f"call_{uuid.uuid4().hex[:24]}", + "type": "function", + "function": { + "name": "shell", + "arguments": self._local_shell_action_to_arguments(item.get("action")), + }, + } + + def _local_shell_output_messages( + self, item: dict[str, Any] + ) -> list[dict[str, Any]]: + call_id = item.get("call_id") or item.get("id") or "" + return self._function_call_output_messages( + {"call_id": call_id, "output": item.get("output", "")} + ) + + def _local_shell_action_to_arguments(self, action: Any) -> str: + if isinstance(action, str): + return action + if not isinstance(action, dict): + return "{}" + + command = action.get("command") + if isinstance(command, str): + stripped = command.strip() + if stripped.startswith(("{", "[")): + try: + json.loads(stripped) + return stripped + except json.JSONDecodeError: + pass + return json.dumps({"cmd": command}) + + commands = action.get("commands") + if isinstance(commands, list): + command_values = [cmd for cmd in commands if isinstance(cmd, str)] + args: dict[str, Any] + if len(command_values) == 1: + args = {"cmd": command_values[0]} + else: + args = {"commands": command_values} + for key in ("timeout_ms", "max_output_length"): + if key in action: + args[key] = action[key] + return json.dumps(args) + + args = {key: value for key, value in action.items() if key != "type"} + return json.dumps(args) if args else "{}" + + @staticmethod + def _declares_local_shell(original_request: dict[str, Any]) -> bool: + """Whether the request offered a local_shell tool, in either spelling. + + codex declares `{"type": "local_shell"}` among its Responses tools. Absent that, a shell-named + function is an ordinary function and has to be replayed as one. + """ + for tool in original_request.get("tools") or []: + if not isinstance(tool, dict): + continue + if tool.get("type") in {"local_shell", "shell"}: + return True + return False + + def _local_shell_call_from_tool_call( + self, tool_call: dict[str, Any] + ) -> dict[str, Any]: + function = tool_call.get("function", {}) + arguments = ( + function.get("arguments", "{}") if isinstance(function, dict) else "{}" + ) + call_id = tool_call.get("id", "") + return { + "type": "local_shell_call", + "id": f"lsh_{uuid.uuid4().hex[:24]}", + "call_id": call_id, + "status": "completed", + "action": self._local_shell_action_from_arguments(arguments), + } + + def _local_shell_action_from_arguments(self, arguments: Any) -> dict[str, Any]: + parsed: Any = None + if isinstance(arguments, str): + try: + parsed = json.loads(arguments) + except json.JSONDecodeError: + parsed = None + elif isinstance(arguments, dict): + parsed = arguments + + if isinstance(parsed, dict): + commands = parsed.get("commands") + if isinstance(commands, list): + action = {"commands": [cmd for cmd in commands if isinstance(cmd, str)]} + else: + command = parsed.get("cmd") or parsed.get("command") + action = {"commands": [command]} if isinstance(command, str) else {} + for key in ("timeout_ms", "max_output_length"): + if key in parsed: + action[key] = parsed[key] + if action.get("commands"): + return action + + if isinstance(arguments, str) and arguments: + return {"commands": [arguments]} + return {"commands": []} + + def _function_call_output_content(self, output: Any) -> Any: + if isinstance(output, dict): + if self._is_responses_content_block(output): + return [output] + for key in ("output", "body", "content"): + if key in output: + return self._function_call_output_content(output[key]) + return output + + def _flatten_function_call_output(self, output: Any) -> str: + if isinstance(output, str): + return output + if isinstance(output, list): + parts = [] + for block in output: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict): + if block.get("type") in {"input_text", "output_text", "text"}: + text = block.get("text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + if isinstance(output, dict): + return json.dumps(output) + return str(output) if output is not None else "" + + def _image_parts(self, content: Any) -> list[dict[str, Any]]: + if not isinstance(content, list): + return [] + return [ + part + for part in content + if isinstance(part, dict) and part.get("type") == "image_url" + ] + + def _is_responses_content_block(self, block: dict[str, Any]) -> bool: + return block.get("type") in { + "input_text", + "output_text", + "text", + "input_image", + "image_url", + } + + def _flush_input_content( + self, content_parts: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + content = openai_responses_input_content_to_chat(content_parts) + return [{"role": "user", "content": content}] if content else [] + + def _flush_tool_block( + self, + tool_calls: list[dict[str, Any]], + tool_outputs: list[dict[str, Any]], + reasoning: str = "", + ) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + if tool_calls: + assistant_msg: dict[str, Any] = { + "role": "assistant", + "content": None, + "tool_calls": list(tool_calls), + } + if reasoning: + assistant_msg["reasoning_content"] = reasoning + messages.append(assistant_msg) + messages.extend(tool_outputs) + return messages + + def _convert_response_item_to_message( + self, + item: dict[str, Any], + ) -> Optional[dict[str, Any] | list[dict[str, Any]]]: + item_type = item.get("type", "") + + if item_type == "message": + role = item.get("role", "user") + content = openai_responses_input_content_to_chat(item.get("content", [])) + if content: + return {"role": role, "content": content} + + elif item_type == "function_call_output": + return self._function_call_output_messages(item) + + # Fallback: plain {role, content} dict + if not item_type and "role" in item and "content" in item: + role = item["role"] + content = item["content"] + if isinstance(content, str): + return {"role": role, "content": content} + if isinstance(content, list): + converted = openai_responses_input_content_to_chat(content) + if converted: + return {"role": role, "content": converted} + + return None + + def _convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + converted = [] + for tool in tools: + if tool.get("type") == "function" and "function" in tool: + converted.append({"type": "function", "function": tool["function"]}) + continue + + tool_type = tool.get("type") + if tool_type in {"shell", "local_shell"}: + converted.append( + { + "type": "function", + "function": { + "name": "shell", + "description": tool.get( + "description", + "Run shell commands in the local workspace.", + ), + "parameters": { + "type": "object", + "properties": { + "cmd": {"type": "string"}, + "commands": { + "type": "array", + "items": {"type": "string"}, + }, + "timeout_ms": {"type": "number"}, + "max_output_length": {"type": "number"}, + }, + }, + }, + } + ) + continue + + # Drop server-side tool types Polar can't dispatch (web_search, + # file_search, computer_use, mcp, code_interpreter, image_generation, + # custom, etc.). Only client-side functions/shell are convertible. + if tool_type and tool_type != "function": + continue + + name = tool.get("name") or tool.get("id", "") + if not name: + continue + + parameters = tool.get("parameters") + if parameters is None: + input_schema = tool.get("inputSchema") or tool.get("input_schema") + if isinstance(input_schema, dict): + json_schema = input_schema.get("jsonSchema") + parameters = ( + json_schema if isinstance(json_schema, dict) else input_schema + ) + else: + parameters = {} + + func_def: dict[str, Any] = { + "name": name, + "description": tool.get("description", ""), + "parameters": parameters, + } + if "strict" in tool: + func_def["strict"] = tool["strict"] + converted.append({"type": "function", "function": func_def}) + + return converted + + def _tool_choice_to_openai_chat(self, tool_choice: Any) -> Any: + if isinstance(tool_choice, str): + if tool_choice == "shell": + return {"type": "function", "function": {"name": "shell"}} + return tool_choice + + if not isinstance(tool_choice, dict): + return tool_choice + + choice_type = tool_choice.get("type") + if choice_type == "function": + function = tool_choice.get("function") + if isinstance(function, dict): + return tool_choice + name = tool_choice.get("name") + if isinstance(name, str) and name: + return {"type": "function", "function": {"name": name}} + if choice_type in {"shell", "local_shell"}: + return {"type": "function", "function": {"name": "shell"}} + return tool_choice + + def _reasoning_config_enables_thinking(self, reasoning_cfg: dict[str, Any]) -> bool: + if not reasoning_cfg: + return False + effort = reasoning_cfg.get("effort") + if isinstance(effort, str) and effort.lower() == "none": + return False + return True + + def _cached_prompt_tokens(self, usage: dict[str, Any]) -> int: + details = usage.get("prompt_tokens_details") + if isinstance(details, dict): + cached = details.get("cached_tokens") + if isinstance(cached, int): + return cached + cached = usage.get("cached_tokens") + return cached if isinstance(cached, int) else 0 + + def _make_error_response(self, message: str) -> dict[str, Any]: + return { + "type": "response.failed", + "response": { + "id": "resp_error", + "object": "response", + "status": "failed", + "error": {"code": "internal_error", "message": message}, + }, + } diff --git a/src/openenv/core/harness/capture/dialects/reasoning.py b/src/openenv/core/harness/capture/dialects/reasoning.py new file mode 100644 index 0000000000..caeb1d2ad4 --- /dev/null +++ b/src/openenv/core/harness/capture/dialects/reasoning.py @@ -0,0 +1,125 @@ +"""Reasoning round-trip helpers shared across API transformers. + +SGLang's `--reasoning-parser` (split mode: `qwen3`, `minimax`, `deepseek-r1`, +etc.) splits the model's chain-of-thought into the assistant message's +`reasoning_content` field. This module helps each transform convert that +field to / from the API-specific reasoning shape: + +- Anthropic: `thinking` content block with `thinking` + `signature` +- Gemini: part with `thought: true`, `text`, `thoughtSignature` +- Responses: `reasoning` output item with `summary`, `content`, `encrypted_content` +- OAI Chat: `reasoning_content` field on the assistant message (passthrough) + +Signatures and `encrypted_content` only need to round-trip opaquely through +the harness (the gateway is the API server on both ends), so we use +deterministic synthetic tokens — no real cryptography is necessary. +""" + +from __future__ import annotations + +import base64 +import hashlib +from typing import Any + + +def make_signature(reasoning_text: str) -> str: + """Deterministic synthetic signature for an Anthropic/Gemini thought block.""" + if not reasoning_text: + return "" + digest = hashlib.sha256(reasoning_text.encode("utf-8")).digest() + return "sg_oe_" + base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def make_google_signature(reasoning_text: str) -> str: + """Encode a proxy-local thought marker as Google's JSON bytes field. + + This is only for responses synthesized by our OpenAI-to-Google bridge; + it is not a native Gemini signature and must not replace native signatures. + Standard padded base64 is required by clients decoding the field as bytes. + """ + marker = make_signature(reasoning_text) + return base64.b64encode(marker.encode("ascii")).decode("ascii") + + +def encrypt_reasoning(reasoning_text: str) -> str: + """Pack reasoning into Responses-style `encrypted_content`. + + Base64-encoded so it survives transport. Decoded by `decrypt_reasoning` + when the harness replays it on the next turn. + """ + if not reasoning_text: + return "" + return "oe:" + base64.urlsafe_b64encode(reasoning_text.encode("utf-8")).decode( + "ascii" + ) + + +def decrypt_reasoning(encrypted: str | None) -> str: + """Reverse of `encrypt_reasoning`. Returns empty string on any failure.""" + if not isinstance(encrypted, str) or not encrypted.startswith("oe:"): + return "" + try: + return base64.urlsafe_b64decode(encrypted[len("oe:") :].encode("ascii")).decode( + "utf-8" + ) + except Exception: + return "" + + +def extract_reasoning_from_anthropic_content(content: Any) -> str: + """Extract reasoning_content from Anthropic assistant content blocks.""" + if not isinstance(content, list): + return "" + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "thinking": + text = block.get("thinking", "") + if isinstance(text, str) and text: + parts.append(text) + return "\n".join(parts) + + +def extract_reasoning_from_gemini_parts(parts: Any) -> str: + """Extract reasoning_content from Gemini content parts (thought:true).""" + if not isinstance(parts, list): + return "" + pieces: list[str] = [] + for part in parts: + if not isinstance(part, dict): + continue + if part.get("thought") is True: + text = part.get("text", "") + if isinstance(text, str) and text: + pieces.append(text) + return "\n".join(pieces) + + +def extract_reasoning_from_responses_item(item: dict[str, Any]) -> str: + """Extract reasoning_content text from a Responses `reasoning` input item. + + Prefers `content[*].text` (full chain), falls back to `summary[*].text`, + finally tries `encrypted_content` (decoded by `decrypt_reasoning`). + """ + content = item.get("content") + if isinstance(content, list): + chunks = [ + b.get("text", "") + for b in content + if isinstance(b, dict) and isinstance(b.get("text"), str) + ] + joined = "\n".join(c for c in chunks if c) + if joined: + return joined + summary = item.get("summary") + if isinstance(summary, list): + chunks = [ + b.get("text", "") + for b in summary + if isinstance(b, dict) and isinstance(b.get("text"), str) + ] + joined = "\n".join(c for c in chunks if c) + if joined: + return joined + return decrypt_reasoning(item.get("encrypted_content")) diff --git a/src/openenv/core/harness/capture/export.py b/src/openenv/core/harness/capture/export.py new file mode 100644 index 0000000000..d030a1bf5f --- /dev/null +++ b/src/openenv/core/harness/capture/export.py @@ -0,0 +1,246 @@ +"""Graph -> the JSON a trainer consumes. + +One document per rollout. Every field a trainer needs is precomputed and validated; nothing +downstream has to re-derive, re-tokenize, or guess. + + { + "session_id": ..., + "stats": {...}, graph shape: turns, roots, forks, discards + "sequences": [ one per root-to-leaf path + {"input_ids", "loss_mask", "logprobs", "prompt_len", "n_turns", + "turn_lengths", sampled tokens per turn: the join key against a harness trace + "role", "agent" | "auxiliary" | "discarded" + "validation": [...]} + ], + "validation": [...], rollout-level findings + "trainable": bool the single gate: did anything survive + } + +Sequences are labelled rather than filtered. A caller that silently drops rows cannot be +distinguished from one that had none to drop, and "the group quietly shrank" is far harder to +diagnose than "three rows were labelled auxiliary". The trainer picks by `role`. + +ROLE ASSIGNMENT is structural first, heuristic only as a tiebreak. A rollout's real work is the +longest path with tool access; a title generator or a summariser is a short toolless root. The old +approach (matching known system-prompt strings) needed a new entry per harness and failed silently +on the harnesses nobody had profiled yet. +""" + +from __future__ import annotations + +from typing import Any + +from .validate import check_rollout, check_sequence + +AGENT, AUXILIARY, DISCARDED = "agent", "auxiliary", "discarded" + + +def _assign_roles(graph, sequences, *, trainable_capture: bool = True) -> list[str]: + """Label each flattened path. Purely structural. + + - a path ending in a discarded node (a sibling that never continued) is a retry + - a path whose turns carry a TOOL MANIFEST is the agent working + - anything else is auxiliary: title generators, summarisers, classifiers + + **Multiple agent paths are normal and all of them are trainable.** A harness that rewrites its + system prompt mid-run breaks token-prefix continuity and starts a new root, even though the + conversation continued: claude-code does exactly this, swapping a 12118-char system prompt for a + 12541-char one at call 6 while its message list grows 2 -> 26 unbroken. Both roots are the agent + doing real work on the same task and earn the same reward. + + An earlier version kept only the single longest tool-using path. On opencode that was + indistinguishable from correct (its second root really is a title generator), but on claude-code + it silently discarded 6 genuine agent turns. Tool access alone is the honest signal: aux calls + essentially never pass a tool manifest, and coding agents essentially always do. + """ + discarded_ids = {n.node_id for n in graph.discarded_nodes()} + live = [ + (i, s) for i, s in enumerate(sequences) if s.node_ids[-1] not in discarded_ids + ] + + # Tools only DISCRIMINATE when some paths have them and others do not. That is the opencode + # shape: an agent chain with a manifest plus a toolless title generator. + # + # Some harnesses never send a manifest at all. terminus-2 parses tool calls out of raw model + # text, so every one of its paths has n_tools == 0. Applying the tool rule there labels the whole + # rollout auxiliary, and an earlier "keep the longest" fallback then kept exactly ONE of its 13 + # turns -- a harness-trace cross-check caught it as `captured [263] vs trace [167, ..., 136]`. + # + # So: if nothing in the rollout uses tools, tools carry no signal and every live path is agent + # work. If something does, the toolless paths really are auxiliary. + any_tools = any(graph.get(nid).n_tools > 0 for _, s in live for nid in s.node_ids) + + roles = [] + for i, seq in enumerate(sequences): + if seq.node_ids[-1] in discarded_ids: + roles.append(DISCARDED) + continue + if not any_tools: + # `n_trainable` is the tiebreak only where it can mean something. On an eval endpoint it + # is 0 for every sequence by construction, so using it there labelled EVERY path auxiliary + # for a harness that sends no tool manifest — terminus-2 parses tool calls out of raw text + # — which emptied `result.turns` and mistagged the conversations, on a rollout that had + # captured perfectly well. With neither tools nor token counts to discriminate on, a live + # path is the agent working: that is the same conclusion the tool rule reaches, and the + # cost of being wrong is a mislabelled trace rather than a mistrained token, since nothing + # here is trainable anyway. + usable = seq.n_trainable if trainable_capture else bool(seq.node_ids) + roles.append(AGENT if usable else AUXILIARY) + continue + has_tools = any(graph.get(nid).n_tools > 0 for nid in seq.node_ids) + roles.append(AGENT if has_tools else AUXILIARY) + return roles + + +def export_session( + session, + *, + include_discarded: bool = False, + include_messages: bool = False, + capture_level: str = "tokens", +) -> dict[str, Any]: + """Build the document for one rollout: training rows when there are any, the trace always. + + Args: + session: + The live capture session. + include_discarded (`bool`, *optional*, defaults to `False`): + Keep paths that led nowhere (retries, resamples) as labelled rows. + include_messages (`bool`, *optional*, defaults to `False`): + Add each turn's request messages, tools and response message. Off by default because it + multiplies payload size by the full conversation text, on when you need to feed TRL's + `TraceEntry` contract or measure re-tokenization skew. + capture_level (`str`, *optional*, defaults to `"tokens"`): + What the upstream could return. Below `tokens` this is an eval rollout: the trace and the + graph structure are complete, no row is trainable, and the token arrays are empty — by + construction rather than by accident. + + Returns: + `dict[str, Any]`: The rollout document. + """ + graph = session.graph + rollout_report = check_rollout( + graph, + capture_level=capture_level, + budget_stop_count=getattr(session, "budget_stop_count", 0), + ) + trainable_capture = ( + capture_level == "tokens" and getattr(session, "purpose", "auto") != "eval" + ) + + # Sequences are built at every level, because they are the rollout's STRUCTURE — which calls + # belong to which conversation, which path is the agent working, which branches died — and that + # structure is real whether or not token ids came back. Everything that reads a rollout as a + # trace (`conversations_from_document`, `turns_from_document`, the UI transcript) walks these + # rows, so dropping them on an eval rollout deletes exactly the payload an eval rollout is for. + # + # What is withheld at a lower level is the *training* claim: `check_sequence` is skipped, since + # every one of its findings is about token arrays that are empty by design, and no row is ever + # marked trainable. The token fields stay as the empty lists the graph produced. + sequences = graph.sequences() + roles = _assign_roles(graph, sequences, trainable_capture=trainable_capture) + + rows: list[dict[str, Any]] = [] + for seq, role in zip(sequences, roles): + if role == DISCARDED and not include_discarded: + continue + report = check_sequence(seq) if trainable_capture else None + rows.append( + { + "role": role, + "root_id": seq.root_id, + "node_ids": seq.node_ids, + "n_turns": seq.n_turns, + "prompt_len": seq.prompt_len, + "n_trainable": seq.n_trainable if trainable_capture else 0, + "turn_lengths": seq.turn_lengths(), + "input_ids": seq.input_ids, + "loss_mask": seq.loss_mask + if trainable_capture + else [0] * len(seq.input_ids), + "logprobs": seq.logprobs, + "trainable": bool(report and report.ok and role == AGENT), + "validation": [str(f) for f in report.findings] if report else [], + } + ) + + # Every call in arrival order, including the ones excluded from training. This is what an + # external trace can be reconciled against: the harness logged every LLM call it made, + # so comparing only the surviving path would report a mismatch on any rollout that retried. + discarded_ids = {n.node_id for n in graph.discarded_nodes()} + turns = [ + { + "node_id": node.node_id, + "index": node.index, + "root_id": graph.root_of(node.node_id), + "n_sampled": len(node.sampled_ids), + "n_prompt": len(node.prompt_ids), + "n_tools": node.n_tools, + "finish_reason": node.finish_reason, + "harness_session_id": node.harness_session_id, + # Travels with the turn because it decides whether a trainer's recompute is comparable to + # the captured logprob at all. See `TurnNode.sampling_params`. + "sampling_params": node.sampling_params, + "requested_sampling_params": node.requested_sampling_params, + "sampled_logprobs": node.sampled_logprobs, + "discarded": node.node_id in discarded_ids, + **( + { + "request_messages": node.request_messages, + "request_tools": node.request_tools, + "response_message": node.response_message, + } + if include_messages + else {} + ), + } + for node in graph.nodes() + ] + + trainable_rows = [r for r in rows if r["trainable"]] + return { + "session_id": session.session_id, + "metadata": session.metadata, + "budget_stop_count": getattr(session, "budget_stop_count", 0), + "turns": turns, + "stats": { + **graph.stats(), + "n_sequences": len(rows), + "n_trainable_sequences": len(trainable_rows), + "n_trainable_tokens": sum(r["n_trainable"] for r in trainable_rows), + }, + "sequences": rows, + "validation": [str(f) for f in rollout_report.findings] + session.findings, + "trainable": bool(trainable_rows) and rollout_report.ok, + # Why this rollout is or is not trainable, travelling with the data rather than living only + # in the server's log. A consumer that reads `trainable` alone is safe; one that wants to + # explain an empty `sequences` list to a human needs these two. + "capture_level": capture_level, + "rollout_type": "train" if trainable_capture else "eval", + } + + +def summarise(document: dict[str, Any]) -> str: + """One screen of text. What you actually read after a rollout.""" + stats = document["stats"] + lines = [ + f"session {document['session_id']} " + f"{document.get('rollout_type', 'train')} " + f"trainable={document['trainable']}", + f" graph: {stats['n_turns']} turns, {stats['n_roots']} roots, " + f"{stats['n_forks']} forks, {stats['n_discarded']} discarded", + f" training: {stats['n_trainable_sequences']} sequence(s), " + f"{stats['n_trainable_tokens']} trainable tokens", + ] + for row in document["sequences"]: + lines.append( + f" [{row['role']:<10}] turns={row['n_turns']:<3} prompt={row['prompt_len']:<6} " + f"len={len(row['input_ids']):<6} trainable={row['n_trainable']:<5} " + f"turn_lengths={row['turn_lengths']}" + ) + for finding in row["validation"]: + if not finding.startswith("[INFO]"): + lines.append(f" {finding}") + for finding in document["validation"]: + lines.append(f" {finding}") + return "\n".join(lines) diff --git a/src/openenv/core/harness/capture/forwarding.py b/src/openenv/core/harness/capture/forwarding.py new file mode 100644 index 0000000000..1c8e841452 --- /dev/null +++ b/src/openenv/core/harness/capture/forwarding.py @@ -0,0 +1,346 @@ +"""Publish the intercept server at a URL the sandbox can reach. + +The agent runs inside a sandbox on the public internet; the intercept runs next to the engine on a +cluster node with no inbound connectivity. Exactly one hop must be forwarded, and it is this one. +The engine itself never needs exposing: it stays on localhost behind the intercept. + +`PortForwarder` is that hop, as a swappable strategy. Three implementations, chosen by what the +sandbox actually is rather than by preference: + + DirectExposure the sandbox can already route to us (local docker, same VPC). No third party, + no expiry, no throughput ceiling. Prefer this whenever it is true. + GradioForwarder frpc via gradio.networking.setup_tunnel. + CloudflareForwarder cloudflared quick forwards, or a named forwards in production. + +MEASURED, not assumed. Over a full day of harness bring-up on one intercept: + + gradio / frpc 521 POSTs, ZERO forwarding errors in the server log, ~370ms health round trip, + still up after 24h. + cloudflared 10765 log lines on the sibling experiment, with repeated + `failed to accept QUIC stream: timeout`, `datagram manager encountered a + failure while serving`, and `lookup region1.v2.argotunnel.com: i/o timeout`. + It always reconnected, so this is churn rather than outage, but it is churn. + +So gradio is the better default at eval scale. Cloudflare earns its place elsewhere: quick forwards +expire in a way named forwards do not, and a named forwards gives a stable hostname, real access +policies, and no shared relay. gradio.live URLs expire at 72h and are a single frpc hop, which is +fine for a sweep and a bottleneck at GRPO group width. + +SHARE TOKENS ARE NOT AUTH. `share_token` identifies the forward to the share server; the resulting +URL is public either way. What protects the GPU behind it is the intercept's own key check, which is +why `SessionRegistry.require_registered` defaults to True. +""" + +from __future__ import annotations + +import logging +import re +import secrets +import select +import shutil +import subprocess +import threading +import time +from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) + + +class ForwardingError(RuntimeError): + """Raised when a forwarder cannot be established. Never returns a half-open forward. + + Failing here is strictly better than returning a URL that does not resolve: a stale URL that + still looks valid produces a rollout that silently captures nothing, which is the exact class of + failure this whole layer exists to make impossible. + """ + + +class PortForwarder(ABC): + """Publish `local_host:local_port` and hand back a URL reachable from the sandbox.""" + + def __init__(self) -> None: + self._url: str | None = None + self._local_port: int | None = None + + @classmethod + def preflight(cls) -> None: + """Raise ForwardingError if this strategy cannot possibly work here. + + Called BEFORE a run starts, so a missing binary or an uninstalled dependency is a startup + error rather than a failure discovered after the first sandbox has been billed. + """ + + @abstractmethod + def start(self, local_port: int, *, local_host: str = "127.0.0.1") -> str: + """Begin forwarding and return the public URL.""" + + @abstractmethod + def stop(self) -> None: + """Tear down. Must be idempotent: teardown runs on both success and failure paths.""" + + @property + def url(self) -> str | None: + return self._url + + @property + def name(self) -> str: + return type(self).__name__ + + def __enter__(self) -> "PortForwarder": + return self + + def __exit__(self, *exc) -> None: + self.stop() + + +class DirectExposure(PortForwarder): + """No forward: hand back the address as-is. + + For local docker sandboxes, or any deployment where the sandbox can already route to the host. + This is the production answer whenever it is available, and it is worth checking before reaching + for a forward: a forward exists because E2B is off-cluster, not because the design needs one. + """ + + def __init__(self, advertise_host: str = "127.0.0.1", scheme: str = "http") -> None: + super().__init__() + self._host = advertise_host + self._scheme = scheme + + def start(self, local_port: int, *, local_host: str = "127.0.0.1") -> str: + self._local_port = local_port + self._url = f"{self._scheme}://{self._host}:{local_port}" + return self._url + + def stop(self) -> None: + self._url = None + + +class GradioForwarder(PortForwarder): + """frpc, via `gradio.networking.setup_tunnel`. + + Preferred over shelling out to a binary for a reason that matters operationally: it RETURNS the + URL, rather than leaving us to grep a subprocess log for it and hope it appeared. It is also + outbound-only, so it needs no inbound firewall rule, and gradio is already an OpenEnv dependency. + Verifiers reached the same conclusion independently and forwards via frpc too. + + Pass `share_server_address` to point at your own frps: stable URLs, no 72h expiry, no third + party, no shared throughput ceiling. + """ + + def __init__( + self, + share_server_address: str | None = None, + share_server_tls_certificate: str | None = None, + ) -> None: + super().__init__() + self._share_server = share_server_address + self._tls_cert = share_server_tls_certificate + self._tunnel = None + self._readers: list[threading.Thread] = [] + + @classmethod + def preflight(cls) -> None: + try: + from gradio.networking import setup_tunnel # noqa: F401 + except Exception as exc: # noqa: BLE001 + raise ForwardingError( + f"gradio is required for GradioForwarder: {exc}" + ) from exc + + def start(self, local_port: int, *, local_host: str = "127.0.0.1") -> str: + from gradio.networking import setup_tunnel + from gradio.tunneling import CURRENT_TUNNELS + + self.stop() + share_token = secrets.token_hex(16) + try: + url = setup_tunnel( + local_host=local_host, + local_port=local_port, + share_token=share_token, + share_server_address=self._share_server, + share_server_tls_certificate=self._tls_cert, + ) + except Exception as exc: # noqa: BLE001 + raise ForwardingError(f"gradio forward failed to open: {exc}") from exc + # Gradio reads stdout only until the URL appears, and never reads stderr. + # Drain both pipes for the lifetime of this child: a full pipe can block + # frpc's logging thread and prevent it from maintaining the relay. + self._tunnel = next( + (tunnel for tunnel in CURRENT_TUNNELS if tunnel.share_token == share_token), + None, + ) + if self._tunnel is None or self._tunnel.proc is None: + raise ForwardingError( + "gradio returned a URL without a managed tunnel process" + ) + for pipe in (self._tunnel.proc.stdout, self._tunnel.proc.stderr): + if pipe is not None: + reader = threading.Thread(target=self._drain, args=(pipe,), daemon=True) + reader.start() + self._readers.append(reader) + self._local_port, self._url = local_port, url + return url + + @staticmethod + def _drain(pipe) -> None: + try: + for line in iter(pipe.readline, b""): + text = line.decode(errors="replace").rstrip() + if any( + marker in text.lower() for marker in ("error", "failed", "warn") + ): + logger.warning("gradio tunnel: %s", text[:1000]) + else: + logger.debug("gradio tunnel: %s", text[:1000]) + except (OSError, ValueError): + pass # The owner may close the pipe during shutdown. + + def stop(self) -> None: + if self._tunnel is not None: + process = self._tunnel.proc + self._tunnel.kill() + if process is not None: + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + for reader in self._readers: + reader.join(timeout=1) + for pipe in (process.stdout, process.stderr): + if pipe is not None: + pipe.close() + self._tunnel = None + self._readers.clear() + self._url = None + + +class CloudflareForwarder(PortForwarder): + """`cloudflared`, either a quick forwards or a named one. + + Quick tunnels (no `tunnel_name`) need no account and print a `*.trycloudflare.com` URL on + stderr, which we parse. Named forwards need `cloudflared login` beforehand but give a stable + hostname that survives restarts, which is what you want once this is not a sweep any more. + + The URL arrives asynchronously on stderr, so `start` blocks until it appears or gives up. That + wait is the entire reason this class is more code than GradioForwarder. + """ + + _URL_RE = re.compile(r"https://[-a-z0-9]+\.trycloudflare\.com") + + def __init__( + self, + tunnel_name: str | None = None, + hostname: str | None = None, + binary: str = "cloudflared", + startup_timeout_s: float = 60.0, + ) -> None: + super().__init__() + self._tunnel_name = tunnel_name + self._hostname = hostname + self._binary = binary + self._startup_timeout_s = startup_timeout_s + self._proc: subprocess.Popen | None = None + + @classmethod + def preflight(cls, binary: str = "cloudflared") -> None: + if shutil.which(binary) is None: + raise ForwardingError( + f"`{binary}` not found on PATH. Install it, or use GradioForwarder, which needs no " + "binary because frpc ships with gradio." + ) + + def start(self, local_port: int, *, local_host: str = "127.0.0.1") -> str: + self.preflight(self._binary) + target = f"http://{local_host}:{local_port}" + + if self._tunnel_name: + cmd = [self._binary, "tunnel", "run", "--url", target, self._tunnel_name] + else: + # `cloudflared tunnel --url`, not `cloudflared forward`. `forward` is an alias for + # `cloudflared access`, a completely different feature: it exits without ever printing a + # *.trycloudflare.com URL, so this path failed at startup every time it was selected. + cmd = [self._binary, "tunnel", "--no-autoupdate", "--url", target] + + self._proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True, + ) + self._local_port = local_port + + # A named forwards serves a hostname we already know, so there is nothing to parse. + if self._tunnel_name and self._hostname: + self._url = f"https://{self._hostname}" + return self._url + + url = self._await_url() + if url is None: + self.stop() + raise ForwardingError( + f"cloudflared printed no forward URL within {self._startup_timeout_s:.0f}s. " + "Check that the binary can reach Cloudflare, or use GradioForwarder." + ) + self._url = url + return url + + def _await_url(self) -> str | None: + """Read stderr until the URL appears, the process dies, or we run out of patience. + + `select` before `readline`, because `readline` BLOCKS until a newline arrives. A cloudflared + that starts and then goes quiet — no URL, no crash — held the loop inside that call forever, + so `startup_timeout_s` was advisory and `start()` could hang indefinitely instead of failing + cleanly. Waiting on readability first means the deadline is honoured whatever the child does. + """ + assert self._proc is not None and self._proc.stdout is not None + stream = self._proc.stdout + deadline = time.monotonic() + self._startup_timeout_s + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + if self._proc.poll() is not None: + return None # died during startup + # Capped so process death is noticed promptly even while the pipe stays silent. + ready, _, _ = select.select([stream], [], [], min(remaining, 0.2)) + if not ready: + continue + line = stream.readline() + if not line: + # EOF: the pipe closed, so nothing further will arrive on it. + return None + match = self._URL_RE.search(line) + if match: + return match.group(0) + + def stop(self) -> None: + proc, self._proc, self._url = self._proc, None, None + if proc is None or proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +_FORWARDERS: dict[str, type[PortForwarder]] = { + "direct": DirectExposure, + "gradio": GradioForwarder, + "cloudflare": CloudflareForwarder, +} + + +def make_forwarder(kind: str = "gradio", **kwargs) -> PortForwarder: + """Build a forwarder by name, for CLI wiring (`--expose gradio|cloudflare|direct`).""" + try: + cls = _FORWARDERS[kind] + except KeyError: + raise ForwardingError( + f"unknown port forwarder {kind!r}; choose one of {sorted(_FORWARDERS)}" + ) from None + return cls(**kwargs) diff --git a/src/openenv/core/harness/capture/graph.py b/src/openenv/core/harness/capture/graph.py new file mode 100644 index 0000000000..ec6edd04f3 --- /dev/null +++ b/src/openenv/core/harness/capture/graph.py @@ -0,0 +1,496 @@ +"""The rollout graph: every model call a harness made, linked by token prefix. + +A rollout is not a list of turns. Harnesses retry, spawn subagents, generate titles, and compact +context, and all of it arrives on one wire looking identical. A flat list forces you to guess which +turns belong together; a graph records it. + + node one model call: the prompt the server tokenized, the tokens it sampled back + parent the call whose prompt+completion is a token-prefix of this call's prompt + root a call that extends nothing (a new conversation: the agent's, a subagent's, + a title generator's, or the continuation after a context compaction) + path root -> leaf, which is exactly one training sequence + +Everything downstream is a walk. `sequences()` concatenates a path into +`input_ids / loss_mask / logprobs`; branch structure tells you which paths are the agent's work and +which are discards. + +WHY A GRAPH AND NOT PREFIX-MERGED CHAINS. Three things fall out of it that a chain cannot express: + + * **Retries become visible.** A retried turn is a sibling that never continued: same parent, no + children. A proxy cannot ask the harness what it discarded, but the shape of the graph shows it. + * **Subagents separate themselves.** A subagent has its own system prompt, so its first call + extends nothing and starts its own root. No system-prompt keyword matching required. + * **Compaction is representable.** A rewritten history is not a prefix extension, so it opens a + new root instead of corrupting the chain it came from. + +TOKEN FIDELITY. We never tokenize. The inference server tokenizes each prompt as a side effect of +serving it and returns `prompt_token_ids`, so turn k+1's prompt IS the canonical tokenization of +everything up to that point, including the tool results the harness inserted. Assistant bodies come +back as sampled `token_ids` with aligned logprobs. So for any path: + + concat(node.context_ids + node.sampled_ids for node in path) + +reproduces, exactly, the token sequences the model actually saw and produced. Nothing is re-rendered +through a local chat template, which is the single largest source of silent train/inference skew. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Iterator + + +def common_prefix_len(a: list[int], b: list[int]) -> int: + """How many leading tokens `a` and `b` share.""" + limit = min(len(a), len(b)) + i = 0 + while i < limit and a[i] == b[i]: + i += 1 + return i + + +def _canonical_arguments(arguments: Any) -> str: + """Tool-call arguments in a form that survives a harness re-serialising them. + + `arguments` travels the wire as a JSON *string*, and the string an agent sends back is not the + string the provider produced. Observed on a real opencode rollout against the HF router, the same + `bash` call arrived as + + {"command": "python3 -c ..."} from the provider + {"command":"python3 -c ..."} echoed back by the harness + + — identical arguments, different bytes, differing only in the space after the colon. Comparing + the raw strings made the message fallback in `_find_parent_by_messages` inert on the very case it + was written for: an eight-turn rollout came back as eight separate roots. Key ordering is the same + hazard from any harness that round-trips through a dict. + + Falls back to the raw string when it is not JSON, which is the honest answer for a model that + emitted malformed arguments: two different malformed strings are two different calls. + """ + if arguments is None: + return "" + if not isinstance(arguments, str): + # Some harnesses hand back an already-decoded object rather than a string. + try: + return json.dumps(arguments, sort_keys=True) + except (TypeError, ValueError): + return str(arguments) + try: + return json.dumps(json.loads(arguments), sort_keys=True) + except (json.JSONDecodeError, TypeError, ValueError): + return arguments.strip() + + +def _message_identity(message: Any) -> tuple: + """The part of a message that decides whether two messages are the same turn. + + Compared instead of the raw dicts because the assistant message a provider returns and the one a + harness sends back in its next request are equal in meaning and unequal as dicts: providers add + `refusal`, `annotations` and `audio: null`, harnesses drop them, and `content` moves between + `null` and `""`. Comparing dicts directly would find no parent for any turn and report every call + as its own root — the exact failure the message fallback exists to avoid. + + Tool calls are reduced to name and arguments: the `id` is provider-generated and does survive a + round trip, but keying on it would make the comparison fail for any harness that rewrites ids. + The arguments are compared as PARSED JSON, not as the string the wire carried — see + `_canonical_arguments`. + """ + if not isinstance(message, dict): + return ("", str(message), ()) + content = message.get("content") + if isinstance(content, list): + # Multimodal content: keep only the text parts, in order. Image bytes are large and their + # encoding is not stable across a round trip. + content = "".join( + str(part.get("text", "")) + for part in content + if isinstance(part, dict) and part.get("type") in {"text", "input_text"} + ) + calls = tuple( + ( + str((call.get("function") or {}).get("name", "")), + _canonical_arguments((call.get("function") or {}).get("arguments")), + ) + for call in (message.get("tool_calls") or []) + if isinstance(call, dict) + ) + return ( + str(message.get("role") or ""), + (str(content) if content is not None else "").strip(), + calls, + ) + + +def _same_message(a: Any, b: Any) -> bool: + """Whether two messages are the same conversational turn. See `_message_identity`.""" + return _message_identity(a) == _message_identity(b) + + +def _without_leading_system(messages: Any) -> list: + """The message list with any leading system messages removed. + + Used only for PARENT LOOKUP, never for what gets stored. Some harnesses re-render their system + prompt on every step, so the same conversation carries a slightly different system message each + turn: claude-code's differs after the first ~150 characters while the rest of the history matches + exactly. Comparing it would fail at index 0, no parent would be found for any turn, and a 13-turn + rollout is reported as 13 separate roots — the failure `_find_parent_by_messages` exists to + prevent, arriving through a different door than the missing-role case it was written for. + Measured downstream: a trace of 13 turns became 101 assistant messages once those false roots + were concatenated. + + Leading system messages are dropped rather than compared loosely so that a conversation which + GAINS or LOSES a system message still lines up; comparing by role alone would keep the lists + aligned only while both have one. + """ + if not isinstance(messages, list): + return [] + i = 0 + while i < len(messages): + m = messages[i] + if isinstance(m, dict) and (m.get("role") or "") == "system": + i += 1 + continue + break + return list(messages[i:]) + + +@dataclass +class TurnNode: + """One model call, and where it sits relative to the call before it.""" + + node_id: str + prompt_ids: list[int] + sampled_ids: list[int] + sampled_logprobs: list[float] | None = None + parent_id: str | None = None + + # Provenance, for attribution and for the per-harness notes. Never used in token math. + index: int = 0 # arrival order within the session + model: str | None = None + finish_reason: str | None = None + harness_session_id: str | None = ( + None # the harness's OWN session id, when it sends one + ) + system_digest: str | None = ( + None # cheap identity for the conversation this belongs to + ) + n_tools: int = 0 + request_messages: list[dict[str, Any]] = field(default_factory=list) + # Retained because TRL's `_turns_from_trace` passes `tools` to `apply_chat_template`: the tool + # manifest is part of the rendered prompt, so a re-tokenization without it does not match what + # the engine actually saw. + request_tools: list[dict[str, Any]] | None = None + # Parameters actually submitted to inference, after capture preparation and compatibility + # edits. Missing values are engine defaults, not a verified training policy. An explicit + # session policy pins every sampling field; the original harness request stays separate. + sampling_params: dict[str, Any] = field(default_factory=dict) + requested_sampling_params: dict[str, Any] = field(default_factory=dict) + response_message: dict[str, Any] = field(default_factory=dict) + + @property + def end_ids(self) -> list[int]: + """Cumulative token sequence after this turn: its prompt plus what it sampled.""" + return self.prompt_ids + self.sampled_ids + + def context_ids(self, parent: "TurnNode | None") -> list[int]: + """Tokens this node adds to the sequence BEFORE the model starts generating. + + For a root that is the whole prompt. For a child it is the interstitial span: the tool + results, user turns and template scaffolding the harness inserted since the parent stopped + generating. These are real tokens the model conditioned on, but it did not produce them, so + they are context (mask 0) rather than targets. + """ + if parent is None: + return list(self.prompt_ids) + return self.prompt_ids[len(parent.end_ids) :] + + +@dataclass +class TrainingSequence: + """One path through the graph, flattened. Maps onto TRL's `TrainingSequence` fields.""" + + input_ids: list[int] + loss_mask: list[int] + logprobs: list[float] + node_ids: list[str] + prompt_len: int # tokens before the first sampled token + root_id: str + n_turns: int + + @property + def n_trainable(self) -> int: + return sum(self.loss_mask) + + def turn_lengths(self) -> list[int]: + """Sampled-token count per turn, in order. The join key against a harness trace.""" + lengths, run = [], 0 + for m in self.loss_mask: + if m: + run += 1 + elif run: + lengths.append(run) + run = 0 + if run: + lengths.append(run) + return lengths + + +class RolloutGraph: + """All model calls for one rollout, linked by prefix. + + `add_turn` is the whole ingestion path: it finds the parent by token prefix and appends. Calls + arrive in wire order, but the graph does not depend on that order being meaningful, which matters + because harnesses issue concurrent requests (parallel subagents, background summarisation). + """ + + def __init__(self) -> None: + self._nodes: dict[str, TurnNode] = {} + self._order: list[str] = [] + self._children: dict[str, list[str]] = {} + + # --- construction -------------------------------------------------- + def add_turn(self, node: TurnNode) -> TurnNode: + node.index = len(self._order) + node.parent_id = self._find_parent(node) + self._nodes[node.node_id] = node + self._order.append(node.node_id) + self._children.setdefault(node.node_id, []) + if node.parent_id is not None: + self._children[node.parent_id].append(node.node_id) + self._adopt_orphaned_roots(node) + return node + + def _adopt_orphaned_roots(self, node: TurnNode) -> None: + """Re-parent descendants when a closer exact-prefix predecessor arrives late. + + `_find_parent` only looks backwards, and it skips any candidate whose `end_ids` is longer than + the new node's prompt. So a turn that arrives BEFORE its own ancestor was permanently orphaned: + ingesting `[1,2,3,4] -> [5]` and then `[1,2] -> [3]` produced two roots and split one genuine + two-turn trajectory in half. + + That contradicts the guarantee this class documents — arrival order is not meaningful, because + harnesses issue concurrent requests — and the existing arrival-order test only covered the + ancestor-first direction. Linking is symmetric now: on insert, look forwards too. + + A,C,B must produce the same chain as A,B,C: C may already be attached to A when B arrives. + """ + end = node.end_ids + if not end: + return + for candidate_id in self._order: + if candidate_id == node.node_id: + continue + candidate = self._nodes[candidate_id] + if len(node.prompt_ids) >= len(candidate.prompt_ids): + continue + if candidate.parent_id is not None: + parent = self._nodes[candidate.parent_id] + if len(parent.end_ids) >= len(end): + continue + if len(end) > len(candidate.prompt_ids): + continue + if common_prefix_len(end, candidate.prompt_ids) == len(end): + if candidate.parent_id is not None: + self._children[candidate.parent_id].remove(candidate_id) + candidate.parent_id = node.node_id + self._children[node.node_id].append(candidate_id) + + def _find_parent(self, node: TurnNode) -> str | None: + """The existing node whose prompt+completion is the LONGEST exact prefix of this prompt. + + Longest wins so a deep chain attaches to its immediate predecessor rather than to an early + ancestor that also matches. Requiring an exact prefix (not a fuzzy match) is deliberate: a + harness that mutates its history has genuinely produced a different sequence, and quietly + attaching it would fabricate a trajectory the model never saw. Such a call becomes a new root + instead, which `roots()` surfaces rather than hides. + + Falls back to message prefixes when there are no token ids to compare. See `_find_parent_by_ + messages`. + """ + if not node.prompt_ids: + return self._find_parent_by_messages(node) + + best_id, best_len = None, 0 + for candidate_id in self._order: + candidate = self._nodes[candidate_id] + end = candidate.end_ids + if len(candidate.prompt_ids) >= len(node.prompt_ids): + continue + if len(end) > len(node.prompt_ids) or len(end) <= best_len: + continue + if common_prefix_len(end, node.prompt_ids) == len(end): + best_id, best_len = candidate_id, len(end) + return best_id + + def _find_parent_by_messages(self, node: TurnNode) -> str | None: + """Same longest-exact-prefix rule, keyed on the message list instead of token ids. + + Only reachable on an eval endpoint, where the upstream returns no token ids at all. Without + this the token path degenerates silently rather than wrongly: every `end_ids` is empty, so + `len(end) <= best_len` is true for every candidate, no parent is ever found, and a 20-turn + conversation is reported as 20 separate roots. The rollout is not wrong, but it reads as if + the agent restarted on every turn, and `check_rollout`'s root-count heuristics all misfire. + + Messages are a weaker key than tokens — they are what the harness *said* it sent rather than + what the engine tokenised — which is exactly why they are not used when ids are available. + For a trace they are sufficient: the question is only which conversation a call continues. + """ + best_id, best_len = None, 0 + for candidate_id in self._order: + candidate = self._nodes[candidate_id] + # The parent's own turn is its request plus the message it produced, and that whole + # thing has to be a prefix of ours — the same "prompt + completion" span `end_ids` is. + # + # The role is defaulted rather than required: a response message is by definition the + # assistant's, but not every producer spells it out (an engine may omit it, and the SSE + # replay path reassembles the message itself), while the harness always names the role + # when it echoes the turn back. Without the default that asymmetry alone breaks every + # link and the whole conversation reads as one root per turn. + reply = candidate.response_message + end = [ + *candidate.request_messages, + *([{"role": "assistant", **reply}] if reply else []), + ] + if not end or len(end) > len(node.request_messages) or len(end) <= best_len: + continue + # Compared with leading system messages dropped from BOTH sides: a harness that + # re-renders its system prompt every step would otherwise fail at index 0 and orphan every + # turn. See `_without_leading_system`. + end_cmp = _without_leading_system(end) + node_cmp = _without_leading_system(node.request_messages) + if not end_cmp or len(end_cmp) > len(node_cmp): + continue + if all(_same_message(a, b) for a, b in zip(end_cmp, node_cmp)): + best_id, best_len = candidate_id, len(end) + return best_id + + # --- structure ----------------------------------------------------- + def nodes(self) -> list[TurnNode]: + return [self._nodes[i] for i in self._order] + + def get(self, node_id: str) -> TurnNode: + return self._nodes[node_id] + + def children(self, node_id: str) -> list[TurnNode]: + return [self._nodes[i] for i in self._children.get(node_id, [])] + + def roots(self) -> list[TurnNode]: + return [self._nodes[i] for i in self._order if self._nodes[i].parent_id is None] + + def leaves(self) -> list[TurnNode]: + return [self._nodes[i] for i in self._order if not self._children.get(i)] + + def root_of(self, node_id: str) -> str: + """Which conversation this node belongs to. Walks parents to the top.""" + node = self._nodes[node_id] + while node.parent_id is not None: + node = self._nodes[node.parent_id] + return node.node_id + + def path_to(self, leaf_id: str) -> list[TurnNode]: + path: list[TurnNode] = [] + node: TurnNode | None = self._nodes[leaf_id] + while node is not None: + path.append(node) + node = self._nodes[node.parent_id] if node.parent_id else None + return list(reversed(path)) + + def paths(self) -> Iterator[list[TurnNode]]: + """Every root-to-leaf path. One per distinct trajectory, including discarded branches.""" + for leaf in self.leaves(): + yield self.path_to(leaf.node_id) + + def forks(self) -> list[tuple[str, list[str]]]: + """Nodes with more than one child: retries, resamples, or parallel branches.""" + return [(pid, kids) for pid, kids in self._children.items() if len(kids) > 1] + + def discarded_nodes(self) -> list[TurnNode]: + """Sampled turns that led nowhere. + + A sibling with no children whose parent has another child that DID continue was generated and + thrown away: a retry after a parse failure, or a resample. Training it with the rollout's + reward credits work that never happened. Detected purely from shape, with no harness + cooperation, which matters because a proxy is otherwise blind to retries. + + The final turn of a real trajectory is also childless, so a sibling is only called discarded + when at least one of its siblings continued. + + Siblings come in two shapes, and only one of them is a fork. Retrying a MID-conversation call + gives the attempts a shared parent, so `forks()` finds them. Retrying the FIRST call — resampled + before any tool result exists, so both attempts carry the identical prompt — gives two roots + with `parent_id=None`, which is not a fork at all: the abandoned attempt was exported as a full + agent sequence and trained with the rollout's reward. Roots are therefore grouped by their exact + prompt, which is precise rather than heuristic — a subagent or an auxiliary call starts from a + different prompt and never groups with the real first turn. + """ + discarded: list[TurnNode] = [] + sibling_groups: list[list[str]] = [kids for _, kids in self.forks()] + + by_prompt: dict[tuple[int, ...], list[str]] = {} + for node in self.roots(): + by_prompt.setdefault(tuple(node.prompt_ids), []).append(node.node_id) + sibling_groups.extend(group for group in by_prompt.values() if len(group) > 1) + + for kids in sibling_groups: + continued = [k for k in kids if self._children.get(k)] + if not continued: + continue # all siblings are terminal: ambiguous, keep them all + discarded.extend(self._nodes[k] for k in kids if not self._children.get(k)) + return discarded + + # --- flattening ---------------------------------------------------- + def sequence_for(self, leaf_id: str) -> TrainingSequence: + """Flatten one root-to-leaf path into token ids, mask and logprobs. + + Invariant enforced here rather than trusted: a turn whose logprobs are missing or misaligned + contributes its tokens as CONTEXT (mask 0), never as targets. A trainable token without a + real behaviour-policy logprob would make GRPO's importance ratio `exp(new - old)` a ratio + against a number we invented. + """ + path = self.path_to(leaf_id) + input_ids: list[int] = [] + loss_mask: list[int] = [] + logprobs: list[float] = [] + prompt_len = 0 + + parent: TurnNode | None = None + for position, node in enumerate(path): + context = node.context_ids(parent) + input_ids.extend(context) + loss_mask.extend([0] * len(context)) + logprobs.extend([0.0] * len(context)) + if position == 0: + prompt_len = len(context) + + usable = node.sampled_logprobs is not None and len( + node.sampled_logprobs + ) == len(node.sampled_ids) + input_ids.extend(node.sampled_ids) + loss_mask.extend([1 if usable else 0] * len(node.sampled_ids)) + logprobs.extend( + node.sampled_logprobs if usable else [0.0] * len(node.sampled_ids) + ) + parent = node + + return TrainingSequence( + input_ids=input_ids, + loss_mask=loss_mask, + logprobs=logprobs, + node_ids=[n.node_id for n in path], + prompt_len=prompt_len, + root_id=path[0].node_id, + n_turns=len(path), + ) + + def sequences(self) -> list[TrainingSequence]: + return [self.sequence_for(leaf.node_id) for leaf in self.leaves()] + + def stats(self) -> dict[str, Any]: + return { + "n_turns": len(self._order), + "n_roots": len(self.roots()), + "n_leaves": len(self.leaves()), + "n_forks": len(self.forks()), + "n_discarded": len(self.discarded_nodes()), + } diff --git a/src/openenv/core/harness/capture/providers.py b/src/openenv/core/harness/capture/providers.py new file mode 100644 index 0000000000..c95402af4e --- /dev/null +++ b/src/openenv/core/harness/capture/providers.py @@ -0,0 +1,325 @@ +"""Native upstream conversion; independent of the harness-facing wire protocol.""" + +from __future__ import annotations + +import copy +import json +import time +from typing import Any + +from .dialects.images import openai_chat_content_to_anthropic_blocks +from .upstream import UpstreamRequestError + + +class ProviderConversionError(UpstreamRequestError): + """A request cannot be translated without losing semantics; retrying cannot fix it.""" + + +def _content_blocks(content): + if content is None: + return [] + if not isinstance(content, (str, list)): + raise ProviderConversionError( + "Anthropic conversion requires string or block-list content" + ) + if isinstance(content, list): + for part in content: + if isinstance(part, str): + continue + if not isinstance(part, dict) or part.get("type") not in { + "text", + "image_url", + }: + raise ProviderConversionError( + "Anthropic conversion cannot preserve this content block" + ) + if part["type"] == "text" and not isinstance(part.get("text"), str): + raise ProviderConversionError("text content must be a string") + if part["type"] == "image_url": + from .dialects.images import openai_chat_image_to_anthropic + + if openai_chat_image_to_anthropic(part) is None: + raise ProviderConversionError("image content cannot be converted") + return openai_chat_content_to_anthropic_blocks(content) + + +def anthropic_request(request: dict[str, Any], model: str) -> dict[str, Any]: + """Convert canonical chat input, or preserve an original native Messages request.""" + original = request.get("_openenv_native_request") + if original is not None: + body = copy.deepcopy(original) + body.pop("_served_model", None) + body["model"] = model + body["stream"] = False + if "temperature" in body and body.get("top_p") == 1: + body.pop("top_p") + if body.get("top_k") == -1: + body.pop("top_k") + # The proxy's cap remains authoritative for native requests too. + body["max_tokens"] = min( + body.get("max_tokens", 4096), request.get("max_tokens", 4096) + ) + return body + unsupported = [ + k + for k in ("response_format", "logit_bias", "audio", "modalities") + if request.get(k) + ] + for key, neutral in ( + ("frequency_penalty", 0), + ("presence_penalty", 0), + ("repetition_penalty", 1), + ("min_p", 0), + ): + if request.get(key) is not None and request[key] != neutral: + unsupported.append(key) + if unsupported: + raise ProviderConversionError( + "native Anthropic conversion cannot preserve: " + ", ".join(unsupported) + ) + body: dict[str, Any] = { + "model": model, + "max_tokens": request.get( + "max_tokens", request.get("max_completion_tokens", 4096) + ), + "stream": False, + } + for key in ("temperature", "top_p", "top_k"): + if key in request and request[key] is not None: + if (key == "top_k" and request[key] == -1) or ( + key == "top_p" and request[key] == 1 + ): + continue + body[key] = request[key] + if "stop" in request: + stop = request["stop"] + body["stop_sequences"] = [stop] if isinstance(stop, str) else stop + messages = [] + system = [] + for message in request.get("messages", []): + role = message["role"] + content = message.get("content") + if role in ("system", "developer"): + if messages: + raise ProviderConversionError( + "Anthropic cannot preserve a system instruction inserted after conversation turns" + ) + system.extend(_content_blocks(content)) + continue + if role == "tool": + native = { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": message["tool_call_id"], + "content": _content_blocks(content), + } + ], + } + elif role in ("user", "assistant"): + blocks = _content_blocks(content) if content else [] + if message.get("reasoning_content") or message.get("reasoning"): + raise ProviderConversionError( + "Anthropic thinking history requires original signed native blocks" + ) + for call in message.get("tool_calls") or []: + function = call["function"] + args = function.get("arguments", {}) + if isinstance(args, str): + try: + args = json.loads(args) + except ValueError as exc: + raise ProviderConversionError( + "tool arguments must be a JSON object" + ) from exc + if not isinstance(args, dict): + raise ProviderConversionError( + "tool arguments must be a JSON object" + ) + blocks.append( + { + "type": "tool_use", + "id": call["id"], + "name": function["name"], + "input": args, + } + ) + native = {"role": role, "content": blocks} + else: + raise ProviderConversionError(f"unsupported role for Anthropic: {role}") + if messages and messages[-1]["role"] == native["role"]: + messages[-1]["content"].extend(native["content"]) + else: + messages.append(native) + body["messages"] = messages + if system: + body["system"] = system + if request.get("tools"): + tools = [] + for tool in request["tools"]: + if tool.get("type") != "function": + raise ProviderConversionError( + "native Anthropic conversion requires function tools" + ) + function = tool["function"] + tools.append( + { + "name": function["name"], + **({"strict": function["strict"]} if "strict" in function else {}), + "description": function.get("description", ""), + "input_schema": function.get( + "parameters", {"type": "object", "properties": {}} + ), + } + ) + body["tools"] = tools + choice = request.get("tool_choice") + if choice in ("auto", "none", "required"): + body["tool_choice"] = {"type": "any" if choice == "required" else choice} + elif isinstance(choice, dict): + body["tool_choice"] = {"type": "tool", "name": choice["function"]["name"]} + if request.get("parallel_tool_calls") is False and body.get("tools"): + body.setdefault("tool_choice", {"type": "auto"})[ + "disable_parallel_tool_use" + ] = True + return body + + +def anthropic_response( + native: dict[str, Any], *, native_passthrough: bool = False +) -> dict[str, Any]: + """Keep the original response while normalizing text/tool calls for capture.""" + text, reasoning, calls = [], [], [] + if not native_passthrough and native.get("stop_reason") not in { + None, + "end_turn", + "stop_sequence", + "tool_use", + "max_tokens", + }: + raise ProviderConversionError( + "Anthropic stop reason cannot be represented by this harness protocol" + ) + for block in native.get("content", []): + if not native_passthrough and ( + block.get("type") not in {"text", "thinking", "tool_use"} + or block.get("citations") + ): + raise ProviderConversionError( + "Anthropic response block cannot be preserved by this harness protocol: " + + str(block.get("type")) + ) + if block["type"] == "text": + text.append(block["text"]) + elif block["type"] == "thinking": + reasoning.append(block["thinking"]) + elif block["type"] == "tool_use": + calls.append( + { + "id": block["id"], + "type": "function", + "function": { + "name": block["name"], + "arguments": json.dumps(block["input"], ensure_ascii=False), + }, + } + ) + usage = native.get("usage", {}) + prompt = sum( + usage.get(k, 0) + for k in ( + "input_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + ) + ) + output = usage.get("output_tokens", 0) + return { + "id": native["id"], + "object": "chat.completion", + "created": int(time.time()), + "model": native["model"], + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "".join(text), + "reasoning_content": "".join(reasoning) or None, + "tool_calls": calls or None, + }, + "finish_reason": {"tool_use": "tool_calls", "max_tokens": "length"}.get( + native.get("stop_reason"), "stop" + ), + "logprobs": None, + } + ], + "usage": { + "prompt_tokens": prompt, + "completion_tokens": output, + "total_tokens": prompt + output, + }, + "_openenv_native_response": copy.deepcopy(native), + } + + +def replay_anthropic(native: dict[str, Any]): + """Emit SDK-compatible events without replacing native thinking signatures.""" + + def frame(event): + return ( + "event: " + + event["type"] + + "\ndata: " + + json.dumps(event, ensure_ascii=False) + + "\n\n" + ) + + message = { + **native, + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {**native.get("usage", {}), "output_tokens": 0}, + } + yield frame({"type": "message_start", "message": message}) + for index, block in enumerate(native.get("content", [])): + initial = dict(block) + deltas = [] + if block["type"] == "text": + initial["text"] = "" + deltas.append({"type": "text_delta", "text": block["text"]}) + elif block["type"] == "tool_use": + initial["input"] = {} + deltas.append( + { + "type": "input_json_delta", + "partial_json": json.dumps(block["input"], ensure_ascii=False), + } + ) + elif block["type"] == "thinking": + initial.update(thinking="", signature="") + deltas.extend( + [ + {"type": "thinking_delta", "thinking": block["thinking"]}, + {"type": "signature_delta", "signature": block["signature"]}, + ] + ) + yield frame( + {"type": "content_block_start", "index": index, "content_block": initial} + ) + for delta in deltas: + yield frame({"type": "content_block_delta", "index": index, "delta": delta}) + yield frame({"type": "content_block_stop", "index": index}) + yield frame( + { + "type": "message_delta", + "delta": { + "stop_reason": native.get("stop_reason"), + "stop_sequence": native.get("stop_sequence"), + }, + "usage": {"output_tokens": native.get("usage", {}).get("output_tokens", 0)}, + } + ) + yield frame({"type": "message_stop"}) diff --git a/src/openenv/core/harness/capture/runner.py b/src/openenv/core/harness/capture/runner.py new file mode 100644 index 0000000000..f637afd96f --- /dev/null +++ b/src/openenv/core/harness/capture/runner.py @@ -0,0 +1,179 @@ +# Copyright 2026 The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the capture proxy in-process, for any environment that needs one. + +MOVED HERE FROM `openenv.harbor.runner` DELIBERATELY. +`CaptureServer` never touched a Harbor type -- it wraps `create_app`, binds a port and hands back the +live `SessionRegistry`. Living under `harbor/` meant a second environment wanting an in-process proxy +had to either import Harbor (dragging in its models, task resolution and rollout engine for a class +that uses none of them) or hand-roll HTTP calls to something already running in the same process. +Both are worse than moving it. `openenv.harbor.runner` re-exports it, so existing imports keep +working. + +A THREAD, NOT A SUBPROCESS, and that is the point: the rollout path needs the live registry object so +it can mint a session and then read the graph straight back out of it. Going through HTTP for that +would add a serialisation round trip and a failure mode for no benefit. +""" + +from __future__ import annotations + +import contextlib +import socket +import threading +import time +from typing import Any + +from .server import create_app + + +def _require_free_port(port: int) -> None: + """Raise if anything is already listening on `port`, naming the holder when we can find it.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind(("0.0.0.0", port)) + except OSError as exc: + raise RuntimeError( + f"capture port :{port} is already in use ({exc.strerror}). {_port_holder(port)}" + " Stop it or pass a different port: a second server on this port cannot bind, and " + "the agent would silently talk to the older one." + ) from exc + + +def _port_holder(port: int) -> str: + """Best-effort description of the process holding `port`, for the error message only.""" + import shutil + import subprocess + + if not shutil.which("ss"): + return "" + with contextlib.suppress(Exception): + out = subprocess.run( + ["ss", "-ltnp"], capture_output=True, text=True, timeout=5 + ).stdout + for line in out.splitlines(): + if f":{port} " in line and "users:" in line: + return f"Held by {line.split('users:', 1)[1].strip()}." + return "" + + +def _health_instance(port: int) -> str | None: + """Instance id reported by whatever is serving `port`, or `None` if nothing answers yet.""" + import httpx + + with contextlib.suppress(Exception): + resp = httpx.get(f"http://127.0.0.1:{port}/health", timeout=2.0) + if resp.status_code == 200: + return str(resp.json().get("instance") or "unknown") + + +class CaptureServer: + """The capture proxy, running in a background thread for the life of a batch. + + A thread rather than a subprocess because the rollout path needs the live `SessionRegistry` — it + mints a session, then reads the graph back out of it directly. Going through HTTP for that would + add a serialisation round trip and a failure mode for no benefit. + """ + + def __init__( + self, + *, + llm_url: str, + model: str, + port: int = 8100, + max_output_tokens: int = 8192, + api_key: str | None = None, + auth_header: str = "Authorization", + capture_level: str = "tokens", + provider: str = "openai", + admin_key: str | None = None, + max_model_calls: int = 0, + ) -> None: + self.app = create_app( + llm_url=llm_url, + model=model, + max_output_tokens=max_output_tokens, + api_key=api_key, + auth_header=auth_header, + capture_level=capture_level, + provider=provider, + admin_key=admin_key, + max_model_calls=max_model_calls, + ) + self.capture_level = "text" if provider == "anthropic" else capture_level + self.admin_key = admin_key + self.port = port + self._thread: threading.Thread | None = None + self._server: Any = None + + @property + def registry(self) -> Any: + return self.app.state.registry + + @property + def inference(self) -> Any: + """The upstream client, for reading back what it had to work around. See `param_fixes`.""" + return self.app.state.inference + + def start(self, timeout_s: float = 30.0) -> None: + """Bind the port and confirm that the process answering on it is *this* one. + + Raises: + RuntimeError: + If the port is already held, or if the server that comes up on it is not ours. + """ + import uvicorn + + # Fail before uvicorn does. Its bind error surfaces on a background thread, where nothing + # observes it, and the port stays served by whoever holds it. + _require_free_port(self.port) + + config = uvicorn.Config( + self.app, host="0.0.0.0", port=self.port, log_level="warning" + ) + self._server = uvicorn.Server(config) + self._thread = threading.Thread(target=self._server.run, daemon=True) + self._thread.start() + + # Reachability is not identity. A stale process on this port answers every probe, so the + # check is that /health reports our own instance id. + want = self.app.state.instance_id + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if not self._thread.is_alive(): + raise RuntimeError( + f"capture server thread exited while starting on :{self.port} " + "(most likely the port was taken between the check and the bind)" + ) + got = _health_instance(self.port) + if got == want: + return + if got is not None: + raise RuntimeError( + f"port :{self.port} is served by a different capture server (instance {got}, " + f"expected {want}). Stop the process holding it, or pass a different port; " + "sessions minted here would be rejected there and every rollout would see " + "no model calls." + ) + time.sleep(0.1) + raise RuntimeError( + f"capture server did not come up on :{self.port} within {timeout_s:.0f}s" + ) + + def stop(self) -> None: + if self._server is not None: + self._server.should_exit = True + if self._thread is not None: + self._thread.join(timeout=10) diff --git a/src/openenv/core/harness/capture/server.py b/src/openenv/core/harness/capture/server.py new file mode 100644 index 0000000000..becc3e0039 --- /dev/null +++ b/src/openenv/core/harness/capture/server.py @@ -0,0 +1,1444 @@ +"""The intercept server. + + input an OpenAI-spec endpoint you already host (vLLM or SGLang) + the served model name + output per rollout, a JSON document of exact token ids, logprobs and loss masks, ready to train + +In between: a coding agent points at this URL, in whichever wire dialect it speaks, and +nothing about the agent changes except a base URL and an API key. + + agent (in an E2B sandbox, any of ~37) + | OPENAI_BASE_URL / ANTHROPIC_BASE_URL / provider config = this server + | API key = the rollout's session id <- the entire multiplexing scheme + v + THIS --detect dialect--> normalise to chat --inject capture params--> your engine + <--replay in the agent's dialect (SSE if it asked for SSE)---------┘ + | + └─ each call becomes a node in the rollout graph, linked by token prefix + +WHY IT CAPTURES FAITHFULLY: we never tokenize. The engine tokenizes each prompt to serve it and hands +back `prompt_token_ids`, so turn k+1's prompt is the canonical tokenization of everything up to that +point, tool results included. Completions come back as sampled ids with aligned logprobs. Stitching +those along a graph path reproduces exactly what the model saw and produced, with no local chat +template involved. See `graph.py`. + +TWO ASYMMETRIES, both learned from real failures: + + * **Capture is non-streaming, the reply is whatever the client asked for.** One complete response + carries ids and logprobs whole; reassembling them from SSE deltas is error-prone in exactly the + way that silently corrupts training data. But a harness that requested SSE and receives a JSON + body does not error, it yields nothing: opencode reported `step-finish reason:"unknown"`, zero + tokens, no error, having been handed a perfectly valid tool call. See `sse.py`. + * **We validate on ingest, not on export.** A turn whose logprobs are misaligned must be caught + while we still know which turn it was. + +Run: python -m intercept.server --llm-url http://127.0.0.1:8000 --model Qwen3.5-9B +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import secrets +import threading +import uuid +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import replace +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from . import sse +from .contract import BUDGET_STOP_MESSAGE, to_trace_entries +from .detection import APIType, detect +from .dialects import TransformManager +from .export import export_session +from .graph import TurnNode +from .sessions import ( + evaluation_sampling, + extract_api_key, + extract_harness_session, + rollout_type_for, + SessionRegistry, + Upstream, +) +from .upstream import ( + InferenceClient, + training_sampling, + truncating_params, + UpstreamError, + UpstreamHTTPError, + UpstreamRequestError, +) +from .validate import check_turn, check_turn_eval + +logger = logging.getLogger("intercept") + + +def _system_digest(messages: list[dict[str, Any]]) -> str | None: + """Cheap identity for 'which conversation is this'. Recorded, never used for routing.""" + import hashlib + + for message in messages or []: + if message.get("role") == "system": + content = message.get("content") + if isinstance(content, list): # anthropic / responses send block lists + content = " ".join( + p.get("text", "") for p in content if isinstance(p, dict) + ) + if isinstance(content, str) and content: + return hashlib.sha256(content.encode()).hexdigest()[:16] + return None + + +# Routes an agent calls that are NOT model turns. They must be answered, but must never become graph +# nodes: recording them adds a bogus root and corrupts the trajectory structure. +# +# Borrowed from verifiers, whose Dialect ABC carries `aux_routes` for exactly this +# (v1/dialects/anthropic.py:273, "relayed as native JSON, never recorded on the trace"). +# claude-code calls count_tokens before sending a turn; without this the catch-all would hand it to +# `transform_request`, forward nonsense upstream, and file the result as a model call. +# Each entry maps a route suffix to the DIALECT whose reply shape the caller expects. Answering an +# aux route in the wrong shape is its own bug: gemini-cli's `:countTokens` used to fall through to the +# catch-all, get detected as GOOGLE, turn into a real chat completion, land in the graph as a bogus +# root that inflated n_turns, and hand the caller a `{"candidates": [...]}` envelope where it wanted +# `{"totalTokens": N}`. +AUX_ROUTES: dict[str, str] = { + "/v1/messages/count_tokens": "anthropic", + ":counttokens": "google", +} + + +def aux_dialect(path: str) -> str | None: + """Which dialect's token-count reply this path expects, or `None` if it is not an aux route. + + Google puts the method after a colon on the model path + (`/v1beta/models/gemini-2.5-pro:countTokens`), so suffix matching has to be case-insensitive + rather than an exact path compare. + """ + normalised = ("/" + path.lstrip("/")).lower() + for route, dialect in AUX_ROUTES.items(): + if normalised.endswith(route): + return dialect + return None + + +def is_aux_route(path: str) -> bool: + return aux_dialect(path) is not None + + +def aux_token_count_response(dialect: str, count: int) -> dict[str, Any]: + """The token-count reply in the shape the calling dialect expects.""" + if dialect == "google": + # gemini-cli reads `totalTokens`; the other two fields are part of the documented response and + # cheap to include rather than have a client guess at their absence. + return { + "totalTokens": count, + "totalBillableCharacters": count * 4, + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": count}], + } + return {"input_tokens": count} + + +def approximate_token_count(body: dict[str, Any]) -> int: + """Answer a count_tokens request without a tokenizer. + + We deliberately do not load one: the whole design keeps tokenization on the engine, and pulling a + tokenizer in here just to serve a side request would reintroduce the "two sources of truth" + problem this architecture exists to avoid. Agents use this figure for context-budget decisions, + not for anything that reaches training, so a ~4-chars-per-token estimate is sufficient. If a + harness turns out to depend on exactness, forward it to the engine's /tokenize endpoint instead. + """ + text_len = 0 + for message in body.get("messages") or []: + content = message.get("content") + if isinstance(content, str): + text_len += len(content) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict): + text_len += len(str(part.get("text") or part.get("content") or "")) + system = body.get("system") + if isinstance(system, str): + text_len += len(system) + elif isinstance(system, list): + text_len += sum( + len(str(p.get("text", ""))) for p in system if isinstance(p, dict) + ) + + # Google puts the conversation in `contents` with `parts`, and the system prompt in + # `systemInstruction` — neither of which the OpenAI/Anthropic branches above look at. Without this + # a `:countTokens` call collapsed to the `max(1, ...)` floor and answered 1 on every request, so + # gemini-cli got a useless context-budget signal and could mismanage compaction mid-rollout. The + # aux route answers Google now, so the estimator has to speak Google too. + for content in body.get("contents") or []: + if not isinstance(content, dict): + continue + for part in content.get("parts") or []: + if isinstance(part, dict): + text_len += len(str(part.get("text") or "")) + elif isinstance(part, str): + text_len += len(part) + instruction = body.get("systemInstruction") or body.get("system_instruction") + if isinstance(instruction, dict): + for part in instruction.get("parts") or []: + if isinstance(part, dict): + text_len += len(str(part.get("text") or "")) + elif isinstance(instruction, str): + text_len += len(instruction) + + # Tool manifests count too, under each dialect's own spelling. + text_len += len(str(body.get("tools") or "")) + return max(1, text_len // 4) + + +def wants_stream(path: str, body: dict[str, Any]) -> bool: + """Did the client ask for SSE? Each dialect says so differently. + + OpenAI chat, Responses and Anthropic all set `stream: true` in the body. **Google does not.** It + signals streaming in the URL: `:streamGenerateContent`, usually with `?alt=sse`. gemini-cli calls + + POST /v1beta/models/:streamGenerateContent?alt=sse + + with no `stream` key anywhere in the body, so a body-only check returns False and we answer a + streaming request with a plain JSON document. It arrives as HTTP 200 and the client dies parsing + it: + + Error: Incomplete JSON segment at the end + at ApiClient.processStreamResponse_1 (@google/gemini-cli/...) + + Same failure family as opencode's silent `reason:"unknown"`: a valid-looking response in the + wrong envelope. verifiers models this as a per-dialect `Dialect.streaming(body)`; this is the + same idea kept to one function. + """ + if body.get("stream") is True: + return True + lowered = path.lower() + return "streamgeneratecontent" in lowered or "alt=sse" in lowered + + +_MAX_TOKENS_KEYS = ("max_tokens", "max_completion_tokens", "max_output_tokens") + +# Sampling knobs that alter the distribution a processed logprob is taken over. Recorded per turn so a +# trainer can tell whether its own recompute is comparable; see `TurnNode.sampling_params`. +SAMPLING_KEYS = ( + "temperature", + "top_p", + "top_k", + "min_p", + "frequency_penalty", + "presence_penalty", + "repetition_penalty", +) + + +def clamp_output_tokens(chat_request: dict[str, Any], cap: int | None) -> int | None: + """Cap the requested output length so prompt + completion fits the served context window. + + Harnesses ask for absurd output budgets. qwen-coder requests **64000** output tokens, which on a + 65536-token model leaves room for a 1536-token prompt and then fails on the next character: + + maximum context length is 65536 tokens. However, you requested 64000 output tokens and + your prompt contains at least 1537 input tokens, for a total of at least 65537 + + Every call 502s, the agent does nothing, and it presents as "reached the intercept, captured + nothing". Polar caps this too (`proxy_max_tokens_cap = 16384`, noting opencode's ~32000 default + "exceeds some provider limits"), so it is a known hazard rather than one harness misbehaving. + + A fixed cap rather than `context - len(prompt)`: computing the latter needs a tokenizer here, and + keeping tokenization on the engine is the whole design. Agent turns are short (the longest seen + across every validated harness is 874 tokens), so a few thousand is generous. + + Returns the value it replaced, for logging, or None if nothing changed. + """ + if not cap: + return None + replaced = None + for key in _MAX_TOKENS_KEYS: + value = chat_request.get(key) + if isinstance(value, int) and value > cap: + chat_request[key] = cap + replaced = max(replaced or 0, value) + if not any(chat_request.get(key) is not None for key in _MAX_TOKENS_KEYS): + chat_request["max_tokens"] = cap + return replaced + + +def normalise_for_capture(chat_request: dict[str, Any]) -> None: + """Force the upstream call into the one shape that yields complete, capturable responses. + + `stream_options` is not cosmetic: vLLM validates it against `stream` and rejects the pair with + "Stream options can only be defined when `stream=True`", which 400s the ENTIRE request. opencode + sends it on every call, so leaving it in is a total outage rather than a degradation. + + Both keys are set here rather than left to the inference client, because the client rewrites + `stream` only after this point: a check against the incoming value sees `true` and leaves + `stream_options` behind, which is precisely the bug this exists to prevent. + + An empty `tools` array is dropped for the same reason. vLLM rejects it outright: + + `tools` must not be an empty array. Either provide at least one tool or omit the field + entirely. + + kimi-cli sends `tools: []` once its agent loop has no tools left to offer, which 400s the call. + The two forms mean the same thing to the model, so dropping the key is lossless and keeps the + rollout alive rather than truncating it mid-trajectory. + + Everything that only makes sense ALONGSIDE `tools` has to go with it. Dropping the list and + leaving its companions behind trades one provider's 400 for another's: + + Invalid value for 'parallel_tool_calls': 'parallel_tool_calls' is only allowed when + 'tools' are specified. + + That is codex against OpenAI, every call, for the whole rollout — it sends `tools: []` plus + `parallel_tool_calls`, so stripping only the list left the orphan behind. vLLM ignores the orphan, + which is why this survived until a hosted provider was tried. + """ + chat_request["stream"] = False + chat_request.pop("stream_options", None) + for key in ("tools", "functions"): + if key in chat_request and not chat_request[key]: + chat_request.pop(key) + # `tool_choice` and `parallel_tool_calls` are equally invalid without `tools`, and meaningless + # once the list is gone. + if "tools" not in chat_request: + chat_request.pop("tool_choice", None) + chat_request.pop("parallel_tool_calls", None) + + +def normalise_response(response: dict[str, Any]) -> None: + """Fill in usage sub-objects that vLLM leaves null but the OpenAI schema always returns. + + vLLM returns `"prompt_tokens_details": null` when prefix caching is off. OpenAI always returns + the object, so a harness that reads `usage.prompt_tokens_details.cached_tokens` without guarding + gets an AttributeError. trae-agent does exactly that and dies after its FIRST call: + + 'NoneType' object has no attribute 'cached_tokens' + + which produced a clean single-turn capture and a task the agent never attempted. + + This touches ONLY accounting fields. No token id, logprob or message content is altered, so it + cannot affect what gets captured or trained. It is a compatibility shim that makes us MORE + OpenAI-conformant than the engine behind us, which is the safe direction: a client that already + guarded for null sees a zeroed object instead, which reads the same. + """ + usage = response.get("usage") + if not isinstance(usage, dict): + return + if usage.get("prompt_tokens_details") is None: + usage["prompt_tokens_details"] = {"cached_tokens": 0, "audio_tokens": 0} + if usage.get("completion_tokens_details") is None: + usage["completion_tokens_details"] = { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0, + } + + +def normalise_client_payload(payload: dict[str, Any], api_type: APIType) -> None: + """Fill in usage sub-objects the OUTBOUND dialect promises but the transformer omits. + + Sibling of `normalise_response`, one layer further out. That one repairs the chat-completions + usage we get FROM vLLM; this repairs the usage we hand TO the client after translation. + + Polar's Responses transformer builds usage as exactly + `{"input_tokens", "output_tokens", "total_tokens"}` (transform/openai_responses.py:43), with no + detail sub-objects. The real Responses API always returns them, and trae-agent reads them without + a guard (trae_agent/utils/llm_clients/openai_client.py): + + cache_read_input_tokens=response.usage.input_tokens_details.cached_tokens or 0, + reasoning_tokens=response.usage.output_tokens_details.reasoning_tokens or 0, + + so `input_tokens_details` is None and it dies with + `'NoneType' object has no attribute 'cached_tokens'` after its FIRST call. + + Note this is why trae-agent looked like a chat-completions harness for a whole night: its seam + says `openai_chat`, but the access log shows exactly one `POST /v1/responses` against 465 + chat-completions calls. It speaks Responses. + + Accounting fields only. No token id, logprob or content is touched, so capture is unaffected. + """ + usage = payload.get("usage") + if not isinstance(usage, dict): + return + if api_type is APIType.OPENAI_RESPONSES: + if usage.get("input_tokens_details") is None: + usage["input_tokens_details"] = {"cached_tokens": 0} + if usage.get("output_tokens_details") is None: + usage["output_tokens_details"] = {"reasoning_tokens": 0} + + +class UpstreamPool: + """One inference client and one capability probe per distinct engine. + + The engine a rollout talks to is a per-SESSION property, not a per-server one: a dataset server is + long-lived (thousands of task files, prebuilt sandbox templates) while a vLLM restarts every + training run, and a train-tier engine and an eval-tier one are usually both wanted at once. So + callers name their engine when they mint a session, and this pool makes that cheap. + + Two things it exists to avoid: + + * **Re-probing.** Deciding a tier means sending real completions (`validate_llm`). Doing that per + session would add several round trips to every rollout, so the measurement is cached per + `(url, model, auth_header, credential_digest)` and shared by matching sessions. + * **Client churn.** One `InferenceClient` per engine rather than per rollout, so connection + pooling and the discovered `param_fixes` are shared. + + The tier is MEASURED, never assumed: an engine that cannot be probed is `text`, the weakest, so a + rollout is never stamped trainable without evidence. + """ + + _probes = ThreadPoolExecutor(max_workers=4, thread_name_prefix="capture-probe") + + def __init__(self, *, default_client: InferenceClient, default_level: str) -> None: + self._default = (default_client, default_level) + self._by_engine: dict[ + tuple[str, str, str, str, str], tuple[InferenceClient, str] + ] = {} + self._pending: dict[tuple[str, str, str, str, str], Future] = {} + self._lock = threading.Lock() + + @property + def default(self) -> tuple[InferenceClient, str]: + """The engine this server was booted with, for sessions that name none.""" + return self._default + + def known(self) -> list[dict[str, Any]]: + """What has been probed so far, for `/health`. Never includes credentials.""" + with self._lock: + return [ + { + "llm_url": url, + # The client's model, not the key's: a caller may have left it blank for a + # single-model endpoint and the probe resolved it, so the key holds "" while the + # requests actually being sent carry the real name. + "model": client.served_model or "", + "capture_level": level, + } + for (url, _requested, _header, _credential, _provider), ( + client, + level, + ) in self._by_engine.items() + ] + + async def resolve(self, upstream: Upstream) -> tuple[InferenceClient, str]: + """Client and measured capture level for `upstream`, probing once per engine.""" + key = upstream.cache_key + with self._lock: + hit = self._by_engine.get(key) + if hit is not None: + return hit + pending = self._pending.get(key) + if pending is None: + pending = self._probes.submit(self._resolve_once, upstream) + self._pending[key] = pending + # Shield shared work: cancelling one waiter must not cancel another rollout's probe. + return await asyncio.shield(asyncio.wrap_future(pending)) + + def _resolve_once(self, upstream: Upstream) -> tuple[InferenceClient, str]: + key = upstream.cache_key + try: + model, level = self._probe(upstream) + client = InferenceClient( + base_url=upstream.llm_url.rstrip("/"), + served_model=model, + api_key=upstream.api_key, + auth_header=upstream.auth_header, + capture_level=level, + provider=upstream.provider, + ) + with self._lock: + self._by_engine[key] = (client, level) + return client, level + finally: + with self._lock: + self._pending.pop(key, None) + + def _probe(self, upstream: Upstream) -> tuple[str, str]: + """`(served_model, capture_level)`. Blocking; always called on a worker thread.""" + from .validate_llm import list_models, validate_llm + + model = upstream.model + try: + if not model: + served = list_models( + upstream.llm_url, + api_key=upstream.api_key, + auth_header=upstream.auth_header, + ) + # Only when unambiguous. Guessing here makes the proxy rewrite `model` to the wrong + # name, and every call then fails upstream for a reason that mentions neither. + model = served[0] if len(served) == 1 else "" + if not model: + logger.warning( + "upstream %s serves several models and none was named; capture level is 'text'", + upstream.llm_url, + ) + return "", "text" + report = validate_llm( + upstream.llm_url, + model, + api_key=upstream.api_key, + auth_header=upstream.auth_header, + provider=upstream.provider, + ) + level = report.capture_level or "text" + logger.warning( + "probed upstream %s (%s): capture_level=%s rollout_type=%s", + upstream.llm_url, + model, + level, + report.rollout_type, + ) + return model, level + except Exception as exc: # noqa: BLE001 - an unreachable engine is a tier, not a crash + logger.warning( + "could not probe upstream %s: %s: %s; capture level is 'text'", + upstream.llm_url, + type(exc).__name__, + exc, + ) + return model, "text" + + +def _budget_stop_response(model: str) -> dict[str, Any]: + """A terminal chat-completion, in the shape every dialect transformer expects on the way out. + + The content is NON-EMPTY on purpose. An empty assistant message reads as a failed generation and + the harness retries it, so the proxy answers the stop again and the rollout spins until its + timeout -- measured, with opencode looping on exactly this. It is deliberately a statement about + the BUDGET rather than about the task: it lands in the harness's own transcript, and capture never + records it, so it must not look like something the model chose to say about the work. + """ + return { + "id": f"budget-{uuid.uuid4().hex[:12]}", + "object": "chat.completion", + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": BUDGET_STOP_MESSAGE, + "tool_calls": None, + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + + +def create_app( + *, + llm_url: str = "", + model: str | None = None, + engine: str = "", + require_registered: bool = True, + max_output_tokens: int | None = 8192, + api_key: str | None = None, + auth_header: str = "Authorization", + provider: str = "openai", + capture_level: str = "tokens", + admin_key: str | None = None, + max_model_calls: int = 0, +) -> FastAPI: + """The capture proxy as an ASGI app. + + Args: + llm_url (`str`): + The OpenAI-spec endpoint to forward to. + model (`str`, *optional*): + Served model id to send upstream, overriding whatever the agent asked for. + engine (`str`, *optional*): + What is on the other end, for `/health` only. Defaults to `"unknown"` there rather than + to `"vllm"`: the proxy cannot tell, nothing passes it on the harbor path, and a hosted + endpoint reported as vLLM reads as a claim about capture that `capture_level` then + contradicts. + require_registered (`bool`, *optional*, defaults to `True`): + Reject callers whose API key is not a minted session id. This port may be public. + max_output_tokens (`int`, *optional*, defaults to `8192`): + Cap on requested completion length; `0` or `None` disables. + api_key (`str`, *optional*): + Credential for the *upstream*. Never the agent-facing key — that one is the session id, + and the sandbox never sees this value. + auth_header (`str`, *optional*, defaults to `"Authorization"`): + Header to send `api_key` under. + capture_level (`str`, *optional*, defaults to `"tokens"`): + What the upstream can return, as decided by `validate_llm`. Below `tokens` every rollout + from this app is an eval rollout. + admin_key (`str`, *optional*): + Required by the session-management routes when set. Leave unset for a private port; set it + whenever this app is reachable from outside, which `serve` does automatically. + max_model_calls (`int`, *optional*, defaults to `0`): + Default ceiling on model calls per session; `0` is unlimited, and a session may name its + own. Once a rollout reaches it the proxy answers a terminal completion itself, which ends + the agent's loop without recording a turn the model never generated. Worth setting on any + training deployment: no agent harness honours its own step config (opencode 1.18.30 ran 61 + model calls at `steps=3`, `maxSteps=3` and unset alike), and one runaway rollout holds its + whole GRPO group hostage. + """ + if provider == "anthropic": + capture_level = "text" + app = FastAPI(title="openenv-capture") + + # Identifies this app instance on /health. A caller that binds a port cannot tell "my server is + # up" from "someone else's server already held this port" by connecting alone, and answering the + # wrong process is silent: sessions are minted here and rejected there, so the agent gets 401 and + # the rollout reports no model calls. + app.state.instance_id = uuid.uuid4().hex + # `None` when the server was booted without an engine. That is a supported state, not a broken + # one: the datasets are what makes this server worth keeping alive, and the engine is a per-rollout + # detail that arrives with the session. A session that names no engine and finds no default gets a + # clear 503 rather than calls to an empty base URL. + app.state.inference = ( + InferenceClient( + base_url=llm_url.rstrip("/"), + served_model=model, + api_key=api_key, + auth_header=auth_header, + capture_level=capture_level, + provider=provider, + ) + if llm_url + else None + ) + app.state.transforms = TransformManager() + app.state.registry = SessionRegistry(require_registered=require_registered) + app.state.model = model + app.state.max_model_calls = max_model_calls + app.state.llm_url = llm_url + app.state.max_output_tokens = max_output_tokens + app.state.capture_level = capture_level + # Per-session engines. The client built above is the DEFAULT, used by sessions that name none, so + # a server booted with --llm-url behaves exactly as before. + app.state.upstreams = UpstreamPool( + default_client=app.state.inference, default_level=capture_level + ) + app.state.admin_key = admin_key or None + + async def _upstream_for(session) -> tuple[InferenceClient, str]: + """The engine this session's calls go to, and the level it was measured at.""" + if session.upstream is not None: + return await app.state.upstreams.resolve(session.upstream) + return app.state.upstreams.default + + def _model_of(session) -> str: + """The model name to send upstream for this session. + + The proxy rewrites `model` because harnesses mangle it: opencode is configured with + `intercepted/` and the provider layer forwards only the last path segment, so what + arrives is `Qwen3.5-2B` where the engine serves `Qwen/Qwen3.5-2B` and answers 404. Rewriting + it is therefore not a nicety, it is what makes the call work at all. + + With the engine per session, the name has to come from the SESSION's engine. Reading the + server default here is what broke an engineless server: `app.state.model` was empty, the + rewrite was skipped, and the agent's mangled name went upstream untouched. + """ + if ( + session is not None + and session.upstream is not None + and session.upstream.model + ): + return session.upstream.model + return app.state.model or "" + + def _level_of(session) -> str: + """A session's capture level without touching the network. + + Reads what the probe recorded on the session, falling back to the server default. Used where + the level is needed but no upstream call is being made — exporting, for instance. + """ + return session.capture_level or app.state.capture_level + + @app.get("/health") + async def health() -> dict[str, Any]: + return { + "status": "ok", + "instance": app.state.instance_id, + "upstream": llm_url, + "engine": engine or "unknown", + "model": app.state.model, + "sessions": len(app.state.registry.list_ids()), + "require_registered": app.state.registry.require_registered, + # What this proxy can actually produce, so a caller never has to infer it from the + # engine name. `upstream_auth` is a boolean on purpose: the key itself must not be + # readable from an endpoint that, on a Space, is public. + "capture_level": app.state.capture_level, + "rollout_type": "train" if app.state.capture_level == "tokens" else "eval", + # Engines named per session and already probed. A caller can see what this server + # measured without minting a session to find out. + "upstreams": app.state.upstreams.known(), + "upstream_auth": bool(app.state.inference and app.state.inference.api_key), + "param_fixes": ( + [str(f) for f in app.state.inference.param_fixes] + if app.state.inference + else [] + ), + } + + def _admin_ok(request: Request) -> bool: + """Whether a caller may use the session-management routes. + + The registered-session check guards the catch-all proxy route and nothing else, which is fine + while this app owns a private port and wrong once it is mounted at `/capture` on a public + Space: `GET /sessions` enumerated every live rollout, `GET /sessions/{id}/rollout` returned its + full token-level training data, `DELETE` ended it, and `POST /sessions` let anyone mint a key + that the proxy would then honour — turning the endpoint into the open relay the 401 exists to + prevent. + + Gated on the ADMIN key rather than a session id, because these routes are the trainer's + control plane, not the agent's data plane. `admin_key` defaults to unset, which keeps a + local run on a private port exactly as convenient as before; `serve`/`push` set it whenever + the proxy is reachable from outside. + """ + expected = app.state.admin_key + if not expected: + return True + offered = extract_api_key(dict(request.headers)) or "" + # Constant-time: these are short strings and the comparison is cheap, but a timing oracle on a + # public endpoint is free to exploit and free to close. + return secrets.compare_digest(offered, expected) + + def _forbidden() -> JSONResponse: + return JSONResponse( + { + "error": { + "message": "this route requires the capture admin key", + "type": "invalid_request_error", + } + }, + status_code=401, + ) + + def _invalid_request(message: str) -> JSONResponse: + return JSONResponse( + {"error": {"message": message, "type": "invalid_request_error"}}, + status_code=400, + ) + + @app.post("/sessions") + async def create_session( + request: Request, payload: dict[str, Any] | None = None + ) -> Any: + """Mint a rollout id. Hand it to the agent as its API key; that is the whole integration. + + The caller may also name the engine this rollout should use — `llm_url`, and optionally + `model`, `api_key`, `auth_header`. That engine is PROBED HERE, before the session is handed + back, and the measured tier is returned with it. So a caller learns whether it is going to get + a trainable rollout at submit time, not minutes later when the token fields come back empty. + + Omitting `llm_url` uses the server's default engine, which is what a server booted with + `--llm-url` has always done. + """ + if not _admin_ok(request): + return _forbidden() + payload = payload or {} + metadata = payload.get("metadata") + if metadata is not None and not isinstance(metadata, dict): + return _invalid_request("metadata must be a JSON object") + metadata = metadata or {} + reserved = { + "session_id", + "upstream", + "capture_level", + "max_model_calls", + "sampling", + "purpose", + "eval_sampling", + } & metadata.keys() + if reserved: + noun = "key" if len(reserved) == 1 else "keys" + return _invalid_request( + f"metadata cannot include reserved {noun}: {', '.join(sorted(reserved))}" + ) + budget = payload.get("max_model_calls", app.state.max_model_calls) + if type(budget) is not int or budget < 0: + return _invalid_request("max_model_calls must be a non-negative integer") + try: + policy = training_sampling(payload.get("sampling")) + except ValueError as exc: + return _invalid_request(str(exc)) + upstream = None + level = "" + llm_url = str(payload.get("llm_url") or "").strip() + if llm_url: + upstream = Upstream( + llm_url=llm_url, + model=str(payload.get("model") or "").strip(), + api_key=payload.get("api_key") or None, + auth_header=str(payload.get("auth_header") or "Authorization"), + provider=str(payload.get("provider") or "openai"), + ) + # Probe now. The cache means this costs round trips only for an engine never seen before, + # so a whole GRPO group naming the same vLLM pays for it once. + client, level = await app.state.upstreams.resolve(upstream) + # Carry the model the probe settled on: the caller may have left it blank for a + # single-model endpoint, and the proxy needs the resolved name to rewrite requests. + upstream = replace(upstream, model=client.served_model or upstream.model) + purpose = payload.get("purpose", "auto") + try: + rollout_type = rollout_type_for(purpose, level or app.state.capture_level) + eval_policy = evaluation_sampling(payload.get("eval_sampling")) + if eval_policy and purpose != "eval": + raise ValueError("eval_sampling requires explicit eval purpose") + if purpose == "eval" and payload.get("sampling") is not None: + raise ValueError( + "eval purpose cannot apply a training sampling override" + ) + except (ValueError, TypeError) as exc: + return _invalid_request(str(exc)) + session = app.state.registry.create( + payload.get("session_id"), + purpose=purpose, + eval_sampling=eval_policy, + upstream=upstream, + capture_level=level, + # Falls back to the server default, so a deployment can cap every rollout without its + # callers knowing, and a caller can still tighten it per rollout. + max_model_calls=budget, + sampling=policy or None, + **metadata, + ) + effective = _level_of(session) + return { + "session_id": session.session_id, + "capture_level": effective, + "rollout_type": rollout_type, + "max_model_calls": session.max_model_calls, + "llm_url": upstream.llm_url if upstream else app.state.llm_url, + "model": upstream.model if upstream else app.state.model, + } + + @app.get("/sessions") + async def list_sessions(request: Request) -> Any: + if not _admin_ok(request): + return _forbidden() + return {"sessions": app.state.registry.summary()} + + @app.get("/sessions/{session_id}") + async def session_status(session_id: str, request: Request) -> Any: + if not _admin_ok(request): + return _forbidden() + """Live progress. `idle_s` is the cheapest wedge detector: turns arriving means progress.""" + session = app.state.registry.get(session_id) + if session is None: + return JSONResponse({"error": "unknown session"}, status_code=404) + return { + "session_id": session_id, + "idle_s": round(session.idle_seconds, 1), + "upstream_errors": session.upstream_errors, + **session.graph.stats(), + } + + @app.get("/sessions/{session_id}/rollout") + async def rollout( + session_id: str, + request: Request, + include_discarded: bool = False, + include_messages: bool = False, + ) -> Any: + """THE training endpoint: stitched, masked, logprob-aligned, validated.""" + if not _admin_ok(request): + return _forbidden() + session = app.state.registry.get(session_id) + if session is None: + return JSONResponse({"error": "unknown session"}, status_code=404) + return export_session( + session, + include_discarded=include_discarded, + include_messages=include_messages, + capture_level=_level_of(session), + ) + + @app.get("/sessions/{session_id}/trace_entries") + async def trace_entries(session_id: str, request: Request) -> Any: + """The LOOP-OWNING training endpoint: `list[TraceEntry]`, one per captured model call. + + `/rollout` returns the stitched document (input_ids + loss_mask + logprobs) for a consumer + that trains on whole sequences. A loop-owning consumer wants the per-call records instead -- + the agent drove its own tool loop, so the unit is "one model call" and it re-derives the + prompt from `request`. That is what `LoopOwningSession.fetch_proxy_trace` is defined to + return, and `to_trace_entries` already produced this shape; it just had no way to be asked, + because it needs the session's graph and that is server-side state. + + Same registry as every other session here, so this serves a rollout from ANY harness -- + opencode, codex, claude-code, a Harbor task -- without knowing which produced it. + """ + if not _admin_ok(request): + return _forbidden() + session = app.state.registry.get(session_id) + if session is None: + return JSONResponse({"error": "unknown session"}, status_code=404) + # `to_trace_entries` refuses a non-trainable document rather than silently handing back + # rows with empty token fields, which is the failure this whole contract exists to avoid. + document = export_session( + session, include_messages=True, capture_level=_level_of(session) + ) + try: + return { + "session_id": session_id, + "entries": to_trace_entries(session.graph, document), + } + except Exception as exc: + return JSONResponse( + {"error": f"{type(exc).__name__}: {exc}", "session_id": session_id}, + status_code=409, + ) + + @app.delete("/sessions/{session_id}") + async def delete_session(session_id: str, request: Request) -> Any: + if not _admin_ok(request): + return _forbidden() + return {"deleted": app.state.registry.delete(session_id)} + + @app.get("/v1/models") + async def models() -> Any: + """The default engine's model list. + + Deliberately not session-scoped: this route is unauthenticated and answers before any session + exists, and some harnesses call it to decide whether their configured model is available. With + no default engine there is nothing to list, and an empty list is the honest answer rather than + a 500. + """ + if app.state.inference is None: + return {"object": "list", "data": []} + return await app.state.inference.list_models() + + @app.post("/{path:path}") + async def proxy(path: str, request: Request) -> Any: + """Catch-all: /v1/chat/completions, /v1/messages, /v1/responses, :generateContent.""" + headers = dict(request.headers) + try: + body = await request.json() + except Exception: # noqa: BLE001 + return _invalid_request("body must be JSON") + if not isinstance(body, dict): + return _invalid_request("body must be a JSON object") + + # Answered, never recorded. Must come before session routing and dialect handling: an aux + # route is not a model turn, so it has no business creating a node or a session. + aux = aux_dialect(path) + if aux is not None: + logger.info("aux route %s (answered as %s, not recorded)", path, aux) + return JSONResponse( + aux_token_count_response(aux, approximate_token_count(body)) + ) + + session = app.state.registry.resolve(headers, body) + if session is None: + # Deliberately 401 rather than serving an unknown caller: this port is public. + return JSONResponse( + { + "error": { + "message": "unknown API key; register a session via POST /sessions", + "type": "invalid_request_error", + } + }, + status_code=401, + ) + + api_type: APIType = detect(f"/{path}", headers, body) + transformer = app.state.transforms.get(api_type) + + import copy + + original_request = copy.deepcopy(body) + # Include the query string: Google puts `alt=sse` there, not in the body. + full_target = ( + f"/{path}?{request.url.query}" if request.url.query else f"/{path}" + ) + client_wants_stream = wants_stream(full_target, body) + + # BUDGET CHECK, BEFORE ANYTHING IS FORWARDED OR RECORDED. + # + # Placed after `client_wants_stream` deliberately. Answering a STREAMING request with a plain + # JSON body does not end the agent's loop -- opencode streams, and it simply retried, so the + # proxy answered the stop over and over while the rollout burned its whole timeout. The reply + # has to go back in the dialect the caller asked for, which is what `sse.replay` is for. + # + # The same experiment that showed opencode ignores its own `steps` setting also showed what + # DOES end its loop: a plain assistant message with `finish_reason="stop"` and no tool calls + # terminates cleanly and `opencode run` exits 0. The content must be non-empty -- an empty + # assistant message reads as a failed generation and gets retried. + # + # Because this returns before `_ingest`, CAPTURE NEVER SEES IT, so no turn the model did not + # generate can enter the training data. It bounds cost and wall clock and nothing else: it is + # not a nudge and says nothing about the task, because shaping what an agent does with its + # last turns is the environment's business, not every environment's. + if session.over_budget: + logger.info( + "session %s hit its model-call budget (%d); ending the agent loop", + session.session_id, + session.max_model_calls, + ) + session.budget_stop_count += 1 + stop = _budget_stop_response(_model_of(session) or app.state.model) + if client_wants_stream: + return StreamingResponse( + sse.replay(api_type, transformer, stop, original_request), + media_type="text/event-stream", + headers=sse.SSE_HEADERS, + ) + payload = transformer.transform_response(stop, original_request) + normalise_client_payload(payload, api_type) + return JSONResponse(payload) + # The served model name has to be on the body BEFORE the transformer runs: each dialect + # reads `_served_model` inside `transform_request` to decide per-model request fixes, and + # `BaseTransformer._normalize_request` strips it again on the way out. Setting it afterwards, + # as the upstream client used to, meant the transformers never saw it (so the Qwen3.5 + # thinking fix silently never applied) and the marker travelled on to the engine unused. + incoming = dict(body) + served_model = _model_of(session) + if served_model: + incoming["_served_model"] = served_model + try: + chat_request = transformer.transform_request(incoming) + except UpstreamRequestError as exc: + return _invalid_request(str(exc)) + if chat_request.get("n", 1) != 1 or isinstance(chat_request.get("n"), bool): + return _invalid_request( + "capture supports exactly one completion per request (n=1)" + ) + if served_model: + chat_request["model"] = served_model + normalise_for_capture(chat_request) + requested_sampling = { + key: chat_request[key] for key in SAMPLING_KEYS if key in chat_request + } + if getattr(session, "eval_sampling", None): + chat_request.update(session.eval_sampling) + if session.sampling: + # These alter logits beyond the full-vocabulary, temperature-only policy the + # trainer recomputes. Capture cannot reconstruct an unreported grammar mask. + constrained = [ + key + for key in ( + "logit_bias", + "allowed_token_ids", + "structured_outputs", + "guided_json", + "guided_regex", + "guided_choice", + "guided_grammar", + "min_tokens", + ) + if chat_request.get(key) + ] + response_format = chat_request.get("response_format") or {} + if not isinstance(response_format, dict): + return _invalid_request("response_format must be a JSON object") + if response_format.get("type", "text") != "text": + constrained.append("response_format") + tool_choice = chat_request.get("tool_choice") + if tool_choice is not None and tool_choice not in ("auto", "none"): + constrained.append("tool_choice") + if constrained: + return _invalid_request( + "training sampling cannot reproduce constrained logits: " + + ", ".join(constrained) + ) + chat_request.update(session.sampling) + output_cap = session.metadata.get("max_output_tokens") + if output_cap is not None and (type(output_cap) is not int or output_cap < 1): + return _invalid_request( + "session max_output_tokens must be a positive integer" + ) + if app.state.max_output_tokens: + output_cap = ( + min(output_cap, app.state.max_output_tokens) + if output_cap + else app.state.max_output_tokens + ) + clamped = clamp_output_tokens(chat_request, output_cap) + if clamped: + logger.info( + "clamped requested output tokens %d -> %d", + clamped, + output_cap, + ) + + upstream_client, session_level = await _upstream_for(session) + if session.sampling and session_level != "tokens": + return _invalid_request( + "training sampling requires an endpoint with token capture" + ) + if upstream_client is None: + # No engine on the session and none on the server. Saying so beats forwarding to an empty + # base URL, which surfaces as a connection error that names neither cause. + return JSONResponse( + { + "error": { + "message": "no inference engine for this session: name one as `llm_url` " + "when creating the session, or boot the server with --llm-url" + } + }, + status_code=503, + ) + # Reserve after validation and upstream resolution. No await separates the check and + # increment, so concurrent requests on the capture loop cannot exceed the session budget. + if session.over_budget: + session.budget_stop_count += 1 + stop = _budget_stop_response(_model_of(session) or app.state.model) + if client_wants_stream: + return StreamingResponse( + sse.replay(api_type, transformer, stop, original_request), + media_type="text/event-stream", + headers=sse.SSE_HEADERS, + ) + payload = transformer.transform_response(stop, original_request) + normalise_client_payload(payload, api_type) + return JSONResponse(payload) + if ( + getattr(upstream_client, "provider", "openai") == "anthropic" + and api_type == APIType.ANTHROPIC + ): + chat_request["_openenv_native_request"] = { + **original_request, + **getattr(session, "eval_sampling", {}), + } + chat_request["_openenv_native_headers"] = { + key: request.headers[key] + for key in ("anthropic-beta", "anthropic-version") + if key in request.headers + } + session.model_calls += 1 + + async def complete_response(): + try: + response = await upstream_client.completion(chat_request) + except UpstreamError as exc: + # A known context limit ends the rollout's compute budget. Let the + # harness terminate normally so Harbor can grade its current answer. + # Like the model-call stop, this control response is never ingested. + if isinstance(exc, UpstreamHTTPError) and exc.status_code == 400: + error = ( + exc.body.get("error", exc.body) + if isinstance(exc.body, dict) + else {} + ) + code = error.get("code") if isinstance(error, dict) else None + message = str(exc).lower() + if code == "context_length_exceeded" or ( + "maximum context length" in message and "tokens" in message + ): + session.findings.append( + "[WARN] context_budget_exhausted: " + str(exc) + ) + session.budget_stop_count += 1 + stop = _budget_stop_response( + _model_of(session) or app.state.model + ) + if client_wants_stream: + return StreamingResponse( + sse.replay( + api_type, transformer, stop, original_request + ), + media_type="text/event-stream", + headers=sse.SSE_HEADERS, + ) + payload = transformer.transform_response(stop, original_request) + normalise_client_payload(payload, api_type) + return JSONResponse(payload) + session.upstream_errors += 1 + logger.warning("upstream error [%s]: %s", session.session_id, exc) + # Preserve a permanent request error: converting a 400 (for example, + # an image sent to a text-only engine) into 502 makes harnesses retry + # unchanged input for minutes. Transient failures retain the gateway + # response, and auth statuses are not exposed as harness credentials. + from .providers import ProviderConversionError + + status = ( + 400 + if isinstance(exc, ProviderConversionError) + else exc.status_code + if isinstance(exc, UpstreamHTTPError) + and exc.status_code in (400, 413, 422) + else 502 + ) + return JSONResponse( + {"error": {"message": str(exc)}}, status_code=status + ) + + native_response = response.pop("_openenv_native_response", None) + effective_sampling = response.pop("_openenv_sampling", None) + if session.sampling and ( + effective_sampling is None + or any( + effective_sampling.get(key) != value + for key, value in session.sampling.items() + ) + ): + session.findings.append( + "[FATAL] sampling_policy_changed: upstream did not preserve the session training policy" + ) + session.upstream_errors += 1 + return JSONResponse( + { + "error": { + "message": "upstream did not preserve the session training sampling policy" + } + }, + status_code=502, + ) + if effective_sampling is not None: + for key in SAMPLING_KEYS: + chat_request.pop(key, None) + chat_request.update(effective_sampling) + normalise_response(response) + _ingest( + session, + chat_request, + response, + api_type, + session_level, + requested_sampling, + ) + + if native_response is not None and api_type == APIType.ANTHROPIC: + if client_wants_stream: + from .providers import replay_anthropic + + return StreamingResponse( + replay_anthropic(native_response), + media_type="text/event-stream", + headers=sse.SSE_HEADERS, + ) + return JSONResponse(native_response) + if client_wants_stream: + return StreamingResponse( + sse.replay(api_type, transformer, response, original_request), + media_type="text/event-stream", + headers=sse.SSE_HEADERS, + ) + payload = transformer.transform_response(response, original_request) + normalise_client_payload(payload, api_type) + return JSONResponse(payload) + + if client_wants_stream: + return await sse.keepalive_response(complete_response(), api_type) + return await complete_response() + + def _ingest( + session, + chat_request: dict[str, Any], + response: dict[str, Any], + api_type: APIType, + capture_level: str, + requested_sampling: dict[str, Any] | None = None, + ) -> None: + """Turn one upstream response into a graph node, validating before it lands. + + Never raises. A capture problem must degrade one turn, not kill a rollout that is otherwise + producing usable data, and certainly not take down the server serving every other rollout. + """ + try: + choices = response.get("choices") or [] + if not choices: + # A 200 with no choices is not a turn. Recording it produced a node with no prompt and + # no completion, which inflated n_turns and n_roots and could push a worthless rollout + # past the `degenerate_rollout` FATAL that exists to catch exactly that. + logger.warning( + "[%s] upstream returned 200 with no choices; not recording a turn", + session.session_id, + ) + session.upstream_errors += 1 + return + choice = choices[0] or {} + logprob_entries = (choice.get("logprobs") or {}).get("content") or [] + logprobs = [e.get("logprob") for e in logprob_entries] or None + # A None inside the list passes the length check and then crashes export on `None > 0.0`, + # turning `GET /sessions/{id}/rollout` into a 500. Missing values mean the turn cannot be + # trained on, which is what dropping them to None already signals. + if logprobs is not None and any(lp is None for lp in logprobs): + logger.warning( + "[%s] %d of %d logprobs are null; treating the turn as untrainable", + session.session_id, + sum(1 for lp in logprobs if lp is None), + len(logprobs), + ) + logprobs = None + sampled_ids = choice.get("token_ids") or [] + prompt_ids = response.get("prompt_token_ids") or [] + index = session.graph.stats()["n_turns"] + + if capture_level != "tokens": + # An eval turn. Grade what an eval turn can be graded on and keep going: running + # `check_turn` here would report `no_prompt_ids` and `no_logprobs` as FATAL on every + # single turn, which is true and useless — it is the known, accepted property of the + # endpoint, decided before the server started. + # + # Any logprobs that did come back are kept on the node for the confidence readout, + # but they are not a training signal: with no token ids there is nothing to align + # them to, so `export` never promotes them to `per_token_logps`. + report = check_turn_eval( + choice.get("message"), + finish_reason=choice.get("finish_reason"), + index=index, + ) + session.findings.extend(str(f) for f in report.findings) + else: + report = check_turn( + prompt_ids, + sampled_ids, + logprobs, + finish_reason=choice.get("finish_reason"), + index=index, + ) + session.findings.extend(str(f) for f in report.findings) + if not report.ok: + logger.warning( + "[%s] turn %d rejected: %s", + session.session_id, + index, + "; ".join(str(f) for f in report.fatal), + ) + # Still recorded, with logprobs dropped: the tokens are real context for later + # turns, and `sequence_for` masks a turn whose logprobs it cannot trust. + logprobs = None + + # A rewritten request is how an eval number becomes unreproducible, so say so — once per + # session, since every turn of a given harness sends the same knobs. + if capture_level == "tokens": + overridden = truncating_params(requested_sampling or chat_request) + if overridden and not session.metadata.get("sampling_overridden"): + session.metadata["sampling_overridden"] = overridden + session.findings.append( + "WARN sampling_neutralised: the harness asked for " + + ", ".join(f"{k}={v}" for k, v in sorted(overridden.items())) + + "; these were sent upstream at their no-op values because a processed " + "logprob is taken after they are applied, which would bias a full-vocab " + "recompute. requested_sampling_params preserves the harness request; " + "sampling_params records the submitted policy." + ) + + session.graph.add_turn( + TurnNode( + node_id=uuid.uuid4().hex[:12], + prompt_ids=list(prompt_ids), + sampled_ids=list(sampled_ids), + sampled_logprobs=list(logprobs) if logprobs else None, + model=chat_request.get("model"), + finish_reason=choice.get("finish_reason"), + harness_session_id=extract_harness_session({}, chat_request), + system_digest=_system_digest(chat_request.get("messages") or []), + n_tools=len(chat_request.get("tools") or []), + request_messages=chat_request.get("messages") or [], + request_tools=chat_request.get("tools"), + sampling_params={ + key: chat_request[key] + for key in SAMPLING_KEYS + if chat_request.get(key) is not None + }, + requested_sampling_params=requested_sampling or {}, + response_message=choice.get("message") or {}, + ) + ) + session.last_turn_at = __import__("time").time() + session.metadata.setdefault("api_type", api_type.value) + except Exception: # noqa: BLE001 + session.findings.append( + "[FATAL] capture_ingest_failed: an upstream response could not be recorded" + ) + logger.exception("[%s] ingest failed; turn dropped", session.session_id) + + return app + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--llm-url", required=True, help="OpenAI-spec endpoint you host" + ) + parser.add_argument( + "--model", default=None, help="served model name to send upstream" + ) + parser.add_argument( + "--engine", + default="", + choices=["", "vllm", "sglang"], + help="what is on the other end, for /health only. Left unset by default because the " + "upstream may be a hosted provider, and reporting one of these two when it is not says " + "something untrue about capture.", + ) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8100) + parser.add_argument( + "--max-output-tokens", + type=int, + default=8192, + help="cap on requested completion length; 0 disables", + ) + parser.add_argument( + "--allow-unregistered", + action="store_true", + help="serve unknown API keys (local debugging only; this port may be public)", + ) + parser.add_argument( + "--api-key", + default=os.environ.get("OPENENV_LLM_API_KEY", ""), + help="credential for the UPSTREAM endpoint (defaults to $OPENENV_LLM_API_KEY). Not the " + "agent-facing key: that is a capture session id, minted per rollout.", + ) + parser.add_argument( + "--auth-header", + default="Authorization", + help="header to send --api-key under; `Authorization` gets a Bearer prefix, anything else " + "(e.g. x-api-key) gets the raw key", + ) + parser.add_argument( + "--capture-level", + default="", + choices=["", "tokens", "logprobs", "text"], + help="what the upstream can return. Probed from the endpoint when omitted, which is the " + "recommended path; pass it only to force a level.", + ) + args = parser.parse_args() + + import uvicorn + + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s" + ) + + level = args.capture_level + if not level: + from .validate_llm import validate_llm + + report = validate_llm( + args.llm_url, + args.model or "", + api_key=args.api_key or None, + auth_header=args.auth_header, + ) + if not report.reachable: + raise SystemExit(report.summary()) + level = report.capture_level + print(f"capture level: {level} ({report.rollout_type} rollouts)") + for fix in report.param_fixes: + print(f" upstream compat: {fix}") + + uvicorn.run( + create_app( + llm_url=args.llm_url, + model=args.model, + engine=args.engine, + require_registered=not args.allow_unregistered, + max_output_tokens=args.max_output_tokens or None, + api_key=args.api_key or None, + auth_header=args.auth_header, + capture_level=level, + ), + host=args.host, + port=args.port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/src/openenv/core/harness/capture/sessions.py b/src/openenv/core/harness/capture/sessions.py new file mode 100644 index 0000000000..a99a5a1c87 --- /dev/null +++ b/src/openenv/core/harness/capture/sessions.py @@ -0,0 +1,280 @@ +"""Session routing: one server, one port, N concurrent rollouts. + +The whole multiplexing scheme is one decision: **the API key IS the session id**. We mint a key per +rollout, hand it to the agent as its `OPENAI_API_KEY` (or `ANTHROPIC_API_KEY`, or a provider config +field), and every SDK forwards it unchanged on every request. So the bearer token that arrives is +already the rollout identifier, and no agent needs to know it is being recorded. + +That is what makes one intercept server serve a whole GRPO group. The alternative, a proxy per +sandbox, means N processes, N ports, N forwards, and capture living inside the thing most likely to +die. + +Two rules learned the hard way: + + * **A registered key beats every other hint.** Harnesses inject their own session headers, and + opencode sends `x-session-id: ses_...` from the AI SDK. Letting that win files the trajectory + under an id the caller has never seen, so lookups return nothing and it reads as "the agent made + no model calls" while every turn was in fact captured. That cost real debugging time. + * **The harness's own session id is kept, not discarded.** It is recorded on the node as + `harness_session_id`. Our key scopes the ROLLOUT; theirs identifies the sub-conversation, which + is exactly the ground truth needed to separate a subagent from the main agent. + +Unknown keys are rejected when `require_registered` is on. It defaults to on because this server sits +behind a public forward in front of a GPU, and an open inference endpoint is a real cost, not a +theoretical one. +""" + +from __future__ import annotations + +import hashlib +import re +import secrets +import threading +import time +from dataclasses import dataclass, field +from typing import Any + +from .graph import RolloutGraph +from .upstream import training_sampling + +# Session ids become dict keys, filenames, and URL path segments. +_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") + + +def clean_session_id(value: Any) -> str | None: + if isinstance(value, str) and _SESSION_ID_RE.fullmatch(value.strip()): + return value.strip() + return None + + +def extract_api_key(headers: dict[str, str]) -> str | None: + """Every major SDK puts its key in one of these three places.""" + lower = {k.lower(): v for k, v in headers.items()} + auth = lower.get("authorization", "") + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return lower.get("x-api-key") or lower.get("x-goog-api-key") + + +def extract_harness_session( + headers: dict[str, str], body: dict[str, Any] +) -> str | None: + """The harness's own conversation id, when it volunteers one. Never used for routing.""" + lower = {k.lower(): v for k, v in headers.items()} + return ( + clean_session_id(lower.get("x-session-id")) + or clean_session_id(lower.get("proxy-x-session-id")) + or clean_session_id(body.get("_session_id")) + or clean_session_id(body.get("user")) + ) + + +@dataclass(frozen=True) +class Upstream: + """Which engine one rollout's calls go to. + + Per session rather than per server because the durable thing here is the DATASET, not the engine. + A dataset server downloads thousands of task files and builds sandbox templates; a vLLM restarts + every training run, and a train-tier engine and an eval-tier one are usually both wanted at once. + Pinning the engine at boot made the long-lived thing hostage to the short-lived one. + + `cache_key` is what lets N concurrent sessions on one engine share a single client and a single + capability probe. A credential digest separates authenticated clients without putting the key + itself in a cache key or repr: different credentials can select different tenants or capabilities. + """ + + llm_url: str + model: str = "" + api_key: str | None = field(default=None, repr=False) + auth_header: str = "Authorization" + provider: str = "openai" + + @property + def cache_key(self) -> tuple[str, str, str, str, str]: + credential = hashlib.sha256((self.api_key or "").encode()).hexdigest() + return ( + self.llm_url.rstrip("/"), + self.model, + self.auth_header.lower(), + credential, + self.provider, + ) + + +def evaluation_sampling(value: dict[str, Any] | None) -> dict[str, Any]: + """An explicit eval policy override, distinct from the train-only policy contract.""" + import math + + if value is None: + return {} + if not isinstance(value, dict) or set(value) - {"temperature", "top_p", "top_k"}: + raise ValueError("eval_sampling supports temperature, top_p, and top_k only") + for key, number in value.items(): + if ( + isinstance(number, bool) + or not isinstance(number, (int, float)) + or not math.isfinite(number) + ): + raise ValueError("eval_sampling values must be finite numbers") + if key == "temperature" and not 0 <= number <= 2: + raise ValueError("eval temperature must be between 0 and 2") + if key == "top_p" and not 0 < number <= 1: + raise ValueError("eval top_p must be in (0, 1]") + if key == "top_k" and ( + type(number) is not int or (number != -1 and number < 1) + ): + raise ValueError("eval top_k must be -1 or a positive integer") + return dict(value) + + +def rollout_type_for(purpose: str, capture_level: str) -> str: + """Separate requested use from observed engine capability; keep legacy auto behavior.""" + if purpose not in {"auto", "eval", "train"}: + raise ValueError("purpose must be auto, eval, or train") + if purpose == "train" and capture_level != "tokens": + raise ValueError("training requires exact engine token capture") + return "eval" if purpose == "eval" or capture_level != "tokens" else "train" + + +@dataclass +class Session: + """One rollout's capture buffer.""" + + session_id: str + created_at: float = field(default_factory=time.time) + graph: RolloutGraph = field(default_factory=RolloutGraph) + metadata: dict[str, Any] = field(default_factory=dict) + findings: list[str] = field(default_factory=list) + last_turn_at: float | None = None + upstream_errors: int = 0 + # Set when the caller named an engine for this rollout; `None` means use the server's default. + upstream: Upstream | None = None + # What that engine was MEASURED to support, filled in by the probe when the session is created. + # Empty means "not measured here", so the server default applies. Never assumed optimistically: + # claiming `tokens` without evidence is how an eval rollout gets stamped trainable. + capture_level: str = "" + purpose: str = "auto" + eval_sampling: dict[str, Any] = field(default_factory=dict) + # Model calls FORWARDED for this rollout, and the ceiling. 0 means unlimited. + # + # Counted on forward rather than read off the graph, because a turn whose logprobs were rejected + # never becomes a node yet still cost an engine call — counting nodes would let a rollout that + # fails validation on every turn run forever. + # + # A ceiling is needed at all because NO AGENT HARNESS HONOURS ITS OWN STEP CONFIG. Measured on + # opencode 1.18.30 against a fake engine that always asks for one more tool call: + # `agent.build.steps=3`, `maxSteps=3` and no setting at all each produced 61 model calls — the + # fake server's own hard stop, i.e. nothing else ever ended the loop. The proxy is the only + # component that sees every call, and the API key IS the rollout, so a counter here is exactly a + # per-rollout step count. + model_calls: int = 0 + max_model_calls: int = 0 + budget_stop_count: int = 0 + sampling: dict[str, float | int] = field(default_factory=dict) + + @property + def over_budget(self) -> bool: + """Whether this rollout has spent its model-call budget.""" + return self.max_model_calls > 0 and self.model_calls >= self.max_model_calls + + @property + def idle_seconds(self) -> float: + """Since the last captured turn. The cheapest signal that separates progress from a wedge.""" + return time.time() - (self.last_turn_at or self.created_at) + + +class SessionRegistry: + """Thread-safe. Uvicorn serves concurrently and rollouts are independent.""" + + def __init__(self, *, require_registered: bool = True) -> None: + self._sessions: dict[str, Session] = {} + self._lock = threading.Lock() + self.require_registered = require_registered + + def create( + self, + session_id: str | None = None, + *, + upstream: Upstream | None = None, + capture_level: str = "", + purpose: str = "auto", + eval_sampling: dict[str, Any] | None = None, + max_model_calls: int = 0, + sampling: dict[str, Any] | None = None, + **metadata: Any, + ) -> Session: + if type(max_model_calls) is not int or max_model_calls < 0: + raise ValueError("max_model_calls must be a non-negative integer") + if purpose not in {"auto", "eval", "train"}: + raise ValueError("purpose must be auto, eval, or train") + if purpose == "eval" and sampling is not None: + raise ValueError("eval purpose cannot apply a training sampling override") + if capture_level: + rollout_type_for(purpose, capture_level) + eval_policy = evaluation_sampling(eval_sampling) + if eval_policy and purpose != "eval": + raise ValueError("eval_sampling requires explicit eval purpose") + policy = training_sampling(sampling) + sid = clean_session_id(session_id) or f"s{secrets.token_hex(12)}" + with self._lock: + session = self._sessions.get(sid) or Session(session_id=sid) + session.metadata.update(metadata) + session.purpose = purpose + session.eval_sampling = eval_policy + if upstream is not None: + session.upstream = upstream + if capture_level: + session.capture_level = capture_level + # Per session, not per server: one deployment serves a training run that wants a tight + # cap and an evaluation run that wants none, at the same time. + if max_model_calls: + session.max_model_calls = max_model_calls + if policy: + session.sampling = policy + self._sessions[sid] = session + return session + + def get(self, session_id: str | None) -> Session | None: + if not session_id: + return None + with self._lock: + return self._sessions.get(session_id) + + def resolve(self, headers: dict[str, str], body: dict[str, Any]) -> Session | None: + """Route a request to its rollout. `None` means reject. + + Order matters and is the opposite of what looks natural: the registered API key wins over any + session header the harness supplies. See the module docstring. + """ + api_key = extract_api_key(headers) + session = self.get(api_key) + if session is not None: + return session + if self.require_registered: + return None + # Open mode (local debugging only): an unknown caller still gets a session, so a + # misconfigured agent shows up as an orphan trajectory instead of vanishing. + return self.create(api_key) + + def list_ids(self) -> list[str]: + with self._lock: + return sorted(self._sessions) + + def delete(self, session_id: str) -> bool: + with self._lock: + return self._sessions.pop(session_id, None) is not None + + def summary(self) -> list[dict[str, Any]]: + with self._lock: + sessions = list(self._sessions.values()) + return [ + { + "session_id": s.session_id, + "turns": s.graph.stats()["n_turns"], + "roots": s.graph.stats()["n_roots"], + "idle_s": round(s.idle_seconds, 1), + "upstream_errors": s.upstream_errors, + **s.metadata, + } + for s in sessions + ] diff --git a/src/openenv/core/harness/capture/sse.py b/src/openenv/core/harness/capture/sse.py new file mode 100644 index 0000000000..b021b531cd --- /dev/null +++ b/src/openenv/core/harness/capture/sse.py @@ -0,0 +1,255 @@ +"""Synthetic SSE: capture non-streaming, reply streaming. + +Every coding harness streams. That is not a preference we can talk them out of -- opencode, codex and +claude-code all drive their UI off token deltas -- and a harness that asks for SSE and receives a +plain JSON body does not error. Its stream parser simply yields nothing, so opencode reports +`step-finish reason:"unknown"` with zero tokens and no message, having received a *perfectly valid* +tool call. Capture looks flawless from our side and the agent does nothing. That failure cost real +debugging time, hence this module. + +Meanwhile capture wants the opposite: one complete response, because token ids and logprobs arrive +whole and reassembling them from deltas is error-prone in exactly the way that silently corrupts +training data. + +So we do both. Fetch non-streaming upstream, store that for capture, then replay the complete +response to the client as a synthetic SSE stream. The client cannot tell the difference; we never +parse deltas. + +The per-dialect machinery is reused from Polar's transformers (`create_stream_state` / +`transform_stream_chunk`), which are dependency-clean. Only the small formatting helpers are ported +here, because in Polar they live in `server.py` next to its node/dispatcher layer. +""" + +from __future__ import annotations + +import asyncio +import json +from contextlib import suppress +from typing import Any, Awaitable + +from starlette.responses import Response, StreamingResponse + +from .detection import APIType +from .dialects.base import BaseTransformer + +SSE_HEADERS = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + # Without this an intermediate proxy (nginx, cloudflared) may buffer the whole stream and hand + # it over at once, which defeats the point for the client even though the bytes are correct. + "X-Accel-Buffering": "no", +} + +# The shared relay can time out both delayed headers and an idle response body +# after 60 seconds. Comments keep SSE alive without representing model output. +KEEPALIVE_INTERVAL_S = 10.0 +KEEPALIVE = ": openenv keepalive\n\n" + + +def _error_event(api_type: APIType, response: Response) -> str: + payload = json.loads(response.body) + error = payload.get("error", payload) + message = error.get("message", "upstream request failed") + if api_type == APIType.ANTHROPIC: + return _format_typed_events( + [{"type": "error", "error": {"type": "api_error", "message": message}}] + ) + if api_type == APIType.OPENAI_RESPONSES: + return _format_typed_events( + [ + { + "type": "error", + "code": "server_error", + "message": message, + "param": None, + "sequence_number": 0, + } + ] + ) + return _format_data_only({"error": {**error, "code": response.status_code}}) + + +async def keepalive_response( + pending: Awaitable[Response], api_type: APIType +) -> Response: + """Keep a delayed replay connected while capturing the complete upstream reply. + + Fast responses retain their original HTTP status, including validation and + upstream errors. Once SSE headers are committed, a later error is an error + event in the client's dialect. It never becomes a synthetic assistant turn. + """ + task = asyncio.ensure_future(pending) + try: + done, _ = await asyncio.wait({task}, timeout=KEEPALIVE_INTERVAL_S) + except BaseException: + task.cancel() + with suppress(asyncio.CancelledError, Exception): + await task + raise + if done: + return task.result() + + async def body(): + while not task.done(): + yield KEEPALIVE + await asyncio.wait({task}, timeout=KEEPALIVE_INTERVAL_S) + response = task.result() + if response.status_code >= 400: + yield _error_event(api_type, response) + return + if not isinstance(response, StreamingResponse): + raise RuntimeError("streaming capture returned a non-streaming success") + async for chunk in response.body_iterator: + yield chunk + + class PendingResponse(StreamingResponse): + async def __call__(self, scope, receive, send): + try: + await super().__call__(scope, receive, send) + finally: + # A dropped client must not leave a capture task running after + # its request owner has gone away, including before body start. + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError, Exception): + await task + + return PendingResponse(body(), media_type="text/event-stream", headers=SSE_HEADERS) + + +def _format_typed_events(events: list[dict[str, Any]]) -> str: + """Anthropic and OpenAI-Responses both use named events (`event: `).""" + return "".join( + f"event: {event.get('type', 'unknown')}\ndata: {json.dumps(event, default=str)}\n\n" + for event in events + ) + + +def _format_data_only(chunk: dict[str, Any]) -> str: + """OpenAI chat-completions and Google use bare `data:` lines.""" + return f"data: {json.dumps(chunk, default=str)}\n\n" + + +def format_events(api_type: APIType, events: list[dict[str, Any]]) -> str: + """Format EVERY event. Dropping any of them truncates the stream. + + This used to emit only `events[0]` for the data-only dialects, which is invisible for + chat-completions (we synthesise exactly one chunk) but silently truncates Google. gemini-cli calls + `:streamGenerateContent?alt=sse`, whose stream state emits several events, and receiving only the + first produced: + + Error: Incomplete JSON segment at the end + at ApiClient.processStreamResponse_1 (@google/gemini-cli/...) + + A 200 with a truncated body, which is the failure shape this whole layer keeps running into. + """ + if api_type in (APIType.ANTHROPIC, APIType.OPENAI_RESPONSES): + return _format_typed_events(events) + return "".join(_format_data_only(event) for event in events) + + +def format_chunk( + api_type: APIType, + transformer: BaseTransformer, + chunk: dict[str, Any], + original_request: dict[str, Any], + *, + is_first: bool, +) -> str: + """Fallback for transformers with no stream-state machine: one-shot chunk transform.""" + transformed = transformer.transform_stream_chunk( + chunk, original_request, is_first=is_first + ) + if api_type == APIType.ANTHROPIC: + return _format_typed_events(transformed) + if api_type == APIType.OPENAI_RESPONSES: + events = ( + transformed + if isinstance(transformed, list) + else ([transformed] if transformed else []) + ) + return _format_typed_events(events) + return _format_data_only(transformed) + + +def response_to_chunk(response: dict[str, Any]) -> dict[str, Any]: + """Repackage a complete chat completion as a single `chat.completion.chunk` delta. + + One chunk carrying everything, rather than a plausible-looking token-by-token replay. The client + only needs a well-formed stream, and faking granularity would invent timing information we do not + have. Tool calls have to be re-indexed into delta form: streaming clients accumulate + `tool_calls[i].function.arguments` across chunks, so the `index` field is required even when + there is exactly one chunk to accumulate. + """ + choice = (response.get("choices") or [{}])[0] + message = choice.get("message") or {} + + tool_calls_delta = [ + { + "index": i, + "id": tc.get("id"), + "type": tc.get("type", "function"), + "function": { + "name": (tc.get("function") or {}).get("name", ""), + "arguments": (tc.get("function") or {}).get("arguments", ""), + }, + } + for i, tc in enumerate(message.get("tool_calls") or []) + ] + + delta: dict[str, Any] = {"role": "assistant"} + if message.get("content") is not None: + delta["content"] = message["content"] + # Reasoning models put thinking here; harnesses that render it expect it in the delta. + for key in ("reasoning_content", "reasoning"): + if message.get(key) is not None: + delta["reasoning_content"] = message[key] + break + if tool_calls_delta: + delta["tool_calls"] = tool_calls_delta + + return { + "id": response.get("id"), + "object": "chat.completion.chunk", + "created": response.get("created"), + "model": response.get("model"), + "choices": [ + {"index": 0, "delta": delta, "finish_reason": choice.get("finish_reason")} + ], + # Clients that sent stream_options.include_usage expect this; we dropped the option upstream + # (vLLM rejects it with stream=False) but the response carries usage anyway, so honour it. + "usage": response.get("usage"), + } + + +async def replay( + api_type: APIType, + transformer: BaseTransformer, + response: dict[str, Any], + original_request: dict[str, Any], +): + """Async generator yielding the SSE body for one complete upstream response.""" + chunk = response_to_chunk(response) + stream_state = transformer.create_stream_state(original_request) + + if stream_state is not None: + # Dialects with real state machines (Anthropic, Responses, Google) emit a sequence of + # lifecycle events -- message_start, content_block_delta, message_stop and friends -- and the + # client will reject a stream that skips them, so finalize() is not optional. + events = stream_state.process_chunk(chunk, is_first=True) + if events: + yield format_events(api_type, events) + final_events = stream_state.finalize() + if final_events: + yield format_events(api_type, final_events) + else: + output = format_chunk( + api_type, transformer, chunk, original_request, is_first=True + ) + if output: + yield output + + if api_type == APIType.OPENAI_CHAT: + # Only chat-completions uses this sentinel. The typed-event dialects signal completion with + # their own terminal event, and an extra [DONE] there is a parse error. + yield "data: [DONE]\n\n" diff --git a/src/openenv/core/harness/capture/upstream.py b/src/openenv/core/harness/capture/upstream.py new file mode 100644 index 0000000000..cef779c9a5 --- /dev/null +++ b/src/openenv/core/harness/capture/upstream.py @@ -0,0 +1,603 @@ +"""The upstream leg: one HTTP client to a vLLM- or SGLang-compatible OpenAI server. + +SGLang was excluded here because it genuinely could not do this: its chat route returned token +*text* with no ids (sgl-project/sglang#18378 asked for exactly this, for the same train/inference +consistency reason). That changed with sgl-project/sglang#30917, merged 2026-07-23, which added +`return_token_ids` to the OpenAI-compatible routes. It is on `main` and NOT in v0.5.16 — that +release has only `return_prompt_token_ids`, the prompt ids without the sampled ones — so an SGLang +endpoint is usable here only when built from main. + +One shape difference, absorbed in `normalize_response` below: SGLang returns the prompt ids PER +CHOICE (`choices[0].prompt_token_ids`); vLLM returns them at the TOP LEVEL of the response. +Everything downstream reads the top-level field, so normalisation hoists SGLang's. + +Two request params do all the work, and both are easy to get subtly wrong: + + return_token_ids=True makes vLLM emit `response.prompt_token_ids` and `choice.token_ids`. + `prompt_token_ids` is the load-bearing one: it is the engine's own + tokenisation of the whole conversation so far, which is what lets turn + k+1 be matched against turn k by exact token prefix without us ever + tokenising locally. + top_logprobs=0 must be SET, not omitted. vLLM only populates `logprobs.content[]` when + `top_logprobs` is not None, even with `logprobs=True`. Zero returns just + the sampled token's logprob, which is all training needs. + +Neither is standard, so neither can be sent unconditionally. `capture_level` says how much this +particular endpoint tolerates, and it is discovered by probing rather than configured (see +`validate_llm`): + + tokens prompt ids + sampled ids + aligned logprobs. vLLM with the two serving flags, or + SGLang built from main. The only level that yields trainable rollouts. + logprobs logprobs but no ids. Nothing trainable can be built from these — an unpaired + logprob has no token to attach to — so they are kept only as an eval diagnostic. + text neither. OpenAI's current models reject `logprobs` outright; Anthropic never had it. + +Below `tokens` a rollout is an eval rollout: same path, same agents, reward and full trace, no token +fields. What must never happen is *looking* trainable while carrying nothing, which is why the level +travels with every response and no contract is written without it. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any + +import httpx + +from .compat import diagnose, MAX_FIXES, ParamFix + +logger = logging.getLogger(__name__) + +# Ordered weakest-last: `CAPTURE_LEVELS.index` is how callers compare two levels. +CAPTURE_LEVELS = ("tokens", "logprobs", "text") + + +def auth_headers(api_key: str | None, header: str = "Authorization") -> dict[str, str]: + """The one header that authenticates us to the upstream, or `{}` when there is no key. + + `Authorization` gets the `Bearer ` prefix the OpenAI spec asks for; any other header name gets + the raw key, because the providers that use a custom header (`x-api-key`) want it bare. The + header name is configurable because the OpenResponses compliance suite treats it as + configuration, and because Anthropic's native route does not accept `Authorization`. + + Args: + api_key (`str`, *optional*): + The upstream credential. This is never the agent-facing key: that one is a capture + session id and is minted per rollout (see `sessions`). + header (`str`, *optional*, defaults to `"Authorization"`): + Header to send it under. + + Returns: + `dict[str, str]`: Headers to merge into the request. + """ + if not api_key: + return {} + name = (header or "Authorization").strip() + if name.lower() == "authorization": + return {name: f"Bearer {api_key}"} + return {name: api_key} + + +class UpstreamError(RuntimeError): + """Any failure talking to the engine. Never leaks httpx types to callers.""" + + +class UpstreamRequestError(UpstreamError): + """The caller's request cannot be represented by the selected inference backend.""" + + +class UpstreamHTTPError(UpstreamError): + """Engine answered with a non-2xx status.""" + + def __init__( + self, status_code: int, body: dict[str, Any] | str | None = None + ) -> None: + self.status_code = status_code + self.body = body + detail = body + if isinstance(body, dict): + error = body.get("error") + detail = error.get("message") if isinstance(error, dict) else error or body + super().__init__(f"upstream returned {status_code}: {str(detail)[:400]}") + + +class UpstreamTimeoutError(UpstreamError): + """Engine did not answer within the liveness ceiling.""" + + +class UpstreamTransportError(UpstreamError): + """Connection-level failure: refused, reset, DNS.""" + + +def normalise_engine_base(url: str) -> str: + """The engine root, with any trailing `/v1` removed. + + Every route this module builds is already `/v1/...`, so a caller who passes the OpenAI-style + base (`http://host:8000/v1`, which is what most SDKs and most people hand you) would otherwise + get `/v1/v1/chat/completions` and see a healthy engine reported as unreachable. Accept both + forms and normalise here rather than making every call site remember which one it holds. + """ + base = url.rstrip("/") + return base[: -len("/v1")] if base.endswith("/v1") else base + + +# Sampling knobs that make a *processed* logprob incomparable to a full-vocab recompute, mapped to the +# value that disables each one. +# +# vLLM's sampler masks the truncated tail to `-inf` and only then takes the log-softmax +# (`v1/sample/ops/topk_topp_sampler.py`: `apply_top_k_top_p` at line 135, `compute_logprobs` at 139, +# which is `logits.log_softmax(...)`). So under `--logprobs-mode processed_logprobs` with `top_p=0.95`, +# every captured logprob is the *renormalised* one — `log p_full(token) - log(kept_mass)` — while a +# trainer recomputing over the full vocabulary gets `log p_full(token)`. The captured number is +# uniformly too high, so GRPO's step-0 importance ratio `exp(recompute - captured)` comes out at +# `kept_mass` rather than 1, and the penalties are worse than a uniform shift because they reorder. +# +# For a training rollout the sampling distribution must BE the policy distribution — that is what makes +# the data on-policy — so truncation is not a feature to preserve here, it is the bug. TRL's own +# GRPOConfig defaults `top_p` to 1.0 for the same reason. At the `tokens` tier these are therefore set +# to their no-op values; at `logprobs`/`text` they are left exactly as the harness sent them, because an +# eval rollout should score the model the harness actually asked for. +# +# The original request is recorded as `requested_sampling_params`. `sampling_params` records the +# actual submitted policy, including compatibility edits; explicit training sessions reject edits +# that change the pinned policy. +NON_POLICY_SAMPLING: dict[str, float | int] = { + "top_p": 1.0, + "top_k": -1, + "min_p": 0.0, + "frequency_penalty": 0.0, + "presence_penalty": 0.0, + "repetition_penalty": 1.0, +} + + +def training_sampling(sampling: dict[str, Any] | None) -> dict[str, float | int]: + """Validate an explicit full-vocabulary training policy for a capture session.""" + if sampling is None: + return {} + if not isinstance(sampling, dict): + raise ValueError("sampling must be a JSON object") + unknown = set(sampling) - {"temperature", *NON_POLICY_SAMPLING} + if unknown: + raise ValueError( + f"unsupported training sampling fields: {', '.join(sorted(unknown))}" + ) + temperature = sampling.get("temperature") + if ( + isinstance(temperature, bool) + or not isinstance(temperature, (float, int)) + or not 0 < temperature < float("inf") + ): + raise ValueError("training sampling requires a finite positive temperature") + for key, value in sampling.items(): + if key == "temperature": + continue + if isinstance(value, bool) or not isinstance(value, (float, int)): + raise ValueError(f"training sampling {key} must be numeric") + if key == "top_k" and value == 0: + continue + if value != NON_POLICY_SAMPLING[key]: + raise ValueError( + f"training sampling requires {key}={NON_POLICY_SAMPLING[key]}" + ) + return {"temperature": float(temperature), **NON_POLICY_SAMPLING} + + +def truncating_params(request: dict[str, Any]) -> dict[str, Any]: + """The entries of `request` that would narrow or distort the sampling distribution. + + Args: + request (`dict[str, Any]`): + A chat-completions body. + + Returns: + `dict[str, Any]`: Requested key -> value, for each knob set to something other than its no-op. + """ + found: dict[str, Any] = {} + for key, neutral in NON_POLICY_SAMPLING.items(): + value = request.get(key) + if ( + value is None + or not isinstance(value, (int, float)) + or isinstance(value, bool) + ): + continue + # `top_k` is disabled by -1 in vLLM and by 0 elsewhere; neither truncates. + if key == "top_k" and value in (-1, 0): + continue + if float(value) != float(neutral): + found[key] = value + return found + + +def prepare_request( + request: dict[str, Any], + *, + served_model: str | None = None, + capture_level: str = "tokens", +) -> dict[str, Any]: + """Add the params that make a response capturable. Mutates and returns `request`. + + Only the top level of `request` is mutated. Nested message dicts are copied before they are + rewritten, because the caller's messages are the same objects the graph stores: rewriting them + in place would mean a captured turn no longer records what the harness actually sent. + + Args: + request (`dict[str, Any]`): + The chat-completions body, already normalised by the dialect transformer. + served_model (`str`, *optional*): + Accepted for symmetry with the caller; the model name is set by the server. + capture_level (`str`, *optional*, defaults to `"tokens"`): + How much this endpoint tolerates. See the module docstring. + + Returns: + `dict[str, Any]`: The same object, edited. + """ + if capture_level == "tokens": + request["logprobs"] = True + request["return_token_ids"] = True + # See `NON_POLICY_SAMPLING`: a processed logprob is taken after these are applied, so leaving + # them on would silently bias every importance ratio computed from this rollout. + for key in truncating_params(request): + request[key] = NON_POLICY_SAMPLING[key] + elif capture_level == "logprobs": + request["logprobs"] = True + # At `text` nothing is injected at all. Current OpenAI models answer `logprobs` with a 400, and + # a rejected request is worse than a missing diagnostic: the agent's turn is simply lost. + + # Not `setdefault`: that keeps an explicitly-provided `None`, and vLLM only fills + # `logprobs.content[]` when `top_logprobs` is not None. A harness that sends + # `"top_logprobs": null` would then get a normal-looking response with no logprobs at all, so + # every turn it produced would be silently untrainable. + if capture_level != "text" and request.get("top_logprobs") is None: + request["top_logprobs"] = 0 + + # vLLM reads a prior turn's thinking from `reasoning`, while the dialect transformers emit the + # canonical `reasoning_content`. Without this rename an earlier turn's interleaved thinking + # renders as an empty `` and the prompt silently differs from what the model + # actually produced — which breaks prefix matching for the turn after it. + # + # That rename is a vLLM accommodation, so at `text` the field is dropped instead: a non-standard + # key inside a message is the same 400 hazard as a non-standard top-level param, and there is no + # prefix matching at that level for it to protect. + messages = request.get("messages") + if isinstance(messages, list): + rewritten: list[Any] = [] + for message in messages: + if ( + isinstance(message, dict) + and message.get("reasoning_content") is not None + ): + message = dict(message) + if capture_level == "text": + message.pop("reasoning_content") + else: + message["reasoning"] = message.pop("reasoning_content") + rewritten.append(message) + request["messages"] = rewritten + + # `_served_model` is an internal marker the dialect transformers read; the server sets it before + # transforming and the transformer strips it. Setting it here would be too late to be read and + # would leak an unknown field to the engine, so this only guarantees it is gone. + request.pop("_served_model", None) + return request + + +def normalize_response(response: dict[str, Any]) -> dict[str, Any]: + """Canonicalise the engine's response shape in place, so callers see one shape. + + Two engines, two placements for the same field. vLLM puts the prompt ids at the top level of the + response; SGLang puts them on each choice (`ChatCompletionResponseChoice.prompt_token_ids`, added + by sgl-project/sglang#30917). Every reader downstream — `check_upstream_response`, the capture + server's `_ingest`, the UI — looks only at the top level, so hoist rather than teach each of them + both spellings. + + Hoisted from `choices[0]` specifically, and only when the top level is empty: the prompt is a + property of the request, so with n>1 every choice carries the same list, and a top-level value + that is already present is the engine's own and must win. + """ + choices = response.get("choices") + if not isinstance(choices, list): + return response + + if ( + not response.get("prompt_token_ids") + and choices + and isinstance(choices[0], dict) + ): + hoisted = choices[0].get("prompt_token_ids") + if hoisted: + response["prompt_token_ids"] = hoisted + + for choice in choices: + if not isinstance(choice, dict): + continue + + message = choice.get("message") + if isinstance(message, dict): + if ( + message.get("reasoning_content") is None + and message.get("reasoning") is not None + ): + message["reasoning_content"] = message.pop("reasoning") + + # Copy each token id onto its logprob entry, and CHECK the pairing while doing it. + # + # The ids and the logprobs arrive on two separate channels and are joined by index. Equal + # length was the only guard, which an equal-length-but-SHIFTED pairing passes — a stop or EOS + # token present in one channel and not the other is enough — after which every logprob is + # attributed to its neighbour's token and training proceeds silently on the misattribution. + # + # When the engine runs with --return-tokens-as-token-ids the check is free and exact: each + # entry's `token` field literally reads `token_id:{id}`, so it can be compared against + # `token_ids[i]` at every position rather than inspected once for a warning. A disagreement + # drops the logprobs, which is what every other unusable-logprob path already does — the ids + # stay as real context and `sequence_for` masks the turn out of training. + token_ids = choice.get("token_ids") + entries = ((choice.get("logprobs") or {}).get("content")) or [] + if isinstance(token_ids, list) and len(token_ids) == len(entries): + mismatch = _pairing_mismatch(token_ids, entries) + if mismatch is not None: + position, declared, actual = mismatch + logger.warning( + "token id / logprob channels disagree at position %d (ids say %s, logprobs say " + "%s); dropping the logprobs for this turn rather than training on a shifted " + "pairing", + position, + declared, + actual, + ) + choice["logprobs"] = None + else: + for token_id, entry in zip(token_ids, entries): + if isinstance(entry, dict): + entry.setdefault("token_id", token_id) + return response + + +def _pairing_mismatch( + token_ids: list[Any], entries: list[Any] +) -> tuple[int, Any, Any] | None: + """The first position where the two channels disagree, or `None`. + + Only positions whose `token` field is in the `token_id:{id}` form can be checked; a server without + `--return-tokens-as-token-ids` emits token TEXT and is skipped, since decoding text back to an id + would need the tokenizer this design deliberately does not load. + """ + for position, (token_id, entry) in enumerate(zip(token_ids, entries)): + if not isinstance(entry, dict): + continue + token = entry.get("token") + if not isinstance(token, str) or not token.startswith("token_id:"): + continue + declared = token[len("token_id:") :] + if declared != str(token_id): + return position, token_id, declared + return None + + +def _retry_after_seconds(response: httpx.Response) -> float | None: + """The `Retry-After` delay a provider asked for, in seconds, if it gave a usable one. + + Only the delta-seconds form is honoured. The HTTP-date form is legal but rare here, and parsing + it wrong would either sleep for hours or not at all. + """ + raw = response.headers.get("retry-after") + if not raw: + return None + try: + return max(0.0, float(raw.strip())) + except ValueError: + return None + + +class InferenceClient: + """Async client to one engine. One instance per server, shared across sessions.""" + + # A high ceiling, not a per-request budget. Callers impose their own deadline; this exists only + # so a wedged engine cannot pin a connection forever. + _LIVENESS_TIMEOUT_S = 900.0 + _CONNECT_TIMEOUT_S = 30.0 + + # Hosted providers rate-limit; a local engine effectively never does. Without this a single 429 + # became a 502 to the agent, which truncates its trajectory while leaving a graph that looks + # perfectly well-formed — the failure class ATIF reconciliation exists to catch. + _RETRY_STATUSES = frozenset({408, 409, 429, 500, 502, 503, 504}) + _MAX_ATTEMPTS = 3 + _BACKOFF_S = 2.0 + # A `Retry-After` longer than this is not worth honouring inside one rollout; the sandbox has its + # own agent timeout and would be killed waiting. + _MAX_RETRY_AFTER_S = 60.0 + + def __init__( + self, + base_url: str, + *, + served_model: str | None = None, + api_key: str | None = None, + auth_header: str = "Authorization", + capture_level: str = "tokens", + provider: str = "openai", + ) -> None: + if provider not in ("openai", "anthropic", "hf", "vllm"): + raise ValueError(f"unknown upstream provider: {provider}") + self.provider = provider + if provider == "anthropic": + capture_level = "text" + auth_header = "x-api-key" + self.base_url = normalise_engine_base(base_url) + self.served_model = served_model + self.api_key = api_key or None + self.auth_header = auth_header or "Authorization" + self.capture_level = capture_level + # Fixes discovered from the provider's own 400s, applied to every later request. Cached + # because they are a property of the endpoint and the model, not of one call: rediscovering + # them per request would double the call count for the life of the server. + self.param_fixes: list[ParamFix] = [] + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=httpx.Timeout( + self._LIVENESS_TIMEOUT_S, connect=self._CONNECT_TIMEOUT_S + ), + headers={ + **auth_headers(self.api_key, self.auth_header), + **( + {"anthropic-version": "2023-06-01"} + if self.provider == "anthropic" + else {} + ), + }, + ) + return self._client + + async def aclose(self) -> None: + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + self._client = None + + async def completion(self, request: dict[str, Any]) -> dict[str, Any]: + """One non-streaming chat completion, prepared for capture and normalised on the way back.""" + if self.provider == "anthropic": + from .providers import anthropic_request, anthropic_response + + body = anthropic_request( + request, self.served_model or request.get("model", "") + ) + # Native request fields cannot pass through OpenAI-specific parameter repair. + native_headers = { + key: value + for key, value in request.get("_openenv_native_headers", {}).items() + if key in {"anthropic-beta", "anthropic-version"} + } + payload = await self._post_with_retries( + "/v1/messages", body, headers=native_headers + ) + response = anthropic_response( + payload, + native_passthrough=request.get("_openenv_native_request") is not None, + ) + response["_openenv_sampling"] = { + k: body[k] for k in ("temperature", "top_p", "top_k") if k in body + } + return response + body = prepare_request( + dict(request), + served_model=self.served_model, + capture_level=self.capture_level, + ) + payload = await self._post("/v1/chat/completions", body) + # The body now includes any compatibility edits made by `_post`. Keep the actual + # submitted policy separate from the harness's request; the server removes this marker. + payload["_openenv_sampling"] = { + key: body[key] + for key in ("temperature", *NON_POLICY_SAMPLING) + if key in body + } + return normalize_response(payload) + + async def list_models(self) -> dict[str, Any]: + client = await self._get_client() + try: + response = await client.get("/v1/models") + except httpx.RequestError as exc: + raise self._transport_error(exc) from exc + await self._raise_for_status(response) + return response.json() + + async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + """POST with two independent recovery paths: transient status, and a rejected parameter. + + They are separate because they mean different things. A 429 means "the same request, later"; + a 400 naming a parameter means "a different request, now". Conflating them would either sleep + through a permanent failure or hammer a provider that asked us to slow down. + """ + for fix in self.param_fixes: + fix.apply(body) + + while True: + try: + return await self._post_with_retries(path, body) + except UpstreamHTTPError as exc: + permission_for_logprobs = ( + exc.status_code == 403 + and "You are not allowed to request logprobs from this model" + in str(exc.body) + ) + if (exc.status_code != 400 and not permission_for_logprobs) or len( + self.param_fixes + ) >= MAX_FIXES: + raise + fix = diagnose(exc.body) + # `apply` returning False means the parameter it named is not in this body, so + # retrying would send the identical request and get the identical 400. + if fix is None or fix in self.param_fixes or not fix.apply(body): + raise + self.param_fixes.append(fix) + logger.info( + "upstream rejected a parameter; %s and retrying (this endpoint now carries " + "%d fix(es))", + fix, + len(self.param_fixes), + ) + + async def _post_with_retries( + self, path: str, body: dict[str, Any], *, headers: dict[str, str] | None = None + ) -> dict[str, Any]: + client = await self._get_client() + for attempt in range(1, self._MAX_ATTEMPTS + 1): + try: + response = await client.post( + path, json=body, **({"headers": headers} if headers else {}) + ) + except httpx.RequestError as exc: + raise self._transport_error(exc) from exc + if response.is_success: + return response.json() + + retry_after = _retry_after_seconds(response) + if ( + response.status_code not in self._RETRY_STATUSES + or attempt == self._MAX_ATTEMPTS + ): + await self._raise_for_status(response) + delay = ( + retry_after + if retry_after is not None + else self._BACKOFF_S * (2 ** (attempt - 1)) + ) + logger.info( + "upstream returned %d; retrying in %.1fs (attempt %d/%d)", + response.status_code, + delay, + attempt, + self._MAX_ATTEMPTS, + ) + await response.aclose() + await asyncio.sleep(min(delay, self._MAX_RETRY_AFTER_S)) + raise AssertionError("unreachable: the final attempt always raises or returns") + + async def _raise_for_status(self, response: httpx.Response) -> None: + if response.is_success: + return + content = await response.aread() + await response.aclose() + body: dict[str, Any] | str | None = None + text = content.decode("utf-8", errors="replace").strip() + if text: + try: + body = json.loads(text) + except json.JSONDecodeError: + body = text + raise UpstreamHTTPError(response.status_code, body) + + @staticmethod + def _transport_error(exc: httpx.RequestError) -> UpstreamError: + if isinstance(exc, httpx.TimeoutException): + return UpstreamTimeoutError(f"engine timed out: {exc}") + return UpstreamTransportError(f"could not reach engine: {exc}") diff --git a/src/openenv/core/harness/capture/validate.py b/src/openenv/core/harness/capture/validate.py new file mode 100644 index 0000000000..5500e880ce --- /dev/null +++ b/src/openenv/core/harness/capture/validate.py @@ -0,0 +1,455 @@ +"""Token-in / token-out validation. Nothing leaves this system unchecked. + +Capture failures in this stack are silent by construction. Every bug found so far returned a +perfectly well-formed payload and reported success: a missing `--return-tokens-as-token-ids` yields +text with no ids and trains on nothing; a harness that rewrites its history yields chains that stitch +into a trajectory the model never saw; an SSE client handed a JSON body yields zero tokens and no +error anywhere. None of these raise. All of them produce plausible JSON. + +So validation is not a debug aid here, it is the only thing standing between a clean-looking run and +weeks of training on corrupted sequences. Checks are graded: + + FATAL the row is not trainable. Drop it. Training on it is worse than dropping it. + WARN the row is trainable but something is off and should be understood. + INFO recorded for the per-harness notes. + +`check_upstream` runs before a rollout is spent (endpoint capability), `check_turn` runs per model +call (token/logprob alignment), `check_sequence` runs on the flattened output (mask/logprob +invariants), and `check_rollout` runs on the whole graph (structure). +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any + +FATAL, WARN, INFO = "FATAL", "WARN", "INFO" + + +@dataclass +class Finding: + level: str + code: str + detail: str + + def __str__(self) -> str: + return f"[{self.level}] {self.code}: {self.detail}" + + +@dataclass +class Report: + findings: list[Finding] = field(default_factory=list) + # Nodes the harness's own trace does not count as agent steps (auxiliary calls). Populated only + # by a harness-trace reconciler. Captured correctly, but excluded from training so they cannot be credited + # with the rollout's reward. Empty for every harness whose counts agree exactly. + aux_node_ids: list[str] = field(default_factory=list) + + def add(self, level: str, code: str, detail: str) -> None: + self.findings.append(Finding(level, code, detail)) + + @property + def fatal(self) -> list[Finding]: + return [f for f in self.findings if f.level == FATAL] + + @property + def ok(self) -> bool: + return not self.fatal + + def merge(self, other: "Report") -> "Report": + self.findings.extend(other.findings) + return self + + def text(self) -> str: + if not self.findings: + return "all checks passed" + return "\n".join(str(f) for f in self.findings) + + +# --- per model call ---------------------------------------------------- +def check_turn( + prompt_ids, sampled_ids, logprobs, *, finish_reason=None, index=0 +) -> Report: + """Validate one captured call before it becomes a graph node.""" + report = Report() + tag = f"turn {index}" + + for name, ids in (("prompt", prompt_ids), ("sampled", sampled_ids)): + if any(type(token) is not int or token < 0 for token in ids): + report.add( + FATAL, + "invalid_token_id", + f"{tag}: {name} ids must be non-negative integers", + ) + + if logprobs is not None: + if any( + isinstance(lp, bool) + or not isinstance(lp, (int, float)) + or not math.isfinite(lp) + for lp in logprobs + ): + report.add( + FATAL, "invalid_logprob", f"{tag}: logprobs must be finite numbers" + ) + elif any(lp > 0 for lp in logprobs): + report.add( + FATAL, "positive_logprob", f"{tag}: logprobs must not be positive" + ) + + if not prompt_ids: + report.add( + FATAL, + "no_prompt_ids", + f"{tag}: server returned no prompt_token_ids. The endpoint " + "is missing --return-tokens-as-token-ids, or the engine does not support it.", + ) + if not sampled_ids: + # Legitimate when the model is cut off at zero tokens, but nothing is trainable either way. + report.add( + WARN, + "no_sampled_ids", + f"{tag}: no completion token ids (finish={finish_reason})", + ) + + if logprobs is None: + if sampled_ids: + report.add( + FATAL, + "no_logprobs", + f"{tag}: {len(sampled_ids)} sampled tokens with no " + "logprobs. GRPO's importance ratio needs the behaviour-policy logprob for " + "every trainable token; without them the turn can only be context.", + ) + if finish_reason == "length": + # WARN rather than FATAL: the tokens and their logprobs are genuine, so the turn is real + # training data. What is not real is its ENDING — the model was cut off by the output cap + # mid-thought, and nothing downstream distinguished that from a turn that chose to stop. A + # trajectory made of truncated turns teaches the policy to stop early. + report.add( + WARN, + "truncated_turn", + f"{tag}: stopped on the output-token cap ({len(sampled_ids)} tokens), so this turn was " + "cut off rather than finished. Its tokens are valid; its ending is an artefact of the cap.", + ) + + if logprobs is not None and len(logprobs) != len(sampled_ids): + report.add( + FATAL, + "logprob_misalign", + f"{tag}: {len(logprobs)} logprobs vs {len(sampled_ids)} sampled ids. An off-by-one " + "here shifts credit onto the wrong tokens and still trains.", + ) + return report + + +def validate_training_turn(prompt_ids, sampled_ids, logprobs, loss_mask) -> None: + """Reject a malformed trainer-facing turn, preserving partial completion masks. + + The mask covers prompt plus completion. Masked completion tokens may retain their real + logprobs; the mask controls supervision, while the logprobs retain capture provenance. + """ + report = check_turn(prompt_ids, sampled_ids, logprobs) + if len(loss_mask) != len(prompt_ids) + len(sampled_ids): + report.add( + FATAL, "length_mismatch", "loss_mask must cover prompt plus completion" + ) + elif any(type(m) is not int or m not in (0, 1) for m in loss_mask): + report.add( + FATAL, "invalid_loss_mask", "loss_mask must contain only integer 0 or 1" + ) + elif any(loss_mask[: len(prompt_ids)]): + report.add(FATAL, "trainable_prompt", "prompt tokens must remain context") + if not report.ok: + raise ValueError( + "invalid training turn: " + "; ".join(str(f) for f in report.fatal) + ) + + +def check_turn_eval(response_message, *, finish_reason=None, index=0) -> Report: + """Validate one captured call on an endpoint that cannot return token ids. + + `check_turn` is the wrong instrument here: every one of its FATALs (`no_prompt_ids`, + `no_logprobs`) is the *expected* condition for a hosted provider, so running it would fill a + perfectly good eval rollout with fatal findings and teach everyone to ignore the findings list. + + What is still worth asserting is that the model actually said something. An empty completion with + no tool call is a turn the agent cannot act on, and it is the shape a content filter or a + truncated stream leaves behind. + """ + report = Report() + tag = f"turn {index}" + message = response_message if isinstance(response_message, dict) else {} + text = message.get("content") or "" + has_output = bool(str(text).strip()) or bool(message.get("tool_calls")) + + if not has_output: + report.add( + WARN, + "empty_completion", + f"{tag}: no text and no tool call (finish={finish_reason}). The agent has nothing " + "to act on; check for a content filter or a length cap.", + ) + if finish_reason == "length": + report.add( + INFO, + "truncated_turn", + f"{tag}: stopped on the output-token cap, so the turn is cut mid-thought", + ) + return report + + +# --- per flattened sequence ------------------------------------------- +def check_sequence(seq, *, min_trainable: int = 1) -> Report: + """Validate a flattened path. These are the invariants the trainer assumes and never re-checks.""" + report = Report() + n = len(seq.input_ids) + + if len(seq.loss_mask) != n or len(seq.logprobs) != n: + report.add( + FATAL, + "length_mismatch", + f"input_ids={n} loss_mask={len(seq.loss_mask)} logprobs={len(seq.logprobs)}", + ) + return report # every later check would be meaningless + + try: + validate_training_turn( + seq.input_ids[: seq.prompt_len], + seq.input_ids[seq.prompt_len :], + seq.logprobs[seq.prompt_len :], + seq.loss_mask, + ) + except ValueError as exc: + report.add(FATAL, "invalid_training_sequence", str(exc)) + return report + + trainable = sum(seq.loss_mask) + if trainable < min_trainable: + report.add( + FATAL, + "nothing_trainable", + f"{trainable} trainable tokens in a {n}-token sequence: this row contributes no " + "gradient and only shrinks the effective group", + ) + + # A masked position carrying a logprob means context was scored; a trainable position without one + # means a target was invented. Both are silent corruption, in opposite directions. + masked_with_lp = sum( + 1 for m, lp in zip(seq.loss_mask, seq.logprobs) if m == 0 and lp != 0.0 + ) + if masked_with_lp: + report.add( + FATAL, + "masked_has_logprob", + f"{masked_with_lp} context positions carry a non-zero logprob", + ) + + if seq.prompt_len >= n: + report.add( + FATAL, + "empty_response", + f"prompt_len={seq.prompt_len} covers the whole sequence", + ) + + # Not an error: a token with logprob exactly 0.0 has probability 1.0, which is common for + # structural tokens in a constrained tool-call grammar (``, closing brackets). + # Recorded so a *sudden* change in the rate is visible. + zero_lp_trainable = sum( + 1 for m, lp in zip(seq.loss_mask, seq.logprobs) if m == 1 and lp == 0.0 + ) + if zero_lp_trainable: + report.add( + INFO, + "certain_tokens", + f"{zero_lp_trainable}/{trainable} trainable tokens have logprob 0.0 (p=1.0)", + ) + + positive = [lp for lp in seq.logprobs if lp > 0.0] + if positive: + report.add( + FATAL, + "positive_logprob", + f"{len(positive)} logprobs > 0 (max {max(positive):.4f}); log-probabilities cannot " + "be positive, so these are not logprobs", + ) + return report + + +# --- per rollout ------------------------------------------------------- +def check_rollout( + graph, + *, + expect_single_root: bool = False, + capture_level: str = "tokens", + budget_stop_count: int = 0, +) -> Report: + """Validate graph structure. This is where harness-specific weirdness shows up first. + + `capture_level` below `tokens` changes what a root count MEANS. On the token path, one root per + turn diagnoses a harness that re-renders its prompt instead of appending. On an eval endpoint + there are no token ids to share a prefix with in the first place, so the same shape says nothing + about the harness — only that message-prefix linking could not match either, which is a property + of what the harness sends rather than a degradation of capture. + """ + report = Report() + stats = graph.stats() + + if stats["n_turns"] == 0: + report.add( + FATAL, + "no_turns", + "the intercept saw no model calls: the agent never reached it " + "(wrong base URL, unresolved model, or auth rejected)", + ) + return report + + if stats["n_roots"] == stats["n_turns"] and stats["n_turns"] > 1: + # One root PER TURN: the harness re-renders its prompt each turn instead of appending, so no + # token prefix is shared. terminus-2 does this (its message list grows 1,3,5..15 while the + # rendered tokens never line up). + # + # WARN, not FATAL. Each turn is still an exact prompt with exact sampled tokens and real + # logprobs, which is perfectly good SINGLE-turn training data. What is lost is cross-turn + # structure: each row repeats its own context. Rollout-level rewards still supervise + # these turns correctly, but repeated context increases compute and packing cost. + if capture_level == "tokens": + report.add( + WARN, + "per_turn_capture_only", + f"every turn is its own root ({stats['n_turns']}). This harness re-renders its " + "prompt rather than appending, so rows are single-turn. Tokens and logprobs are " + "exact; rollout rewards can still supervise every retained turn. Repeated context " + "increases training cost.", + ) + else: + report.add( + INFO, + "per_turn_trace_only", + f"every turn is its own root ({stats['n_turns']}). With no token ids, turns are " + "linked by message prefix, and this harness's messages do not extend each other " + "exactly — so the trace is per-call rather than one thread. Nothing is lost that " + "an eval rollout carries.", + ) + elif stats["n_roots"] > 1: + # Normal: aux calls (title generation), subagents, or a harness that rewrote its system + # prompt partway (claude-code) and so continued under a second prefix family. + report.add( + WARN, + "multiple_roots", + f"{stats['n_roots']} roots across {stats['n_turns']} turns. Each root is a separate " + "conversation (subagent, aux call, or a rewritten prompt that broke the chain).", + ) + elif expect_single_root and stats["n_roots"] != 1: + report.add(FATAL, "root_count", f"expected 1 root, got {stats['n_roots']}") + + if stats["n_turns"] == 1 and budget_stop_count <= 0: + # A recorded proxy budget stop explains a one-turn rollout. In particular, a tool can + # return enough data after the first call to exhaust the next prompt's context budget. + # Its original verifier score and sampled tokens remain valid; rejecting it here would + # turn a legitimate bounded attempt into an unintended retry. + # ONE call for an entire agentic task. Capture is trivially self-consistent here (a single + # turn has nothing to stitch to and no prefix to disagree with), so every other check in this + # file passes and the rollout reads as clean. It is not: an agent that made one model call + # and stopped did not attempt the task. + # + # Found the hard way. swe-agent, trae-agent, nemo-agent and antigravity-sdk each passed 5/5 + # while producing exactly one turn per task and solving 0/5, for four unrelated harness-side + # reasons (litellm cost registry, a null `prompt_tokens_details`, a tool-less prompt format, + # and an SDK loop that exits after the first tool call). The capture layer was right every + # time and the rollouts were still worthless. + # + # FATAL because the whole point of this layer is refusing to hand over data we cannot stand + # behind, and a one-turn agentic rollout is a harness failure wearing a clean capture. + report.add( + FATAL, + "degenerate_rollout", + "exactly 1 model call for the whole task: the agent stopped after its first " + "response. Capture is self-consistent because there is nothing to stitch, so the " + "other checks cannot see this. Read the trial's agent stdout for the real cause.", + ) + + if stats["n_discarded"]: + report.add( + WARN, + "discarded_turns", + f"{stats['n_discarded']} sampled turn(s) led nowhere (retries or resamples). They " + "are excluded from training paths; the tokens were still generated and billed.", + ) + if stats["n_forks"]: + report.add(INFO, "forks", f"{stats['n_forks']} fork point(s) in the graph") + return report + + +# --- endpoint capability, before spending a sandbox -------------------- +def check_upstream_response(payload: dict[str, Any]) -> Report: + """Assert a raw chat-completions reply actually carries what capture needs. + + Run this against the endpoint before booting anything. The failure it catches (an endpoint served + without the capture flags) otherwise surfaces only as empty training rows, hours later. + """ + report = Report() + choices = payload.get("choices") or [] + if not choices: + report.add(FATAL, "no_choices", "response has no choices") + return report + choice = choices[0] + + if not payload.get("prompt_token_ids"): + report.add( + FATAL, + "no_prompt_token_ids", + "no top-level prompt_token_ids. Serve with --return-tokens-as-token-ids; without " + "it multi-turn stitching is impossible because turn k+1's prompt is unknown.", + ) + if not choice.get("token_ids"): + report.add( + FATAL, + "no_completion_token_ids", + "choices[0].token_ids missing. Send return_token_ids=True and serve with " + "--return-tokens-as-token-ids.", + ) + + content = (choice.get("logprobs") or {}).get("content") + if not content: + report.add( + FATAL, + "no_logprobs", + "choices[0].logprobs.content missing. Send logprobs=True.", + ) + else: + if choice.get("token_ids") and len(content) != len(choice["token_ids"]): + report.add( + FATAL, + "logprob_misalign", + f"{len(content)} logprobs vs {len(choice['token_ids'])} token ids", + ) + token = (content[0] or {}).get("token", "") + if not str(token).startswith("token_id:"): + # This says more than it looks like it says, and the mild reading of it is what makes the + # failure silent. + # + # `token_id:N` in this field is what --return-tokens-as-token-ids produces. Its absence + # means that flag was not passed, and its partner --logprobs-mode processed_logprobs + # almost certainly was not either, since the docs present them as a pair. That second + # flag is the one that matters here: without it vLLM returns RAW (pre-temperature) + # logprobs instead of the sampled distribution's. + # + # Measured on one vLLM 0.25.1, same prompt and token at temperature 0.7: + # with both flags -1.3292 without -1.2546 + # Both are plausible, both align to the sampled ids, and `token_ids` arrives either way + # because it comes from the REQUEST parameter rather than from either flag. So every + # other check in this file passes and the rollout grades as fully trainable while + # carrying a wrong importance ratio. + report.add( + WARN, + "token_strings", + f"logprob tokens are strings ({token!r}) not 'token_id:N', so " + "--return-tokens-as-token-ids was not passed. Its partner --logprobs-mode " + "processed_logprobs is then probably absent too, which means these logprobs are " + "RAW (pre-temperature) rather than the sampling distribution's. Token ids arrive " + "regardless (they come from the request parameter), so nothing else here can catch " + "it: the rollout will look perfectly trainable and train on a wrong importance " + "ratio. Restart the engine with both flags.", + ) + return report diff --git a/src/openenv/core/harness/capture/validate_llm.py b/src/openenv/core/harness/capture/validate_llm.py new file mode 100644 index 0000000000..4132d07476 --- /dev/null +++ b/src/openenv/core/harness/capture/validate_llm.py @@ -0,0 +1,768 @@ +"""Work out what an inference endpoint can actually return, before anything is spent on it. + +This runs BEFORE a server binds a port, and grading honestly here is the entire point. + +An engine missing `--return-tokens-as-token-ids --logprobs-mode processed_logprobs` still answers +every request perfectly well: it returns text, a `200`, and plausible-looking usage. What it does not +return is token ids. Every row rebuilt downstream is then empty, training silently does nothing, and +the first symptom is a loss curve that never moves days later. That failure has no loud edge, so the +check has to be up front — and the answer has to travel with everything the endpoint later produces. + +The probe therefore settles a **capture level**, not a yes/no: + + tokens prompt ids, sampled ids, aligned logprobs. Trainable. vLLM with the two flags, or + SGLang from main. + logprobs logprobs, no ids. Not trainable: an unpaired logprob has no token to attach to. + text neither. + +Below `tokens` the endpoint is an eval backend — reward and full trace, no token fields — which is a +real, useful thing rather than a failure, so the server runs instead of refusing. What it must never +do is present an eval rollout as a training one, which is why the level is stamped on the result and +no contract is written without `tokens`. + +The probe also **negotiates**: hosted providers reject the capture params, and reject them per model +rather than per endpoint. `return_token_ids` is a 400 on OpenAI; `logprobs` is a 400 on every current +OpenAI model; `max_tokens` and `temperature: 0` are 400s there too. So a rejection is read (see +`compat.diagnose`), the offending param is dropped or renamed, and the request is retried — the level +is decided by what finally came back, never by what we hoped to send. + +Two engines implement the contract. vLLM, served with +`--return-tokens-as-token-ids --logprobs-mode processed_logprobs`. And SGLang built from `main`: +sgl-project/sglang#30917 (merged 2026-07-23) added `return_token_ids` to the OpenAI-compatible +routes, which is what sgl-project/sglang#18378 had asked for. Released SGLang is still unusable — +v0.5.16 carries only `return_prompt_token_ids`, the prompt without the sampled ids — so the version +matters and nothing but a live probe can tell the two apart, which is what this file does. SGLang +also puts the prompt ids per choice rather than at the top level; `normalize_response` absorbs that, +so the probe below runs its payload through it before grading. + +A hosted alternative exists but is narrow: fireworks-ai via the HF router honours vLLM's +`return_token_ids`, though every one of its live models is a reasoning model whose reasoning tokens +are dropped from history, so multi-turn stitching degrades to per-turn. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from dataclasses import dataclass, field + +from .compat import behaviour_warnings, diagnose, MAX_FIXES +from .upstream import auth_headers, normalise_engine_base, normalize_response +from .validate import check_upstream_response + +# The one place the engine requirements are spelled out. Three call sites used to carry their own +# copy of this text and two of them still said "vLLM" only, months after SGLang started working. +ENGINE_HINT = ( + "Token capture needs one of:\n" + " - vLLM, served with --return-tokens-as-token-ids --logprobs-mode processed_logprobs\n" + " - SGLang built from git main (sgl-project/sglang#30917); no serving flag needed, and its " + "logprobs are already temperature-scaled\n" + "Any other OpenAI-spec endpoint (OpenAI, Anthropic, HF Inference Providers) still works, as an " + "EVAL backend: you get the reward and the full trace, but no token ids or logprobs, so nothing " + "from it is trainable." +) + + +@dataclass +class LLMReport: + """Outcome of the probe. `ok` means trainable; `reachable` means usable at all.""" + + ok: bool + llm_url: str + model: str + findings: list[str] = field(default_factory=list) + n_prompt_ids: int = 0 + n_completion_ids: int = 0 + served_models: list[str] = field(default_factory=list) + # "tokens" | "logprobs" | "text", or "" when nothing came back at all. + capture_level: str = "" + reachable: bool = False + # Params this endpoint rejected and how we worked around them, as human-readable strings. Carried + # so that a rewritten request is visible: dropping `temperature` changes the sampling + # distribution, which makes an eval number irreproducible if nobody is told. + param_fixes: list[str] = field(default_factory=list) + # "ok" | "no-tool-call" | "rejected" | "unknown", or "" when not probed. Every validated harness + # sends a tool manifest on every call, and the capture probe sends none — so this is the only + # signal about whether a coding agent can work here at all. + tool_support: str = "" + # "processed" | "raw" | "unknown", or "" when the question was not asked (only `tokens`-level + # endpoints are asked, since nothing below it is trainable anyway). See `probe_logprobs_mode`. + logprobs_mode: str = "" + + @property + def trainable(self) -> bool: + return self.capture_level == "tokens" + + @property + def rollout_type(self) -> str: + return "train" if self.trainable else "eval" + + def summary(self) -> str: + if self.ok: + return ( + f"engine OK: {self.n_completion_ids} completion ids, " + f"{self.n_prompt_ids} prompt ids" + ) + if self.reachable: + detail = ( + "logprobs but no token ids" + if self.capture_level == "logprobs" + else "no token ids and no logprobs" + ) + return ( + f"endpoint is EVAL ONLY ({detail}); rollouts carry reward and trace but " + "nothing trainable" + ) + return "engine NOT reachable:\n " + "\n ".join(self.findings) + + +# Above this magnitude a "logprob" is an engine sentinel for -inf, not a measurement (vLLM uses +# -9999). Real values stay in the tens even in the tail of a 150k vocabulary. +_SENTINEL_LOGPROB = 100.0 + + +def _raw_logprobs_allowed() -> bool: + """Whether an operator has explicitly accepted raw logprobs on the training path. + + An escape hatch exists because the probe infers from behaviour: the two vLLM flags are + independent, and someone may have a configuration this cannot see. A refusal that cannot be + overridden becomes a reason to stop trusting the tool. + """ + return os.environ.get("OPENENV_ALLOW_RAW_LOGPROBS", "").strip().lower() in { + "1", + "true", + "yes", + } + + +def _post( + url: str, + body: dict, + timeout: float, + api_key: str | None = None, + auth_header: str = "Authorization", +) -> dict: + request = urllib.request.Request( + url, + data=json.dumps(body).encode(), + headers={ + "Content-Type": "application/json", + "User-Agent": "OpenEnv-provider-probe/1.0", + **auth_headers(api_key, auth_header), + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read()) + + +def list_models( + llm_url: str, + timeout: float = 30.0, + api_key: str | None = None, + auth_header: str = "Authorization", +) -> list[str]: + """Served model ids, or [] if the endpoint is unreachable or does not publish a list.""" + try: + request = urllib.request.Request( + f"{normalise_engine_base(llm_url)}/v1/models", + headers=auth_headers(api_key, auth_header), + ) + with urllib.request.urlopen(request, timeout=timeout) as r: + return [m.get("id", "") for m in json.loads(r.read()).get("data", [])] + except Exception: # noqa: BLE001 - unreachable is reported by the caller, not raised here + return [] + + +# Findings that say nothing beyond "this endpoint is not `tokens`", which the level already says. +# Deliberately does NOT include `no_choices`: an endpoint that answered without a `choices` array is +# broken rather than merely eval-only, and that has to keep surfacing. +_EXPECTED_BELOW_TOKENS = frozenset( + { + "no_prompt_token_ids", + "no_completion_token_ids", + "no_logprobs", + "token_strings", + } +) + + +def _grade(payload: dict) -> tuple[str, list[str]]: + """The capture level a response payload supports, and the findings behind that verdict. + + Grades the payload the same way the capture path will see it. `InferenceClient.completion` + normalises every response before it reaches `_ingest`, so grading the raw body instead would + reject an SGLang endpoint (per-choice prompt ids) that the rollout path handles perfectly well — + a false negative in the one check whose whole job is to be trusted. + """ + report = check_upstream_response(payload) + if report.ok: + return "tokens", [str(f) for f in report.findings] + + # Not `tokens`, so the only remaining question is whether logprobs came back at all. Read from + # the same place `_ingest` reads them, so the two cannot disagree. + choice = (payload.get("choices") or [{}])[0] + entries = ((choice.get("logprobs") or {}).get("content")) or [] + has_logprobs = any( + isinstance(entry, dict) and entry.get("logprob") is not None + for entry in entries + ) + level = "logprobs" if has_logprobs else "text" + + # Drop the findings that merely restate the tier. "no top-level prompt_token_ids", reported as + # FATAL, is the *definition* of an eval endpoint, and printing three fatal-looking lines under a + # heading that already says EVAL ONLY reads as a broken endpoint rather than a correctly + # classified one — which is how a findings list stops being read at all. Anything else the probe + # noticed still comes through. + expected = _EXPECTED_BELOW_TOKENS + return level, [ + str(f) for f in report.findings if getattr(f, "code", None) not in expected + ] + + +_PROBE_TOOL = [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command", + "parameters": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + }, + } +] + + +def probe_tool_support( + base: str, + model: str, + *, + timeout: float = 60.0, + api_key: str | None = None, + auth_header: str = "Authorization", + fixes: list | None = None, +) -> tuple[str, list]: + """Can this endpoint take a tool manifest, and what does it demand in exchange? + + A separate probe from the capture one, and deliberately NON-FATAL, because the two questions have + different consequences. The capture probe decides reachability and the training tier; this decides + whether a *coding agent* can work here, which no other check looks at — every validated harness + sends a tool manifest on every call, and the capture probe sends none. + + It is also the only place a specific class of problem is visible before a rollout is spent. Some + models accept tools only on terms that change their behaviour: + + gpt-5.6: "Function tools with reasoning_effort are not supported ... use /v1/responses or set + reasoning_effort to 'none'." + + The shim satisfies that by turning reasoning off, after which the model emits a valid first tool + call and then agentic loops die — goose and codex each managed exactly one model call and 0/3 + tasks, while scoring 3/3 against a non-reasoning model on the same endpoint. Without a tool in the + probe body that demand is never made, so `harbor info` looked perfectly healthy and the failure + only appeared minutes into a rollout. Hence: send a tool, and report what it cost. + + Returns: + `tuple[str, list]`: one of `"ok"` (a tool call came back), `"no-tool-call"` (accepted the + manifest but answered in prose), `"rejected"` (will not take tools at all) or `"unknown"`, + plus any additional [`ParamFix`] objects the endpoint demanded. + """ + body = { + "model": model, + "messages": [ + {"role": "user", "content": "Use the bash tool to list the files in /tmp."} + ], + "tools": _PROBE_TOOL, + "tool_choice": "auto", + # Generous on purpose. A reasoning model spends output tokens thinking before it emits the + # call, so a small cap truncates it mid-thought and looks exactly like "will not use tools": + # Qwen3.6-35B-A3B produced 224 tokens of reasoning and `finish_reason: length` at 64, and was + # reported as tool-incapable while in fact working with all 16 harnesses. + "max_tokens": 512, + "temperature": 1.0, + } + extra: list = [] + for fix in fixes or []: + fix.apply(body) + + while True: + try: + payload = _post( + f"{base}/v1/chat/completions", + body, + timeout, + api_key=api_key, + auth_header=auth_header, + ) + break + except urllib.error.HTTPError as exc: + detail = exc.read()[:600].decode(errors="replace") + fix = None + if exc.code == 400 and len(extra) < MAX_FIXES: + try: + fix = diagnose(json.loads(detail)) + except json.JSONDecodeError: + fix = diagnose(detail) + if fix is None or not fix.apply(body): + # `tools` is protected from being dropped, so an endpoint that simply cannot do tool + # calling ends up here rather than silently having the manifest removed. + return "rejected", extra + extra.append(fix) + except Exception: # noqa: BLE001 + return "unknown", extra + + choice = (payload.get("choices") or [{}])[0] + message = choice.get("message") or {} + if message.get("tool_calls"): + return "ok", extra + # Truncated before it could decide is not evidence about tool support. Reporting it as a failure + # is how a warning that fires on a healthy endpoint teaches everyone to ignore warnings. + if choice.get("finish_reason") == "length": + return "unknown", extra + return "no-tool-call", extra + + +def probe_logprobs_mode( + base: str, + model: str, + *, + timeout: float = 60.0, + api_key: str | None = None, + auth_header: str = "Authorization", + fixes: list | None = None, +) -> str: + """Whether an endpoint's logprobs are the sampling distribution's or the raw pre-temperature ones. + + This closes the one hole no other check here can see. vLLM's `logprobs_mode` defaults to + `raw_logprobs` (`vllm/config/model.py`), documented as the values "before applying any logit + processors, **including temperature and top_k/top_p**". Raw logprobs are aligned, negative, + correctly counted, and wrong: GRPO's importance ratio needs the logprob under the policy that + actually sampled the token. `token_ids` arrives either way, because it comes from the request + parameter rather than from a serving flag, so an engine launched with neither flag produces a + rollout that grades as fully trainable and trains on the wrong numbers. + + The test follows from the definition, and compares the GAP between the top two tokens rather + than absolute values. That matters: processed logprobs are `logsoftmax(logits / T)`, so a + difference between two of them is `(logit_a - logit_b) / T` — the normalising constant cancels. + The gap is therefore invariant under renormalisation and under any per-replica offset, which + absolute comparison is not. A first attempt compared values directly and misread a + data-parallel engine (DP=4) as processed, because consecutive calls landed on different replicas. + + Measured, three repeats each, on two live Qwen3.5-4B servers: + + --logprobs-mode processed_logprobs gap 6.7500 @T=1.0 -> 3.3750 @T=2.0 ratio 0.500 + default (raw_logprobs) gap 6.7500 @T=1.0 -> 6.7500 @T=2.0 ratio 1.000 + + So the expected ratio is `T1 / T2` when processed and `1.0` when raw, and the two are a factor of + two apart. No `seed` is sent and the sampled token is ignored: `top_logprobs` at the first + position is a property of the prompt and the temperature alone. + + Args: + base (`str`): + Engine root, already normalised. + model (`str`): + Model id to probe. + timeout (`float`, *optional*, defaults to `60.0`): + Per-request ceiling. + api_key (`str`, *optional*): + Upstream credential. + auth_header (`str`, *optional*, defaults to `"Authorization"`): + Header to send it under. + fixes (`list[ParamFix]`, *optional*): + Workarounds already discovered for this endpoint, applied to both probes. + + Returns: + `str`: `"processed"`, `"raw"`, or `"unknown"` when the endpoint cannot answer the question — + which is not a failure, only an absence of evidence. + """ + applied = list(fixes or []) + # A provider that rejected `temperature` outright cannot be asked a question about temperature. + if any(getattr(f, "param", "") == "temperature" for f in applied): + return "unknown" + + cool, hot = 1.0, 2.0 + gaps: list[float] = [] + for temperature in (cool, hot): + body = { + "model": model, + "messages": [{"role": "user", "content": "Reply with exactly: hello"}], + "max_tokens": 1, + "temperature": temperature, + "logprobs": True, + "top_logprobs": 5, + } + for fix in applied: + fix.apply(body) + try: + payload = _post( + f"{base}/v1/chat/completions", + body, + timeout, + api_key=api_key, + auth_header=auth_header, + ) + except Exception: # noqa: BLE001 - no evidence is a valid outcome, not an error + return "unknown" + entries = ( + ((payload.get("choices") or [{}])[0].get("logprobs") or {}).get("content") + ) or [] + if not entries: + return "unknown" + values = sorted( + ( + float(t["logprob"]) + for t in ((entries[0] or {}).get("top_logprobs") or []) + if isinstance(t, dict) and t.get("logprob") is not None + ), + reverse=True, + ) + if len(values) < 2: + return "unknown" + # Too PEAKED to divide by, the mirror of the flatness guard below. When the runner-up is + # -inf the engine reports a sentinel (vLLM: -9999), and +-inf is unchanged by division, so + # the ratio is 1.0 at every temperature and processed logprobs get misread as raw. + # + # This is not hypothetical. A reasoning model's chat template FORCES its first token -- + # `` for Qwen3, at p~1.0 with every alternative at -inf -- and position 1 is exactly + # where this probe measures, by design, because it is the only position independent of + # sampling. Measured on a live Qwen3-8B served WITH --logprobs-mode processed_logprobs: + # + # position 1 (forced ): gap 9999.0 @T=1.0 -> 9999.0 @T=2.0 ratio 1.000 "raw" + # position 1 after prefilling past : + # gap 3.7500 @T=1.0 -> 1.8750 @T=2.0 ratio 0.500 processed + # + # Three trials each; the second form is the same method at a non-degenerate position and + # agrees with the flag the engine was actually launched with. Without this guard every + # reasoning model with a forced opening token is demoted to the eval tier and every rollout + # comes back 409, which reads as "this model cannot train" rather than "this probe cannot + # measure". Real logprobs never approach this magnitude (a 150k-vocab tail bottoms out in the + # tens), so the threshold separates sentinels from data without needing to know the engine's + # chosen sentinel value. + if abs(values[1]) > _SENTINEL_LOGPROB: + return "unknown" + gaps.append(values[0] - values[1]) + + # Too flat to divide by. A near-uniform distribution makes the ratio noise, and guessing from + # noise is how a check becomes something people override on principle. + if gaps[0] < 0.5: + return "unknown" + + ratio = gaps[1] / gaps[0] + expected_processed = cool / hot + return "raw" if abs(ratio - 1.0) < abs(ratio - expected_processed) else "processed" + + +def _validate_anthropic(llm_url, model, *, timeout, api_key, check_tools): + """Probe native Messages without inferring token IDs from hosted text.""" + base = normalise_engine_base(llm_url) + report = LLMReport(ok=False, llm_url=base, model=model) + if not model: + report.findings.append("Native Anthropic requires an explicit model") + return report + body = { + "model": model, + "max_tokens": 64, + "messages": [{"role": "user", "content": "Reply with ok."}], + } + if check_tools: + body["messages"][0]["content"] = "Call report_ok with value ok." + body["tools"] = [ + { + "name": "report_ok", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ] + body["tool_choice"] = {"type": "tool", "name": "report_ok"} + request = urllib.request.Request( + f"{base}/v1/messages", + data=json.dumps(body).encode(), + headers={ + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + **auth_headers(api_key, "x-api-key"), + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read()) + if payload.get("type") != "message" or not isinstance( + payload.get("content"), list + ): + raise ValueError("Native Messages returned an invalid response shape") + if not payload["content"]: + raise ValueError("Native Messages returned no content") + report.reachable = True + report.capture_level = "text" + if check_tools: + report.tool_support = ( + "ok" + if any( + block.get("type") == "tool_use" + and block.get("name") == "report_ok" + and isinstance(block.get("input"), dict) + for block in payload["content"] + ) + else "no-tool-call" + ) + except urllib.error.HTTPError as exc: + report.findings.append(f"Native Messages HTTP {exc.code}") + except Exception as exc: + report.findings.append(f"Native Messages probe failed: {type(exc).__name__}") + return report + + +def validate_llm( + llm_url: str, + model: str, + *, + provider: str = "openai", + timeout: float = 120.0, + api_key: str | None = None, + auth_header: str = "Authorization", + check_logprobs_mode: bool = True, + check_tools: bool = True, +) -> LLMReport: + """Send one real completion and report what the endpoint can return. + + Deliberately a live probe rather than a flag inspection: launch flags are not readable over the + API, and an engine can be started with the right arguments and still not behave (wrong version, + a proxy in between that strips fields). The only trustworthy check is asking for a completion and + looking at what comes back. + + Args: + llm_url (`str`): + OpenAI-spec endpoint. Accepts both `http://host:8000` and `http://host:8000/v1`. + model (`str`): + Model id to probe. + timeout (`float`, *optional*, defaults to `120.0`): + Per-request ceiling. + api_key (`str`, *optional*): + Upstream credential, for a token-gated endpoint. + auth_header (`str`, *optional*, defaults to `"Authorization"`): + Header to send the credential under. + + Returns: + [`LLMReport`]: `capture_level`, whether it is trainable, and every workaround applied. + """ + # Accepts both `http://host:8000` and `http://host:8000/v1`, so a URL that works with any + # OpenAI SDK also works here instead of probing `/v1/v1/models` and reporting it dead. + if provider not in {"openai", "anthropic", "hf", "vllm"}: + raise ValueError(f"Unknown upstream provider: {provider}") + if provider == "anthropic": + return _validate_anthropic( + llm_url, model, timeout=timeout, api_key=api_key, check_tools=check_tools + ) + base = normalise_engine_base(llm_url) + served = list_models( + base, timeout=min(timeout, 30.0), api_key=api_key, auth_header=auth_header + ) + + # An empty list is no longer fatal on its own. Some hosted gateways do not publish `/v1/models`, + # or gate it behind different scopes than inference, and refusing there would reject an endpoint + # that serves completions perfectly well. The completion probe below is the real test; a missing + # list only costs us the model-name check. + listed_model = model.rsplit(":", 1)[0] if provider == "hf" else model + if served and model not in served and listed_model not in served: + # Worth failing on rather than warning: a mismatched name is silently accepted by some + # servers and then every request 404s at rollout time instead of at startup. + return LLMReport( + ok=False, + llm_url=base, + model=model, + served_models=served, + findings=[f"model {model!r} is not served here; available: {served}"], + ) + + body = { + "model": model, + "messages": [{"role": "user", "content": "Reply with the single word: ok"}], + "max_tokens": 8, + "temperature": 0.0, + "logprobs": True, + "top_logprobs": 0, + # vLLM >= 0.10.2 exposes this on the OpenAI route. Its ABSENCE from the response is exactly + # the signal we are testing for; OpenAI rejects the param outright, which `compat` handles. + "return_token_ids": True, + } + fixes: list[str] = [] + # The fix OBJECTS as well as their rendering: the logprobs-mode probe below issues its own + # requests and has to carry the same workarounds, or it would rediscover every one of them. + applied: list = [] + payload: dict | None = None + failure = "" + + # Negotiate down. Each 400 that names a parameter costs one retry and removes one obstacle; the + # capture params are the ones most likely to go, and losing them is what decides the level. + while payload is None: + try: + payload = _post( + f"{base}/v1/chat/completions", + body, + timeout, + api_key=api_key, + auth_header=auth_header, + ) + except urllib.error.HTTPError as exc: + detail = exc.read()[:600].decode(errors="replace") + fix = None + if ( + exc.code == 400 + or ( + exc.code == 403 + and "You are not allowed to request logprobs from this model" + in detail + ) + ) and len(fixes) < MAX_FIXES: + try: + fix = diagnose(json.loads(detail)) + except json.JSONDecodeError: + fix = diagnose(detail) + if fix is None or not fix.apply(body): + failure = f"probe failed: HTTP {exc.code}: {detail[:300]}" + break + fixes.append(str(fix)) + applied.append(fix) + except Exception as exc: # noqa: BLE001 + failure = f"probe failed: {type(exc).__name__}: {str(exc)[:300]}" + break + + if payload is None: + return LLMReport( + ok=False, + llm_url=base, + model=model, + served_models=served, + findings=[failure or "probe failed"], + param_fixes=fixes, + ) + + payload = normalize_response(payload) + level, findings = _grade(payload) + + # Surfaced at VALIDATE time, before a sandbox or a token is spent. A fix that only respells a + # field is noise; one that changes how the model behaves decides whether agent rollouts work at + # all, and the user has no way to know which they got from the fix list alone. + # A tool manifest is what every harness actually sends, so ask with one. Non-fatal: an endpoint + # that cannot do tool calling is still a usable eval backend for non-agentic work, and its + # reachability was already settled above. + tool_support = "" + if check_tools: + tool_support, tool_fixes = probe_tool_support( + base, + model, + timeout=min(timeout, 60.0), + api_key=api_key, + auth_header=auth_header, + fixes=applied, + ) + for fix in tool_fixes: + fixes.append(f"{fix} (needed for tool calling)") + applied.append(fix) + if tool_support == "rejected": + findings.append( + "[FATAL] no_tool_calling: this endpoint will not accept a tool manifest. Every " + "validated harness sends one on every call, so agent rollouts cannot work here." + ) + elif tool_support == "no-tool-call": + findings.append( + "[WARN] no_tool_call_emitted: the endpoint accepted a tool manifest but answered in " + "prose instead of calling the tool. Agent rollouts may stall on the first turn." + ) + + findings.extend( + f"[WARN] behaviour_changed: {w}" for w in behaviour_warnings(applied) + ) + choice = (payload.get("choices") or [{}])[0] + + # Only worth asking at `tokens`: below it nothing is trainable anyway, and the two extra calls + # would buy an answer no decision depends on. + mode = "" + if level == "tokens" and check_logprobs_mode: + mode = probe_logprobs_mode( + base, + model, + timeout=min(timeout, 60.0), + api_key=api_key, + auth_header=auth_header, + fixes=applied, + ) + # `token_strings` inferred, from the logprob token field, exactly what has now been + # MEASURED. Keeping both prints two paragraphs about one fact, the weaker one first. + if mode in {"raw", "processed"}: + findings = [f for f in findings if "token_strings" not in f] + if mode == "raw": + if _raw_logprobs_allowed(): + findings.append( + "[WARN] raw_logprobs_forced: these logprobs are RAW (pre-temperature) and " + "$OPENENV_ALLOW_RAW_LOGPROBS is set, so they are being treated as trainable " + "anyway. GRPO's importance ratio will be wrong." + ) + else: + # Demoted rather than failed. The endpoint answers perfectly well and is a fine eval + # backend; what it cannot do is produce a training contract, and `capture_level` is + # exactly the field that says so. Refusing outright would take away a usable server + # over a problem that only affects training. + level = "logprobs" + findings.append( + "[FATAL] raw_logprobs: measured RAW (pre-temperature) logprobs — the same " + "token's logprob did not change between temperature 1.0 and 2.0. Token ids " + "are present, so nothing else here would have caught this, and the rollout " + "would have looked perfectly trainable while carrying a wrong importance " + "ratio. Restart the engine with --logprobs-mode processed_logprobs (SGLang " + "from main is already temperature-scaled). Downgraded to EVAL; set " + "OPENENV_ALLOW_RAW_LOGPROBS=1 to override." + ) + + return LLMReport( + ok=level == "tokens", + llm_url=base, + model=model, + served_models=served, + findings=findings, + n_prompt_ids=len(payload.get("prompt_token_ids") or []), + n_completion_ids=len(choice.get("token_ids") or []), + capture_level=level, + reachable=True, + param_fixes=fixes, + logprobs_mode=mode, + tool_support=tool_support, + ) + + +def require_llm( + llm_url: str, + model: str, + *, + timeout: float = 120.0, + api_key: str | None = None, + auth_header: str = "Authorization", + require_tokens: bool = False, +) -> LLMReport: + """`validate_llm`, but raises when the endpoint cannot be used at all. + + Args: + require_tokens (`bool`, *optional*, defaults to `False`): + Also raise when the endpoint is reachable but cannot return token ids. Off by default: + such an endpoint is a working eval backend, and refusing it would rule out every hosted + provider. Set it on a path where an eval rollout is worthless — a training run. + + Raises: + RuntimeError: If the endpoint is unreachable, or `require_tokens` and it is eval-only. + """ + report = validate_llm( + llm_url, model, timeout=timeout, api_key=api_key, auth_header=auth_header + ) + if not report.reachable: + raise RuntimeError(report.summary() + "\n\n" + ENGINE_HINT) + if require_tokens and not report.trainable: + raise RuntimeError( + report.summary() + "\n\nThis path needs trainable rollouts.\n" + ENGINE_HINT + ) + return report diff --git a/src/openenv/core/mcp_client.py b/src/openenv/core/mcp_client.py index e172f32eef..ce80357e86 100644 --- a/src/openenv/core/mcp_client.py +++ b/src/openenv/core/mcp_client.py @@ -113,6 +113,7 @@ def __init__( websocket_ping_timeout_s: Optional[float] = 20.0, provider: Optional[Any] = None, mode: Optional[str] = None, + max_message_size_mb: float = 100.0, ): """ Initialize MCP client. @@ -132,6 +133,11 @@ def __init__( Container/runtime provider for lifecycle management. mode (`str`, *optional*): Communication mode. Must be 'production' for MCP clients. Defaults to 'production'. + max_message_size_mb (`float`, *optional*, defaults to `100.0`): + Largest WebSocket frame to accept. `EnvClient` has always taken this, but + `MCPClientBase` did not forward it, so no MCP client could raise it — an environment + whose tool returns a large result closed the connection with `1009 message too big` + and there was no way to ask for more from the client side. """ # MCPClientBase defaults to production mode, but allow override for validation if mode is None: @@ -153,6 +159,7 @@ def __init__( websocket_ping_timeout_s=websocket_ping_timeout_s, provider=provider, mode=mode, + max_message_size_mb=max_message_size_mb, ) self._tools_cache: Optional[List[Tool]] = None self.use_production_mode = self._mode == "production" diff --git a/src/openenv/harbor/__init__.py b/src/openenv/harbor/__init__.py new file mode 100644 index 0000000000..edf7c0ca29 --- /dev/null +++ b/src/openenv/harbor/__init__.py @@ -0,0 +1,18 @@ +"""Harbor integration: run Harbor tasks as OpenEnv environments, for eval and training. + +Harbor owns what it is good at — task datasets, sandbox backends, coding agents, verifiers, trial +concurrency, pass@k. This package adds the OpenEnv side and nothing more: + + tasks.py dataset discovery over the Task API (HF repo | local dir | Harbor registry) + seams.py how each agent is pointed at the capture proxy — the only per-agent knowledge + install_fixes.py subclasses for agents whose Harbor wrapper cannot be configured as shipped + atif.py cross-check captured tokens against Harbor's own ATIF trajectory + models.py wire types + +Generic capture lives in `openenv.core.harness.capture` and knows nothing about Harbor. The +dependency runs one way only: `openenv.harbor` imports capture, never the reverse. ATIF is here +rather than there because it is Harbor's trace format, not a general one. + +Harbor itself is an optional dependency (`pip install openenv[harbor]`), imported lazily so that +importing `openenv` never requires it. +""" diff --git a/src/openenv/harbor/atif.py b/src/openenv/harbor/atif.py new file mode 100644 index 0000000000..2a67ab640d --- /dev/null +++ b/src/openenv/harbor/atif.py @@ -0,0 +1,563 @@ +"""Reconcile our token capture against Harbor's ATIF trajectory. The independent cross-check. + +Harbor agents write `agent/trajectory.json` in ATIF (Agent Trajectory Interchange Format, currently +v1.7), a published spec for logging agent interaction histories across debugging, SFT and RL. ~27 of +Harbor's agents build ATIF trajectories, which is far more than the six the docs list. + +Why this matters more than it sounds. ATIF and the intercept measure the same rollout through +completely independent paths: the harness counts its own tokens and reports them to Harbor, while we +derive ours from engine-returned token ids stitched along a graph. If they agree turn by turn, the +masking, the turn segmentation and the prefix stitching are all correct simultaneously. Nothing else +we can run gives that assurance, because every internal check shares our own assumptions. + +Validated on opencode + Qwen3.5-4B, 8 agent steps: + + ATIF completion_tokens : [37, 36, 104, 264, 255, 119, 32, 27] total 874 + intercept turn_lengths : [37, 36, 104, 264, 255, 119, 32, 27] total 874 + ATIF step-1 prompt_tokens 7990 == intercept prompt_len 7990 + +ATIF also carries three things a proxy structurally cannot see, which is the other half of the value: + + llm_call_count >1 means the harness burned several model calls on one logical step, + i.e. it retried. A proxy sees the calls but not that they were retries. + subagent_trajectories nested trajectories (v1.7). Ground truth for which turns are a subagent, + instead of inferring it from graph roots. + tool_call_id <-> observation.source_call_id + which tool result answered which call. + +`Metrics` in ATIF has optional `logprobs` and `completion_token_ids` fields, which harnesses leave +empty. So the end state is not two formats to reconcile: it is ATIF with our token fields filled in, +one artifact that is trace, SFT dataset and RL data at once. `merge_into_atif` does that. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from openenv.core.harness.capture.contract import BUDGET_STOP_MESSAGE +from openenv.core.harness.capture.validate import FATAL, INFO, Report, WARN + + +def load_atif(trial_dir: str | Path) -> dict[str, Any] | None: + """Read `agent/trajectory.json` from a Harbor trial dir. None if the agent emitted none.""" + path = Path(trial_dir) / "agent" / "trajectory.json" + if not path.is_file(): + return None + try: + return json.loads(path.read_text()) + except Exception: # noqa: BLE001 - a malformed trace must not break a good rollout + return None + + +def _pi_session_as_atif(trial_dir: Path) -> dict[str, Any] | None: + """pi's own session log, reshaped into the two ATIF fields reconciliation reads. + + pi writes no `trajectory.json`, but it does write `agent/pi/sessions/*.jsonl`, and every + assistant record there carries `usage.output`: the completion-token count for that call, which + is exactly what `metrics.completion_tokens` provides in ATIF. So the cross-check is available + for pi after all, just under a different name and shape. + """ + sessions = sorted((trial_dir / "agent" / "pi" / "sessions").glob("*.jsonl")) + if not sessions: + return None + steps: list[dict[str, Any]] = [] + for raw in sessions[-1].read_text().splitlines(): + try: + record = json.loads(raw) + except Exception: # noqa: BLE001 - a malformed line must not lose the whole trace + continue + message = record.get("message") or {} + if record.get("type") != "message" or message.get("role") != "assistant": + continue + usage = message.get("usage") or {} + steps.append( + { + "source": "agent", + "metrics": {"completion_tokens": int(usage.get("output") or 0)}, + } + ) + return {"steps": steps} if steps else None + + +# Harnesses that emit no ATIF but do write something equivalent. Keyed by nothing in particular: +# each reader inspects the trial directory and returns None when its format is absent, so the order +# only decides which wins if two ever match. +_FALLBACK_TRACES = (_pi_session_as_atif,) + + +def load_trace(trial_dir: str | Path) -> tuple[dict[str, Any] | None, str]: + """The best available independent record of this rollout. + + Returns: + `tuple[dict | None, str]`: The trace and where it came from, one of `atif`, the name of a + fallback reader, or `""` when the harness recorded nothing to compare against. + """ + trial_dir = Path(trial_dir) + atif = load_atif(trial_dir) + if atif is not None: + return atif, "atif" + for reader in _FALLBACK_TRACES: + try: + trace = reader(trial_dir) + except Exception: # noqa: BLE001 - a fallback is a bonus, never a failure mode + continue + if trace is not None: + return trace, reader.__name__.strip("_").replace("_as_atif", "") + return None, "" + + +def agent_steps(atif: dict[str, Any]) -> list[dict[str, Any]]: + """Steps the agent produced. `source` is one of user | agent | system.""" + return [s for s in (atif.get("steps") or []) if s.get("source") == "agent"] + + +def atif_turn_lengths(atif: dict[str, Any]) -> list[int]: + return [ + int((s.get("metrics") or {}).get("completion_tokens") or 0) + for s in agent_steps(atif) + ] + + +def _subsequence_gap(needle: list[int], haystack: list[int]) -> list[int] | None: + """Indices of `haystack` skipped when `needle` is matched as a subsequence, else None. + + Greedy two-pointer, which is exact for subsequence membership. Returns None the moment a needle + element cannot be found, so a genuine disagreement (a value we never captured, or counts that + differ) falls through to the FATAL path rather than being explained away as auxiliary calls. + + Requires the needle to be strictly shorter; equal lists are handled by the exact-match path and + a LONGER needle means ATIF logged calls we never saw, which is a capture failure, not aux calls. + """ + if len(needle) >= len(haystack): + return None + skipped: list[int] = [] + j = 0 + for i, value in enumerate(haystack): + if j < len(needle) and value == needle[j]: + j += 1 + else: + skipped.append(i) + return skipped if j == len(needle) else None + + +def _reconcile_eval( + document: dict[str, Any], atif: dict[str, Any], report: Report +) -> Report: + """Cross-check an eval rollout against ATIF on call COUNT, since token counts do not exist. + + The main path compares per-call completion token counts, which an eval endpoint never returns — + every captured turn has `n_sampled == 0`, so that comparison would report a mismatch on every + rollout and `no_agent_sequence` would fire first anyway, `sequences` being empty by design. + + Counts are still worth comparing, and this is not a consolation prize: the one real bug ATIF + reconciliation has caught was a harness whose trajectory was TRUNCATED (an empty `tools` array + drew a 400 from vLLM) while the graph stayed well-formed. That is a count disagreement, and it is + just as visible here. + + A mismatch is a WARN rather than FATAL. On the training path a disagreement means data we cannot + corroborate and must not learn from; here there is nothing to learn from in the first place, and + the trace is still the honest record of what happened. + """ + ours = len(document.get("turns") or []) + theirs = len(atif_turn_lengths(atif)) + if not theirs: + report.add( + INFO, + "atif_no_agent_steps", + f"ATIF logged no agent steps; {ours} captured call(s) stand unverified", + ) + return report + + report.add( + INFO, + "eval_reconcile_counts_only", + "eval rollout: compared call counts only, since the endpoint returns no token counts", + ) + if ours < theirs: + report.add( + WARN, + "atif_calls_missing", + f"ATIF logged {theirs} agent step(s) but only {ours} were captured. Calls the harness " + "made did not reach the proxy, so the trace is incomplete.", + ) + elif ours > theirs: + report.add( + INFO, + "atif_extra_calls", + f"captured {ours} call(s) against {theirs} ATIF agent step(s); the extra ones are " + "auxiliary (token counting, title generation, a 'next speaker' check)", + ) + return report + + +def _partial_usage_matches_by_call_id(turns, steps) -> bool: + """Match missing usage by unique native call identity, never by treating None as zero. + + ACP can report usage only on the final response while retaining model-generated + tool call IDs on preceding steps. Known counts must still agree exactly. The + equal-length/order requirement deliberately does not infer auxiliary calls. + """ + if len(turns) != len(steps) or not steps: + return False + counts = [(step.get("metrics") or {}).get("completion_tokens") for step in steps] + if all(count is None for count in counts) or all( + count is not None for count in counts + ): + return False + seen = set() + for turn, step, count in zip(turns, steps, counts, strict=True): + if count is not None: + if count != turn["n_sampled"]: + return False + continue + ours = [ + call.get("id") + for call in ((turn.get("response_message") or {}).get("tool_calls") or []) + ] + theirs = [call.get("tool_call_id") for call in step.get("tool_calls", []) or []] + if ( + not ours + or ours != theirs + or any(not isinstance(value, str) or not value for value in ours) + ): + return False + if len(set(ours)) != len(ours) or seen.intersection(ours): + return False + seen.update(ours) + return True + + +def reconcile(document: dict[str, Any], atif: dict[str, Any] | None) -> Report: + """Compare the exported rollout against ATIF. Disagreement is the signal. + + Deliberately FATAL on a per-turn mismatch. Two independent measurements of the same rollout + disagreeing means one is wrong, and we cannot tell which. Training on data we cannot corroborate + is exactly the failure this whole layer exists to prevent. + """ + report = Report() + if atif is None: + report.add( + INFO, "no_atif", "agent emitted no ATIF trajectory; cross-check unavailable" + ) + return report + + report.add( + INFO, + "atif_version", + f"schema {atif.get('schema_version')} " + f"agent {(atif.get('agent') or {}).get('name')}", + ) + + # The proxy's terminal budget response is not a model call. Some harnesses log it as an + # assistant step anyway. Only discount exact zero-token control messages backed by the + # proxy's own counter; an unknown zero-token step or a sampled copy of this text still fails. + remaining_stops = document.get("budget_stop_count", 0) + steps = [] + skipped_stops = 0 + skipped_api_errors = 0 + for step in atif.get("steps") or []: + metrics = step.get("metrics") or {} + # Claude Code writes its terminal API error as an assistant message with + # the reserved model name. Harbor preserves that provenance. + # It is not a sampled response; ordinary zero-token or unmarked records + # still participate in the strict comparison below. + if ( + step.get("source") == "agent" + and step.get("model_name") == "" + and str(step.get("message", "")).startswith("API Error: ") + and not step.get("tool_calls") + and metrics.get("completion_tokens") == 0 + and metrics.get("prompt_tokens") == 0 + and metrics.get("cached_tokens", 0) == 0 + ): + skipped_api_errors += 1 + continue + if ( + remaining_stops > 0 + and step.get("source") == "agent" + and step.get("message") == BUDGET_STOP_MESSAGE + and not step.get("tool_calls") + and metrics.get("completion_tokens") in (None, 0) + and metrics.get("prompt_tokens") in (None, 0) + ): + remaining_stops -= 1 + skipped_stops += 1 + else: + steps.append(step) + if skipped_stops or skipped_api_errors: + atif = {**atif, "steps": steps} + if skipped_api_errors: + report.add( + WARN, + "atif_synthetic_api_error", + f"excluded {skipped_api_errors} explicitly synthetic zero-token API error record(s); " + "only actual model responses are compared and trained", + ) + if skipped_stops: + report.add( + INFO, + "proxy_budget_stops", + f"excluded {skipped_stops} zero-token proxy stop message(s) from the model-call cross-check", + ) + + if document.get("rollout_type", "train") == "eval": + return _reconcile_eval(document, atif, report) + + agent_rows = [r for r in document["sequences"] if r["role"] == "agent"] + if not agent_rows: + # When the intercept saw NO calls at all, `check_rollout` already says so plainly. Adding a + # second FATAL here just buries the real cause: I misread this as a distinct failure mode + # twice tonight before noticing it was always downstream of `no_turns`. + if not document.get("turns"): + report.add( + INFO, + "no_turns_upstream", + "no model calls were captured, so there is nothing to reconcile; see the " + "rollout's own no_turns finding for the cause", + ) + else: + report.add( + FATAL, + "no_agent_sequence", + f"{len(document['turns'])} calls captured but none labelled 'agent'", + ) + return report + + # Compare against EVERY call the agent made, in arrival order, across ALL agent roots. + # + # Two reasons this is the right comparison rather than the surviving training path: + # * ATIF logs every LLM call including retries, so a rollout that retried would report a false + # mismatch. Comparing the full list also validates the discard decisions: get one wrong and + # the equality breaks. + # * A harness that rewrites its system prompt mid-run (claude-code) splits one conversation + # across several roots. ATIF still sees one flat list, so the union of agent roots is what + # lines up with it. + agent_roots = {r["root_id"] for r in agent_rows} + turns = [t for t in document.get("turns", []) if t["root_id"] in agent_roots] + ours_all = [t["n_sampled"] for t in turns] + theirs = atif_turn_lengths(atif) + + if _partial_usage_matches_by_call_id(turns, agent_steps(atif)): + report.add( + WARN, + "atif_partial_usage", + "ATIF omits some per-call token counts. All reported counts agree; " + "missing-count steps match captured responses by unique tool-call IDs in order. " + "No calls were inferred auxiliary or removed. Exact token supervision remains " + "engine-derived; the missing counts have no independent token cross-check.", + ) + return report + + # A converter that never fills in token counts gives us nothing to compare against. vibe reports + # completion_tokens=0 on every step while capturing perfectly (reward 1.0, 6 turns, 1197 + # trainable tokens), so calling that a MISMATCH would fail a harness for its trace converter's + # laziness rather than for anything wrong with the rollout. Downgrade to "no cross-check + # available", which is what `atif=none` already means for harnesses that emit nothing at all. + if not any(theirs): + # Covers BOTH shapes of an unhelpful converter: all-zero counts (vibe) and no agent steps at + # all (antigravity-sdk reported `[]` while we captured a 145-token call). Either way there is + # nothing to compare against, and failing the rollout would punish it for the trace + # converter's gaps rather than for anything wrong with the capture. + detail = ( + f"{len(theirs)} steps but all completion_tokens are 0" + if theirs + else "no agent steps at all" + ) + report.add( + WARN, + "atif_no_token_counts", + f"ATIF has {detail}, so no independent cross-check is possible. Intercept " + f"captured {sum(ours_all)} tokens across {len(ours_all)} calls; those numbers " + "stand unverified.", + ) + return report + + # ATIF being a strict SUBSEQUENCE of our calls is a real and benign shape, distinct from a + # disagreement. It means we saw calls the harness did not log as agent steps, which is what an + # auxiliary call is: gemini-cli fires a "next speaker" check between steps, mini-swe-agent does + # something similar. Both measurements are individually right. + # + # intercept: [58, 132, 266, 370, 105, 32, 54, 164, 33] + # ATIF: [58, 132, 266, 370, 105, 32, 33] + # + # The old code called any inequality FATAL, which failed a rollout for correctly capturing MORE + # than the harness chose to log. Note the asymmetry that makes this safe: extra calls on OUR side + # are explainable, whereas MISSING calls would mean we lost something, and that still fails. + # + # The aux calls are identified by position and excluded from training, because they are not the + # agent working on the task and must not carry the rollout's reward. + aux_idx = _subsequence_gap(theirs, ours_all) if ours_all != theirs else None + + # A subsequence match only carries evidence when ATIF accounts for MOST of what we captured. + # The shorter the ATIF list relative to ours, the more likely a match is coincidence: 5 values + # will embed in 49 almost by construction, so "the other 44 are auxiliary" is an inference the + # data does not support. + # + # Real example that forced this. mimo on one task: 49 captured calls, ATIF logged 5, and the + # matcher happily demoted 44 to auxiliary under a WARN, discarding 90% of a rollout without + # failing anything. Contrast the cases where the inference IS sound, where ATIF covers the large + # majority: gemini-cli 8/12, 14/19, 6/10, mini-swe-agent 5/6. + # + # Below the floor we refuse rather than guess. Silently training on a tenth of a rollout is a + # worse outcome than an explicit failure, which is the whole premise of this layer. + if aux_idx is not None and len(theirs) < 0.5 * len(ours_all): + report.add( + FATAL, + "atif_coverage_too_low", + f"ATIF accounts for only {len(theirs)} of {len(ours_all)} captured calls " + f"({len(theirs) / len(ours_all):.0%}). They embed as a subsequence, but at this " + "ratio that is as likely coincidence as signal, so the extra calls cannot be " + "called auxiliary with any confidence. Refusing rather than discarding " + f"{len(aux_idx)} calls on a guess.\n" + f" intercept: {ours_all}\n ATIF : {theirs}", + ) + return report + + if aux_idx is not None: + aux_nodes = [turns[i]["node_id"] for i in aux_idx] + report.add( + WARN, + "atif_aux_calls", + f"{len(aux_idx)} of {len(ours_all)} captured calls are absent from ATIF, so the " + f"harness did not consider them agent steps (auxiliary calls such as " + f"gemini-cli's next-speaker check). The remaining {len(theirs)} agree " + f"token-for-token. Sizes: {[ours_all[i] for i in aux_idx]}. These are excluded " + f"from training rather than credited with the rollout's reward.", + ) + report.aux_node_ids = aux_nodes + return report + + if ours_all == theirs: + n_discarded = sum(1 for t in turns if t["discarded"]) + n_trained = sum(len(r["turn_lengths"]) for r in agent_rows) + detail = ( + f"all {len(ours_all)} calls agree token-for-token (total {sum(ours_all)}); " + f"{n_discarded} discarded, {n_trained} trained" + ) + if len(agent_rows) > 1: + detail += ( + f"; across {len(agent_rows)} agent sequences (the harness rewrote its prompt " + "mid-run, so the conversation spans several token-prefix families)" + ) + report.add(INFO, "turns_match", detail) + else: + report.add( + FATAL, + "turn_mismatch", + f"per-call completion tokens disagree.\n" + f" intercept (all calls): {ours_all}\n" + f" ATIF : {theirs}\n" + f" intercept (trained) : {[r['turn_lengths'] for r in agent_rows]}", + ) + + steps = agent_steps(atif) + if steps: + atif_prompt = int((steps[0].get("metrics") or {}).get("prompt_tokens") or 0) + # The first agent sequence in arrival order holds the rollout's opening prompt. + first_prompt_len = agent_rows[0]["prompt_len"] + if atif_prompt and atif_prompt != first_prompt_len: + report.add( + WARN, + "prompt_len_mismatch", + f"first-turn prompt: intercept {first_prompt_len} vs ATIF {atif_prompt}", + ) + + retried = [s for s in steps if int(s.get("llm_call_count") or 1) > 1] + if retried: + report.add( + WARN, + "atif_retries", + f"{len(retried)} ATIF step(s) report llm_call_count>1: the harness retried. " + f"Graph found {document['stats']['n_discarded']} discarded turn(s).", + ) + + subagents = atif.get("subagent_trajectories") or [] + if subagents: + # FATAL, not WARN. The old text said subagent turns "must not be trained with the parent + # rollout's reward" and then did nothing to stop it: no node ids were collected, no role was + # changed, so if a subagent's calls landed in the same session as `agent` roots they shipped as + # trainable carrying the parent's reward — the exact outcome the sentence forbade. The aux path + # right above demotes; this one only complained. + # + # Refusing rather than guessing which roots belong to the subagent: ATIF gives trajectories, + # not the node ids that would let us demote precisely, and picking roots by shape here would be + # inventing an attribution. A rollout whose reward cannot be attributed is not trainable. + report.add( + FATAL, + "atif_subagents", + f"ATIF reports {len(subagents)} subagent trajectory(ies) and the graph has " + f"{document['stats']['n_roots']} root(s). Subagent turns must not be trained with the " + "parent rollout's reward, and ATIF does not say which captured calls are theirs, so " + "this rollout cannot be attributed. Run this harness without subagents to train on it.", + ) + return report + + +def merge_into_atif(atif: dict[str, Any], document: dict[str, Any]) -> dict[str, Any]: + """Fill ATIF's empty `completion_token_ids` / `logprobs` with our captured values. + + Produces one artifact that is both the human-readable trace and the training data, rather than + two formats a consumer has to join. Only attempted when the per-turn counts already agree; a + mismatch means we cannot map our tokens onto their steps, and guessing would be worse than + leaving the fields empty. + """ + agent_rows = [r for r in document["sequences"] if r["role"] == "agent"] + if not agent_rows: + return atif + + # ATIF logs every call the harness made; our training rows contain only the ones that survived, + # possibly split across several roots. Align on the FULL call list in arrival order and skip the + # discarded positions, rather than zipping lists of different length and silently shifting every + # token onto the wrong step. + agent_roots = {r["root_id"] for r in agent_rows} + turns_meta = [t for t in document.get("turns", []) if t["root_id"] in agent_roots] + steps_all = [s for s in atif.get("steps") or [] if s.get("source") == "agent"] + if len(turns_meta) != len(steps_all): + return atif + if [t["n_sampled"] for t in turns_meta] != atif_turn_lengths(atif): + return atif + + merged = json.loads(json.dumps(atif)) # never mutate Harbor's artifact in place + steps = [s for s in merged["steps"] if s.get("source") == "agent"] + + # Sampled tokens from every agent sequence, in the order their nodes arrived. + by_node: dict[str, list[tuple[int, float]]] = {} + for row in agent_rows: + sampled = [ + (tid, lp) + for tid, m, lp in zip(row["input_ids"], row["loss_mask"], row["logprobs"]) + if m + ] + # One mask-run per node that contributed trainable tokens. If a node was masked out (its + # logprobs could not be trusted) the counts diverge and the node->span mapping is no longer + # reliable, so we decline to merge rather than attach tokens to the wrong step. + if len(row["node_ids"]) != len(row["turn_lengths"]): + return atif + cursor = 0 + for node_id, length in zip(row["node_ids"], row["turn_lengths"]): + by_node[node_id] = sampled[cursor : cursor + length] + cursor += length + + for meta, step in zip(turns_meta, steps): + metrics = step.setdefault("metrics", {}) + if meta["discarded"]: + # Generated, then abandoned by the harness. Recorded so the trace stays complete, but + # flagged so nobody trains it with the rollout's reward. + metrics["discarded"] = True + continue + span = by_node.get(meta["node_id"]) + if span is None: + continue + metrics["completion_token_ids"] = [t for t, _ in span] + metrics["logprobs"] = [lp for _, lp in span] + + first = agent_rows[0] + merged.setdefault("extra", {})["intercept"] = { + "prompt_ids": first["input_ids"][: first["prompt_len"]], + "n_trainable": sum(r["n_trainable"] for r in agent_rows), + "n_agent_sequences": len(agent_rows), + "session_id": document["session_id"], + } + return merged diff --git a/src/openenv/harbor/capabilities.py b/src/openenv/harbor/capabilities.py new file mode 100644 index 0000000000..6fb2c156c9 --- /dev/null +++ b/src/openenv/harbor/capabilities.py @@ -0,0 +1,316 @@ +"""What can this server actually run, right now? + +A client should not have to guess. `capabilities()` answers three questions in one call — which +harnesses exist and how well each is trusted, which sandboxes have working credentials, and which +datasets are served — so the failure "you asked for a sandbox whose API key is missing" happens at +discovery time instead of 90 seconds into a rollout. + +Credential checks reuse Harbor's own `preflight()` per backend rather than reimplementing key +lookups. Two things about that call have to be handled and are easy to miss: + + * it raises **`SystemExit`**, a `BaseException`, so a bare `except Exception` will not catch it and + the server process dies instead of reporting an unavailable sandbox + * `EnvironmentFactory.run_preflight` is never called by `Job.create()` — only Harbor's CLI calls + it — so a library caller gets no credential validation at all unless it asks +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from . import seams + +# Harbor agents that run HOST-SIDE, in this server's process, rather than being installed into the +# sandbox. The distinction is not cosmetic: their LLM traffic never leaves the host, so they reach +# the capture proxy on localhost and need no public URL at all. +HOST_SIDE_AGENTS = frozenset({"terminus-2", "computer-1", "oracle", "nop", "dspy-rlm"}) + +# Backends worth advertising. Harbor registers 23; these are the ones with a credential story we +# check and have exercised. Others still work via `--sandbox `, just unadvertised. +KNOWN_SANDBOXES = ("docker", "e2b", "modal", "daytona") + + +@dataclass +class SandboxStatus: + name: str + available: bool + detail: str = "" + + +@dataclass +class HarnessStatus: + name: str + dialect: str + kind: str # "installed" (runs in the sandbox) | "base" (runs host-side) + # "validated" | "unstable:" | "unsupported:" | "blocked:" | "untested". + # Only "validated" is offered for comparison work; see seams.py for what each one means. + status: str + needs_subclass: bool # True when Harbor's wrapper cannot be configured as shipped + notes: str = "" + + +@dataclass +class Capabilities: + harnesses: list[HarnessStatus] = field(default_factory=list) + sandboxes: list[SandboxStatus] = field(default_factory=list) + datasets: list[dict[str, Any]] = field(default_factory=list) + llm: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "harnesses": [vars(h) for h in self.harnesses], + "sandboxes": [vars(s) for s in self.sandboxes], + "datasets": self.datasets, + "llm": self.llm, + } + + @property + def available_sandboxes(self) -> list[str]: + return [s.name for s in self.sandboxes if s.available] + + @property + def validated_harnesses(self) -> list[str]: + return [h.name for h in self.harnesses if h.status == "validated"] + + def render(self, *, verbose: bool = False) -> str: + """Human-readable summary, printed at server start. + + Deliberately shows what is NOT available and why, not just what is. A sandbox missing its + API key is the single most common reason a rollout dies 90 seconds in, and Harbor's own + preflight message names the exact variable — so it is worth surfacing at startup rather than + making someone read a traceback later. + + Args: + verbose (`bool`, *optional*, defaults to `False`): + List every harness instead of only the validated ones. + + Returns: + `str`: A multi-line report. + """ + out: list[str] = [] + + if self.llm: + model = self.llm.get("model", "?") + ok = self.llm.get("ok") + level = self.llm.get("capture_level") or "" + if ok: + mark = "TRAIN" + elif self.llm.get("reachable"): + # Loud, and in the same column as a failure, because this is the line that decides + # whether anything this server produces can be trained on. It is not an error, and it + # must not read as a success either. + mark = "EVAL ONLY" + elif ok is False: + mark = "FAILED" + else: + mark = "unchecked" + out.append(f"llm {model} [{mark}]") + if self.llm.get("url"): + auth = " (authenticated)" if self.llm.get("authenticated") else "" + out.append(f" {self.llm['url']}{auth}") + if mark == "TRAIN" and self.llm.get("logprobs_mode"): + # Worth a line even when everything is fine: it is the one property of a trainable + # endpoint that is invisible in the data and wrong by default. + out.append( + f" logprobs: {self.llm['logprobs_mode']}" + + ( + " (temperature-scaled, as training needs)" + if self.llm["logprobs_mode"] == "processed" + else "" + ) + ) + if mark == "EVAL ONLY": + if self.llm.get("logprobs_mode") == "raw": + # Demoted for a different reason than the tier name suggests: this endpoint has + # token ids, and saying "no token ids" about it would be simply untrue. + detail = "token ids present but logprobs are RAW (pre-temperature)" + elif level == "logprobs": + detail = "logprobs, no token ids" + else: + detail = "no token ids, no logprobs" + out.append( + f" {detail} — rollouts carry reward and trace but are NOT trainable" + ) + for fix in self.llm.get("param_fixes") or []: + # A rewritten request is a changed experiment: dropping `temperature` alters the + # sampling distribution, so it cannot be a silent accommodation. + out.append(f" upstream compat: {fix}") + # Anything the probe flagged. A `[TRAIN]` endpoint can still carry a warning worth + # reading — raw rather than processed logprobs being the one that looks perfect and + # trains on the wrong importance ratio — so these print at every level, not just on + # failure. INFO is dropped; it is detail, not a decision. + for finding in self.llm.get("findings") or []: + if not str(finding).startswith("[INFO]"): + out.append(f" {_wrap_finding(str(finding))}") + + usable = [s for s in self.sandboxes if s.available] + out.append(f"\nsandboxes {len(usable)} of {len(self.sandboxes)} usable") + for s in self.sandboxes: + if s.available: + out.append(f" [ok] {s.name}") + else: + out.append(f" [--] {s.name:<10} {s.detail[:88]}") + + if self.datasets: + total = sum(d.get("num_tasks", 0) for d in self.datasets) + out.append(f"\ndatasets {len(self.datasets)} split(s), {total} tasks") + for d in self.datasets: + if d.get("error"): + out.append(f" [--] {d['name']:<48} {d['error'][:60]}") + else: + out.append( + f" [ok] {d['name']:<48} {d.get('num_tasks', 0):>6} tasks" + ) + + validated = [h for h in self.harnesses if h.status == "validated"] + out.append( + f"\nharnesses {len(validated)} validated of {len(self.harnesses)} known" + ) + shown = self.harnesses if verbose else validated + by_dialect: dict[str, list[str]] = {} + for h in shown: + label = h.name + ("*" if h.kind == "base" else "") + by_dialect.setdefault(h.dialect, []).append(label) + for dialect, names in sorted(by_dialect.items()): + out.append(f" {dialect:<18} {', '.join(sorted(names))}") + out.append(" (* runs host-side in this process, so it needs no public URL)") + + if not usable: + out.append( + "\nWARNING: no sandbox has working credentials; every rollout will fail." + ) + return "\n".join(out) + + +def _wrap_finding(text: str, width: int = 92, indent: str = " " * 10) -> str: + """Wrap one finding to the terminal, keeping the continuation under the same column. + + The long ones matter most and are the ones a single line truncates into uselessness. + """ + import textwrap + + lines = textwrap.wrap(text, width=width) or [text] + return ("\n" + indent).join(lines) + + +def _missing_sdk(environment_class: Any) -> str: + """Report the backend's own verdict on whether its SDK is importable. + + Loading the class is not enough. Every Harbor backend guards its provider SDK with a + module-level `try: import ... except ImportError: _HAS_X = False`, and raises `MissingExtraError` + from `__init__` rather than at import. So the module imports cleanly, the class loads cleanly, + the backend reports available, and the failure arrives only once a rollout tries to build a + sandbox, by which time it reads as a broken rollout rather than a missing dependency. + + Reading the flag asks the backend the same question its constructor will ask, before offering it. + + Returns: + `str`: A message naming the missing extra, or `""` when the SDK is present. + """ + import sys + + module = sys.modules.get(getattr(environment_class, "__module__", ""), None) + if module is None: + return "" + absent = sorted( + flag + for flag, value in vars(module).items() + if flag.startswith("_HAS_") and value is False + ) + if not absent: + return "" + extras = ", ".join(flag.removeprefix("_HAS_").lower() for flag in absent) + # Deliberately not `harbor[cloud]`: that extra cannot be installed at all, because it pulls + # `langsmith[sandbox]` and `tensorlake` together and they demand incompatible `websockets` + # ranges. Pointing someone at it would send them to a resolver error. + return ( + f"SDK not installed ({extras}). Install it with: pip install 'openenv[harbor]', " + "which pulls every sandbox backend." + ) + + +def check_sandbox(name: str) -> SandboxStatus: + """Ask Harbor whether this backend's credentials are present. + + `SystemExit` is caught explicitly: Harbor's preflight raises it as its failure signal, and it + does not inherit from `Exception`. + """ + try: + from harbor.environments.factory import ( + _load_environment_class, + EnvironmentFactory, + ) + from harbor.models.environment_type import EnvironmentType + except ImportError as exc: + return SandboxStatus(name, False, f"harbor not installed: {exc}") + + try: + env_type = EnvironmentType(name) + except ValueError: + return SandboxStatus(name, False, f"unknown environment type {name!r}") + + # Credentials are only half of it. Harbor's preflight checks env vars but never imports the + # backend, so a provider with perfect credentials and no SDK installed reports "available" and + # then fails at rollout time with MissingExtraError. Load the class first. + try: + environment_class = _load_environment_class(env_type) + except Exception as exc: # noqa: BLE001 - Harbor raises its own MissingExtraError here + hint = str(exc).replace("\n", " ")[:160] + return SandboxStatus( + name, False, hint or f"backend {name!r} could not be loaded" + ) + + missing = _missing_sdk(environment_class) + if missing: + return SandboxStatus(name, False, missing) + + try: + EnvironmentFactory.run_preflight(env_type) + except SystemExit as exc: + # Harbor's own message names the missing variable; pass it through rather than paraphrase. + return SandboxStatus(name, False, str(exc) or "credentials missing") + except ImportError as exc: + return SandboxStatus( + name, False, f"extra not installed: pip install 'harbor[{name}]' ({exc})" + ) + except Exception as exc: # noqa: BLE001 - an unexpected failure is still "not available" + return SandboxStatus(name, False, f"{type(exc).__name__}: {str(exc)[:160]}") + return SandboxStatus(name, True) + + +def list_harnesses() -> list[HarnessStatus]: + """Every harness with a seam, and how much to trust it.""" + out: list[HarnessStatus] = [] + for name in sorted(set(seams.SEAMS)): + seam = seams.get(name) + out.append( + HarnessStatus( + name=name, + dialect=seam.dialect, + kind="base" if name in HOST_SIDE_AGENTS else "installed", + status=seam.status, + needs_subclass=seam.import_path is not None, + notes=seam.notes, + ) + ) + return out + + +def capabilities( + *, + datasets: list[str] | None = None, + sandboxes: tuple[str, ...] = KNOWN_SANDBOXES, + llm: dict[str, Any] | None = None, +) -> Capabilities: + """Full picture. Safe to call on a fresh instance; does no I/O beyond credential checks.""" + result = Capabilities( + harnesses=list_harnesses(), + sandboxes=[check_sandbox(s) for s in sandboxes], + llm=dict(llm or {}), + ) + if datasets: + from .tasks import HarborTaskProvider + + result.datasets = HarborTaskProvider(datasets).list_splits() + return result diff --git a/src/openenv/harbor/client.py b/src/openenv/harbor/client.py new file mode 100644 index 0000000000..96ca65673b --- /dev/null +++ b/src/openenv/harbor/client.py @@ -0,0 +1,216 @@ +"""Typed client for a deployed harbor_env server. + +Two surfaces, matching the server: the Task API for discovery (plain HTTP, cheap, side-effect free) +and one MCP tool for execution (a single long call). + +```python +from openenv.harbor.client import HarborEnv + +with HarborEnv(base_url="http://localhost:8000") as env: + split = env.splits()[0]["name"] + print(env.num_tasks(split)) + result = env.run_rollout(split=split, task_index=0, harness="opencode", sandbox="e2b") + print(result.reward, result.turns[0].completion_token_ids[:8]) +``` +""" + +from __future__ import annotations + +import inspect +import json +from typing import Any + +import httpx +from openenv.core.env_server.mcp_types import CallToolAction, CallToolObservation +from openenv.core.mcp_client import MCPToolClient +from openenv.core.utils import run_async_safely + +from .models import HarborRolloutResult, HarborTaskRef + +# A rollout is minutes. The base client defaults to 60s, which fires mid-run. +_DEFAULT_MESSAGE_TIMEOUT_S = 1800.0 + + +class HarborEnv(MCPToolClient): + """Client for `harbor_env`. + + Args: + base_url (`str`): + Server root, e.g. `http://localhost:8000`. + message_timeout_s (`float`, *optional*, defaults to `1800.0`): + Websocket message timeout. Raised well above the base client's 60s because a single + rollout runs for minutes and the default would time out mid-call. + """ + + def __init__( + self, + base_url: str, + *, + message_timeout_s: float = _DEFAULT_MESSAGE_TIMEOUT_S, + **kwargs: Any, + ) -> None: + super().__init__( + base_url=base_url, message_timeout_s=message_timeout_s, **kwargs + ) + self._http = httpx.Client(base_url=base_url.rstrip("/"), timeout=120.0) + + # --- discovery (Task API) -------------------------------------------- + def splits(self) -> list[dict[str, Any]]: + """Datasets this server offers, with task counts.""" + return self._http.get("/harbor_env/splits").raise_for_status().json() + + def num_tasks(self, split: str = "") -> int: + payload = self._post("/harbor_env/num_tasks", {"split": split}) + return int(payload.get("num_tasks", 0)) + + def get_task(self, split: str, index: int) -> HarborTaskRef: + payload = self._post("/harbor_env/task", {"split": split, "index": index}) + return HarborTaskRef.model_validate(payload.get("task", payload)) + + def get_task_range( + self, split: str, start: int = 0, stop: int = 20 + ) -> list[HarborTaskRef]: + payload = self._post( + "/harbor_env/task_range", {"split": split, "start": start, "stop": stop} + ) + return [HarborTaskRef.model_validate(t) for t in payload.get("tasks", [])] + + # --- execution (MCP) --------------------------------------------------- + def run_rollout( + self, + *, + split: str = "", + task_index: int = 0, + harness: str = "opencode", + sandbox: str = "e2b", + reward_key: str = "", + keep_sandbox: bool = False, + force_build: bool = False, + llm_url: str = "", + model: str = "", + api_key: str = "", + auth_header: str = "", + provider: str = "openai", + purpose: str = "auto", + eval_sampling: dict[str, Any] | None = None, + agent_timeout_sec: float = 0.0, + agent_step_limit: int = 0, + sampling: dict[str, Any] | None = None, + ) -> HarborRolloutResult: + """Run one rollout and return its result. + + `harness` and `sandbox` are per-call, so consecutive rollouts can use different agents and + different backends against the same server. + + Args: + split (`str`, *optional*): + Dataset spec. Defaults to the server's first. + task_index (`int`, *optional*, defaults to `0`): + Index into the split. + harness (`str`, *optional*, defaults to `"opencode"`): + A validated seam name, or a `module:Class` import path for your own agent. + sandbox (`str`, *optional*, defaults to `"e2b"`): + Harbor environment type, e.g. `e2b` or `modal`. + reward_key (`str`, *optional*): + Which reward key is the training signal, for multi-reward tasks. + llm_url (`str`, *optional*): + Engine for THIS rollout. Probed on first use (cached per engine) and the measured + tier decides `rollout_type`: token ids plus processed logprobs give `train`, anything + less gives `eval`. Omit to use whatever engine the server was booted with, if any. + model (`str`, *optional*): + Served model id at `llm_url`. Resolved automatically when that endpoint serves + exactly one model. + api_key (`str`, *optional*): + Credential for `llm_url`, when it is token-gated. + auth_header (`str`, *optional*): + Header to send the credential under, when not `Authorization`. + agent_step_limit (`int`, *optional*): + Cap model calls at the proxy, including auxiliary calls, and set a native step + limit where supported. `0` leaves model calls uncapped. + agent_timeout_sec (`float`, *optional*): + Override the agent's execution timeout; `0` uses the task's configured value. + Sandbox setup retains its separate build and healthcheck timeouts. + sampling (`dict`, *optional*): + Explicit full-vocabulary training policy, e.g. `{"temperature": 0.8}`. Use the + trainer's recompute temperature. Requested and submitted policies remain separate. + + Returns: + [`HarborRolloutResult`]: Reward, per-turn token ids and logprobs, and findings. + """ + raw = self._call( + "run_rollout", + split=split, + task_index=task_index, + harness=harness, + sandbox=sandbox, + reward_key=reward_key, + keep_sandbox=keep_sandbox, + force_build=force_build, + llm_url=llm_url, + model=model, + api_key=api_key, + auth_header=auth_header, + # Preserve the established OpenAI/auto wire contract with older + # servers. Explicit new semantics must still be sent (and rejected + # by a server that cannot implement them). + **({"provider": provider} if provider != "openai" else {}), + **({"purpose": purpose} if purpose != "auto" else {}), + **({"eval_sampling": eval_sampling} if eval_sampling is not None else {}), + agent_timeout_sec=agent_timeout_sec, + agent_step_limit=agent_step_limit, + **({"sampling": sampling} if sampling is not None else {}), + ) + return HarborRolloutResult.model_validate_json(_as_text(raw)) + + def capabilities(self) -> dict[str, Any]: + """Harnesses, sandboxes, datasets and LLM status for this server.""" + return json.loads(_as_text(self._call("capabilities"))) + + # --- internals --------------------------------------------------------- + def _call(self, name: str, **kwargs: Any) -> Any: + """Call an MCP tool from synchronous code. + + `MCPToolClient.call_tool` cannot be used here. It is a coroutine that internally does + `await self.step(action)`, but `EnvClient.step` dispatches on execution mode and returns a + concrete `StepResult` in sync mode, so awaiting it raises `TypeError: object StepResult + can't be used in 'await' expression`. Driving `step` directly works in both modes. + """ + result = self.step(CallToolAction(tool_name=name, arguments=kwargs)) + if inspect.isawaitable(result): # async mode returns an awaitable instead + result = run_async_safely(result) + + observation = result.observation + if isinstance(observation, CallToolObservation): + if observation.error is not None: + raise RuntimeError( + f"tool {name!r} failed: {observation.error.message} " + f"({observation.error.error_type.value})" + ) + return observation.result + return observation + + def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + return self._http.post(path, json=body).raise_for_status().json() + + def close(self) -> None: + try: + self._http.close() + finally: + super().close() + + +def _as_text(raw: Any) -> str: + """MCP tool results arrive as text content; unwrap whatever shape the transport used.""" + if isinstance(raw, str): + return raw + if isinstance(raw, dict): + content = raw.get("content") + if isinstance(content, list) and content: + first = content[0] + if isinstance(first, dict) and "text" in first: + return str(first["text"]) + return json.dumps(raw) + if isinstance(raw, list) and raw: + first = raw[0] + return str(getattr(first, "text", first)) + return str(raw) diff --git a/src/openenv/harbor/contract.py b/src/openenv/harbor/contract.py new file mode 100644 index 0000000000..c11361005c --- /dev/null +++ b/src/openenv/harbor/contract.py @@ -0,0 +1,193 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# Licensed under the BSD-style license in the repository LICENSE file. + +"""Authoritative Harbor training export shared by clients, trainers and the UI.""" + +from __future__ import annotations + +from typing import Any + +from openenv.core.harness.capture.validate import validate_training_turn + +from .models import HarborRolloutResult + + +def _openai_tool_calls(flat: list[dict[str, Any]]) -> list[dict[str, Any]]: + """`{name, arguments}` -> OpenAI's `{id, type, function: {name, arguments}}`. + + `HarborTurn.tool_calls` is deliberately flattened: a reward function checking which tool ran should + not have to walk a wire envelope. But TRL reads `message["tool_calls"]` verbatim, and both + `has_tool_call` and `apply_chat_template` expect the nested form. Handing over the flat shape makes + `has_tool_call` false for every turn, so `train_turn_fn=has_tool_call` — the documented default for + a coding agent — discards the entire rollout while the run still looks healthy. Templates that + iterate `call.function.name` would raise instead. + + `arguments` is left exactly as captured. It is a JSON *string* on the wire, and TRL's + `_decode_tool_call_arguments` parses it before rendering, so parsing it here would hand the + template a dict it does not expect. + """ + out: list[dict[str, Any]] = [] + for index, call in enumerate(flat or []): + if not isinstance(call, dict): + continue + # Already nested (a future capture change, or another dialect): pass it through untouched. + if call.get("function"): + out.append(call) + continue + name = call.get("name") + if not name: + continue + out.append( + { + # An id is required by the schema and is what pairs a call with its tool result. The + # capture does not keep the harness's own id, so a positional one is minted: within a + # single assistant message that is enough to keep the pairing unambiguous. + "id": str(call.get("id") or f"call_{index}"), + "type": "function", + "function": { + "name": str(name), + "arguments": call.get("arguments", ""), + }, + } + ) + return out + + +def to_trace_entries(result: HarborRolloutResult) -> list[dict[str, Any]]: + """`HarborRolloutResult` -> TRL `TraceEntry` records, one per trainable turn. + + Auxiliary calls and discarded retries are already excluded by the server, so a caller needs no + `agent_turn_fn`: the capture layer can tell an aux call from an agent turn structurally, which a + flat trace cannot. + + `request_messages` is what makes this possible at all. Without it the token fields say what was + produced but not what produced them, and no `TraceEntry` can be built. + """ + if result.rollout_type == "eval": + raise ValueError("eval-only rollout has no exact-token training contract") + fatal = [finding for finding in result.findings if "[FATAL" in finding] + if fatal: + raise ValueError( + "cannot train a capture with fatal validation findings: " + fatal[0] + ) + entries: list[dict[str, Any]] = [] + for turn in result.turns or []: + if ( + turn.role != "agent" + or turn.discarded + or not turn.trainable + or not turn.completion_token_ids + ): + continue + mask = turn.loss_mask + if mask is None: + mask = [0] * len(turn.prompt_token_ids) + [1] * len( + turn.completion_token_ids + ) + validate_training_turn( + turn.prompt_token_ids, turn.completion_token_ids, turn.per_token_logps, mask + ) + entries.append( + { + "request": { + "messages": list(turn.request_messages), + "tools": turn.request_tools, + }, + "response": { + "choices": [ + { + "message": { + "role": "assistant", + "content": turn.text, + "tool_calls": _openai_tool_calls(turn.tool_calls) + or None, + }, + "finish_reason": turn.finish_reason, + } + ] + }, + # The engine's own tokenization, carried through rather than dropped. HarborTurn has + # held this since it was introduced ("not a local re-render", models.py); it simply + # had nowhere to go until TraceEntry gained the field. A consumer that has it must + # not call apply_chat_template -- that re-render matched the engine on 0 of 28 + # measured turns and collapsed a run at its first weight update. + "prompt_token_ids": list(turn.prompt_token_ids), + "completion_token_ids": list(turn.completion_token_ids), + "per_token_logps": list(turn.per_token_logps), + # Preserve partial completion masks after reconciliation; eligibility is not + # inferable from token ids or the turn-level trainable flag. + "loss_mask": list(mask), + "metadata": { + "turn": turn.turn, + "node_id": turn.node_id, + "sampling_params": dict(turn.sampling_params), + "requested_sampling_params": dict(turn.requested_sampling_params), + "n_tools": turn.n_tools, + "finish_reason": turn.finish_reason, + }, + } + ) + return entries + + +def export_training_contract(result: HarborRolloutResult) -> dict[str, Any]: + """Export validated supervision and an explicit audit of excluded turns. + + Args: + result (`HarborRolloutResult`): The captured and reconciled rollout. + + Returns: + `dict`: Versioned trace entries, masked turn records and task outcome. + """ + entries = to_trace_entries(result) + turn_ids = [turn.turn for turn in result.turns] + if len(set(turn_ids)) != len(turn_ids): + raise ValueError("duplicate turn identities in training contract") + selected = {entry["metadata"]["turn"]: entry for entry in entries} + turns = [] + for turn in result.turns: + entry = selected.get(turn.turn) + mask = ( + entry["loss_mask"] + if entry + else [0] * (len(turn.prompt_token_ids) + len(turn.completion_token_ids)) + ) + turns.append( + { + "turn": turn.turn, + "node_id": turn.node_id, + "prompt_token_ids": list(turn.prompt_token_ids), + "completion_token_ids": list(turn.completion_token_ids), + "per_token_logps": list(turn.per_token_logps), + "loss_mask": list(mask), + "finish_reason": turn.finish_reason, + "discarded": turn.discarded, + "trainable": bool(entry) and any(mask), + "sampling_params": dict(turn.sampling_params), + "requested_sampling_params": dict(turn.requested_sampling_params), + } + ) + return { + "schema_version": 1, + **{ + key: getattr(result, key) + for key in ( + "task_id", + "task_name", + "dataset", + "harness", + "sandbox", + "trial_name", + "session_id", + "reward", + "rewards", + "reward_key", + "rollout_type", + "capture_level", + ) + }, + "n_trainable_tokens": sum(sum(entry["loss_mask"]) for entry in entries), + "trace_entries": entries, + "turns": turns, + } diff --git a/src/openenv/harbor/e2b_stream.py b/src/openenv/harbor/e2b_stream.py new file mode 100644 index 0000000000..8991b5d149 --- /dev/null +++ b/src/openenv/harbor/e2b_stream.py @@ -0,0 +1,64 @@ +"""E2B uploads using the SDK's streaming gzip transport and a total deadline.""" + +from __future__ import annotations + +import asyncio +from contextlib import ExitStack +from pathlib import Path, PurePosixPath + +from harbor.environments.e2b import E2BEnvironment +from tenacity import retry, stop_after_attempt, wait_exponential + + +class E2BStreamingEnvironment(E2BEnvironment): + """Preserve file contents while bounding large artifact upload memory and time. + + Selected explicitly through Harbor's native environment import path. Retrying + a file upload overwrites the same target with the same source; commands and + model requests retain their existing policies. The SDK decompresses in envd. + """ + + @retry( + stop=stop_after_attempt(2), wait=wait_exponential(min=1, max=10), reraise=True + ) + async def upload_file(self, source_path: Path | str, target_path: str): + if self._sandbox is None: + raise RuntimeError("Sandbox not found. Please start the environment first.") + with Path(source_path).open("rb") as stream: + await asyncio.wait_for( + self._sandbox.files.write( + target_path, + stream, + gzip=True, + use_octet_stream=True, + request_timeout=30, + ), + timeout=120, + ) + + @retry( + stop=stop_after_attempt(2), wait=wait_exponential(min=1, max=10), reraise=True + ) + async def upload_dir(self, source_dir: Path | str, target_dir: str): + if self._sandbox is None: + raise RuntimeError("Sandbox not found. Please start the environment first.") + source = Path(source_dir) + paths = sorted(p for p in source.rglob("*") if p.is_file()) + for start in range(0, len(paths), self._UPLOAD_BATCH_SIZE): + with ExitStack() as stack: + entries = [ + { + "path": str( + PurePosixPath(target_dir) + / path.relative_to(source).as_posix() + ), + "data": stack.enter_context(path.open("rb")), + } + for path in paths[start : start + self._UPLOAD_BATCH_SIZE] + ] + await asyncio.wait_for( + self._sandbox.files.write_files( + entries, gzip=True, use_octet_stream=True, request_timeout=30 + ), + timeout=120, + ) diff --git a/src/openenv/harbor/environment.py b/src/openenv/harbor/environment.py new file mode 100644 index 0000000000..50e6963c53 --- /dev/null +++ b/src/openenv/harbor/environment.py @@ -0,0 +1,338 @@ +"""The OpenEnv environment: Task API for discovery, one MCP tool for execution. + +`run_rollout` is a single long tool call rather than `reset`/`step`, because OpenEnv's HTTP handlers +construct and close an environment per request while a Harbor rollout is one stateful 60-600s run +that the harness drives. There is no meaningful `step()` to expose while opencode drives itself. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from openenv.core.env_server.mcp_environment import MCPEnvironment +from openenv.core.env_server.types import Observation + +from .models import HarborState + +# A rollout is minutes, not seconds. OpenEnv's MCP tools default to 30s and there is no config knob, +# so the only way to raise it is to shadow `step`/`step_async` and inject a default. +_ROLLOUT_TIMEOUT_S = 1800.0 + + +def _trials_dir() -> Path: + """Where per-rollout artifacts are written. + + Overridable because the default lives on `/tmp`, which is node-local, shared between users, and + cleared: one sweep of 12 harnesses x 15 tasks left 13,187 trial directories totalling 35 GB + there, and a 16,000-rollout run projects to ~42 GB. Traces are the durable product of an eval -- + the reward is one float, the trace is the evidence -- so they belong on a filesystem the user + chose. + + Set `OPENENV_HARBOR_TRIALS_DIR` to relocate. The default is unchanged so existing deployments + behave exactly as before. + """ + return Path( + os.environ.get("OPENENV_HARBOR_TRIALS_DIR") or "/tmp/openenv-harbor-trials" + ) + + +class HarborEnvironment(MCPEnvironment): + """Per-session environment exposing `run_rollout` and `capabilities` over MCP.""" + + SUPPORTS_CONCURRENT_SESSIONS = True + + # Server-wide config, set once by `serving.build_app`. Class-level because OpenEnv builds a + # throwaway instance per `/metadata` and `/schema` request, and `__init__` must stay cheap and + # credential-free or the docs page cannot load. + _datasets: list[str] = [] + _llm_url: str = "" + _model: str = "" + # The validated LLM report from startup. Rebuilding it per request would mean re-probing the + # endpoint on every `capabilities()` call, so the startup verdict (including `ok`) is carried. + _llm: dict[str, Any] = {} + + @classmethod + def configure( + cls, + *, + datasets: list[str], + llm_url: str = "", + model: str = "", + llm: dict[str, Any] | None = None, + ) -> None: + cls._datasets = list(datasets) + cls._llm_url = llm_url + cls._model = model + cls._llm = dict(llm or {}) + + def __init__(self) -> None: + from fastmcp import FastMCP + + from .capabilities import capabilities as _capabilities + from .tasks import HarborTaskProvider + + self._provider = HarborTaskProvider(self._datasets) + self._state = HarborState(episode_id=str(uuid4()), llm_url=self._llm_url) + + mcp = FastMCP("harbor_env") + + @mcp.tool + async def run_rollout( + split: str = "", + task_index: int = 0, + harness: str = "opencode", + sandbox: str = "e2b", + reward_key: str = "", + keep_sandbox: bool = False, + force_build: bool = False, + llm_url: str = "", + model: str = "", + api_key: str = "", + auth_header: str = "", + agent_timeout_sec: float = 0.0, + agent_step_limit: int = 0, + sampling: dict[str, Any] | None = None, + provider: str = "openai", + purpose: str = "auto", + eval_sampling: dict[str, Any] | None = None, + ) -> str: + """Run one Harbor rollout and return a JSON `HarborRolloutResult`. + + `harness`, `sandbox` AND the engine are all per-call, so consecutive rollouts can use + different agents, different backends and different engines against the same server. + + `agent_timeout_sec` overrides the agent's execution timeout. `agent_step_limit` caps + captured model calls, including auxiliary calls, and sets a native step limit where + supported. Zero leaves calls uncapped. `sampling={"temperature": ...}` pins an explicit + full-vocabulary training policy; use the same temperature as the trainer. + + Naming `llm_url` probes that engine (once per engine, then cached) and decides this + rollout's tier from what it can actually return: token ids and processed logprobs mean + `train`, anything less means `eval`. That is why a trainer and an eval run can share one + server — the dataset tree and the sandbox templates are the expensive things to host, and + the engine is the cheap, changing part. + """ + return await self._run_rollout( + split, + task_index, + harness, + sandbox, + reward_key, + keep_sandbox, + force_build, + llm_url, + model, + api_key, + auth_header, + agent_timeout_sec, + agent_step_limit, + sampling, + provider, + purpose, + eval_sampling, + ) + + @mcp.tool + def capabilities() -> str: + """Harnesses, sandboxes, datasets and LLM status for this server.""" + caps = _capabilities( + datasets=self._datasets, + llm=self._llm or {"url": self._llm_url, "model": self._model}, + ) + return json.dumps(caps.to_dict()) + + @mcp.tool + def list_tasks(split: str = "", start: int = 0, stop: int = 20) -> str: + """A window of tasks in a split, for browsing without pulling all of them.""" + return json.dumps(self._provider.get_task_range(split, start, stop)) + + super().__init__(mcp) + + # --- Task API (OpenEnv discovers these by duck typing) ---------------- + def list_splits(self) -> list[dict[str, Any]]: + return self._provider.list_splits() + + def num_tasks(self, split: str) -> int: + return self._provider.num_tasks(split) + + def list_tasks(self, split: str) -> list[dict[str, Any]]: + return self._provider.list_tasks(split) + + def get_task(self, split: str, index: int) -> dict[str, Any]: + return self._provider.get_task(split, index) + + def get_task_range( + self, split: str, start: int | None = None, stop: int | None = None + ) -> list[dict[str, Any]]: + return self._provider.get_task_range(split, start, stop) + + # --- Environment ------------------------------------------------------ + def reset( + self, seed: int | None = None, episode_id: str | None = None, **_: Any + ) -> Observation: + """New episode. Boots nothing — a sandbox is created per `run_rollout`, not per reset.""" + service = self._service() + self._state = HarborState( + episode_id=episode_id or str(uuid4()), + llm_url=self._llm_url, + intercept_url=getattr(service, "public_url", "") if service else "", + ) + return Observation( + done=False, + reward=None, + metadata={ + "status": "ready", + "message": "Call run_rollout(split=..., task_index=..., harness=..., sandbox=...)", + "datasets": self._datasets, + }, + ) + + def _step_impl( + self, action: Any, timeout_s: float | None = None, **_: Any + ) -> Observation: + return Observation( + done=False, + reward=None, + metadata={ + "error": f"Unknown action {type(action).__name__}; " + "use CallToolAction(name='run_rollout', ...)" + }, + ) + + def step( + self, action: Any, timeout_s: float | None = None, **kwargs: Any + ) -> Observation: + return super().step(action, timeout_s=timeout_s or _ROLLOUT_TIMEOUT_S, **kwargs) + + async def step_async( + self, action: Any, timeout_s: float | None = None, **kwargs: Any + ) -> Observation: + return await super().step_async( + action, timeout_s=timeout_s or _ROLLOUT_TIMEOUT_S, **kwargs + ) + + @property + def state(self) -> HarborState: + return self._state + + # --- internals -------------------------------------------------------- + @staticmethod + def _service() -> Any: + from .serving import HarborService + + return HarborService.current() + + async def _run_rollout( + self, + split: str, + task_index: int, + harness: str, + sandbox: str, + reward_key: str, + keep_sandbox: bool, + force_build: bool, + llm_url: str = "", + model: str = "", + api_key: str = "", + auth_header: str = "", + agent_timeout_sec: float = 0.0, + agent_step_limit: int = 0, + sampling: dict[str, Any] | None = None, + provider: str = "openai", + purpose: str = "auto", + eval_sampling: dict[str, Any] | None = None, + ) -> str: + from .models import HarborRolloutResult + from .rollout import run_rollout as _run + + service = self._service() + if service is None: + return HarborRolloutResult( + ok=False, + error="server not initialised: no capture proxy is running", + harness=harness, + sandbox=sandbox, + ).model_dump_json() + + split = split or (self._datasets[0] if self._datasets else "") + try: + task_dir = self._provider.task_dir(split, int(task_index)) + except Exception as exc: # noqa: BLE001 + return HarborRolloutResult( + ok=False, error=str(exc)[:400], harness=harness, sandbox=sandbox + ).model_dump_json() + + # This tool is `async` on purpose. FastMCP dispatches a SYNC tool body through + # `anyio.to_thread.run_sync` with no limiter (`fastmcp/utilities/async_utils.py`), which + # uses anyio's default `CapacityLimiter(40)` — and the body holds that worker thread for the + # whole rollout. That capped the server at 40 concurrent rollouts no matter what + # `MAX_CONCURRENT_ENVS` said, and invisibly: `rollout.py` starts its clock inside the body, + # after admission, so queue time never appeared in `wall_s`. Measured 40 concurrent for a + # sync tool vs 400 for an async one at the same request count. + # + # Staying on the loop also keeps Harbor's teardown correct: `Trial._finalize` SHIELDS + # `agent_environment.stop(...)`, and the old `asyncio.run`-per-rollout closed the loop as + # soon as the coroutine returned, cancelling that shielded task and leaking the sandbox. + async def _resolve_and_run(): + """Settle which engine serves this rollout, then run it. + + The probe is async and cached per engine, so it has to happen inside the coroutine rather + than at call time. Without a named engine this falls back to whatever the server booted + with, which is what every existing caller gets. + """ + from openenv.core.harness.capture.sessions import Upstream + + pool = service.capture.app.state.upstreams + if llm_url: + upstream = Upstream( + llm_url=llm_url, + model=model, + api_key=api_key or None, + auth_header=auth_header or "Authorization", + provider=provider, + ) + client, level = await pool.resolve(upstream) + served = client.served_model or model + else: + upstream = None + client, level = pool.default + # Read from the live service rather than class config: it is the same value the proxy + # was built with, so the result cannot claim a level the proxy is not running at. + level = getattr(service, "capture_level", "tokens") + served = service.model + return await _run( + task_dir=task_dir, + harness=harness, + sandbox=sandbox, + registry=service.capture.registry, + intercept_url=service.public_url, + model=served, + trials_dir=_trials_dir(), + dataset=split, + reward_key=reward_key, + keep_sandbox=keep_sandbox, + force_build=force_build, + capture_level=level, + purpose=purpose, + eval_sampling=eval_sampling, + upstream=upstream, + inference=client, + # 0 defers to the task file. This bounds agent execution only; + # Harbor's environment build/healthcheck timeouts govern setup. + # It is not a wall-clock deadline for the whole RPC. + agent_timeout_sec=agent_timeout_sec or None, + agent_step_limit=agent_step_limit or None, + sampling=sampling, + ) + + result = await _resolve_and_run() + + self._state.rollouts_completed += 1 + self._state.last_reward = result.reward + self._state.last_task_id = result.task_id + self._state.last_trial_name = result.trial_name + return result.model_dump_json() diff --git a/src/openenv/harbor/install_fixes.py b/src/openenv/harbor/install_fixes.py new file mode 100644 index 0000000000..54b16e5ed2 --- /dev/null +++ b/src/openenv/harbor/install_fixes.py @@ -0,0 +1,698 @@ +"""Local subclasses that fix agent INSTALL failures, registered via `AgentConfig.import_path`. + +Same escape hatch as `pi_agent.py`, different reason. These agents' seams are fine; they simply never +get far enough to use them, because installing the CLI into the DataAgent sandbox fails. Both are +upstream bugs in Harbor's wrappers rather than anything about the intercept, and both are fixed here +without touching Harbor. + +Each override is deliberately minimal: call Harbor's own `install()` and change only the one thing +that is wrong, so we inherit every future upstream fix instead of forking the install logic. +""" + +from __future__ import annotations + +import importlib +import json +import logging +import shlex +from pathlib import Path +from typing import Any + + +def _harbor(module: str, name: str) -> Any: + """Import one Harbor internal, or return a stub that fails only when that agent is used. + + Every subclass below is fitted to a specific upstream wrapper, so this module unavoidably reaches + into Harbor's internals. What is avoidable is the blast radius: as plain module-top imports, one + upstream rename raised ImportError for the whole module and took out every seam routed through + `import_path` — eight agents at once, none of them related to the rename. + + A failed import now yields a placeholder that is still subclassable, so the module imports and the + other seven agents keep working. Instantiating the affected one raises, naming what moved. + """ + try: + return getattr(importlib.import_module(module), name) + except Exception as exc: # noqa: BLE001 - degrade one agent, not the whole module + reason = f"{module}.{name} is not available in this Harbor build ({exc})" + logging.getLogger(__name__).warning( + "%s; the seam that depends on it will fail if used", reason + ) + + class _MissingHarborBase: + _reason = reason + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + raise RuntimeError( + f"this harness cannot run: {type(self)._reason}. Harbor's wrapper moved or was " + "renamed; update the subclass in openenv/harbor/install_fixes.py." + ) + + return _MissingHarborBase + + +ClineCli = _harbor("harbor.agents.installed.cline.cline", "ClineCli") +ExecInput = _harbor("harbor.agents.installed.cline.cline", "ExecInput") +GeminiCli = _harbor("harbor.agents.installed.gemini_cli", "GeminiCli") +KimiCli = _harbor("harbor.agents.installed.kimi_cli", "KimiCli") +OpenClaw = _harbor("harbor.agents.installed.openclaw", "OpenClaw") +OpenHands = _harbor("harbor.agents.installed.openhands", "OpenHands") +Pi = _harbor("harbor.agents.installed.pi", "Pi") +SweAgent = _harbor("harbor.agents.installed.swe_agent", "SweAgent") +BaseEnvironment = _harbor("harbor.environments.base", "BaseEnvironment") + + +class InterceptGeminiCli(GeminiCli): + """gemini-cli, with `bash` guaranteed before nvm runs. + + `nvm_node_install_snippet()` pipes the installer into **bash**: + + curl -o- .../install.sh | env -u NODE_VERSION bash + + In an image without bash that pipe fails, nvm never lands, and the snippet's own guard reports: + + Error: NVM failed to load + + which reads like an nvm problem rather than a missing shell. Harbor 0.20.0's own + `GeminiCli.install` apt-installs `curl` and nothing else (gemini_cli.py:110-115), so `bash` is + still the gap this closes. + + Installed directly with `exec_as_root` rather than through a dependency helper: the + `ensure_system_dependencies(environment, (...))` this used to call does not exist in Harbor + 0.20.0, and calling it failed the install outright with + + 'InterceptGeminiCli' object has no attribute 'ensure_system_dependencies' + + — which surfaced as `Agent install failed` and zero model calls, i.e. a harness that could not run + at all. Mirrors Harbor's own apt invocation in the same file so the two cannot drift again. + """ + + async def install(self, environment: BaseEnvironment) -> None: + await self.exec_as_root( + environment, + command="apt-get update && apt-get install -y curl bash", + env={"DEBIAN_FRONTEND": "noninteractive"}, + ) + await super().install(environment) + + +class InterceptOpenHands(OpenHands): + """OpenHands pinned to the last V0 release, which still has `openhands.core.main`. + + Harbor's wrapper installs `openhands-ai` unpinned and then verifies with: + + /opt/openhands-venv/bin/python -m openhands.core.main --version + + OpenHands V1 restructured the package: the agent core moved out into `openhands-sdk` / + `openhands-agent-server`, so `openhands.core` no longer exists and install fails with + + ModuleNotFoundError: No module named 'openhands.core' + + V0 is scheduled for removal on 2026-04-01, so this pin is a stopgap: once Harbor's wrapper is + updated for the V1 entry point, drop the pin and this subclass. 0.49.0 is the newest 0.x on PyPI. + """ + + DEFAULT_V0_VERSION = "0.49.0" + # Harbor defaults to `uv python install 3.13`, but 0.49.0 shipped in July 2025 and does not + # resolve there, so the version pin alone is not enough. + DEFAULT_PYTHON = "3.12" + + # Transitive dependencies openhands-ai 0.49.0 imports but does not declare. Installing 0.49.0 + # succeeds, and then the import check dies: + # File ".../openhands/events/event_store_abc.py", line 5, in + # from deprecated import deprecated + # ModuleNotFoundError: No module named 'deprecated' + # Pinning an old release means living with whatever its metadata got wrong at the time. + MISSING_DEPS = ("Deprecated",) + VENV = "/opt/openhands-venv" + + def __init__(self, *args: Any, **kwargs: Any): + kwargs.setdefault("version", self.DEFAULT_V0_VERSION) + kwargs.setdefault("python_version", self.DEFAULT_PYTHON) + super().__init__(*args, **kwargs) + + async def install(self, environment: BaseEnvironment) -> None: + """Install via Harbor, and repair the missing deps if its verify step trips over them. + + Harbor's install ends with `python -m openhands.core.main --version`, so an undeclared + dependency surfaces as a failed install rather than a failed run. We cannot pre-empt it + without forking Harbor's install command, so instead: let it run, and if it fails, add the + known-missing packages to the venv it already built and re-run the same verification. If that + passes, the install is genuinely fine. + """ + try: + await super().install(environment) + # Runtime startup needs the shim even when upstream dependencies installed cleanly. + await self._install_poetry_shim(environment) + return + except Exception as exc: # noqa: BLE001 - remediate, then re-verify honestly + self.logger.warning( + "openhands install failed (%s); attempting dependency repair", + str(exc)[:160], + ) + + packages = " ".join(self.MISSING_DEPS) + await self.exec_as_agent( + environment, + command=( + f"set -euo pipefail; {self.VENV}/bin/python -m ensurepip --upgrade || true; " + f"{self.VENV}/bin/python -m pip install {packages}" + ), + ) + await self._install_poetry_shim(environment) + # Same check Harbor uses. If this still fails it raises, and the failure is real. + await self.exec_as_agent( + environment, + command=f"{self.VENV}/bin/python -m openhands.core.main --version", + ) + + async def _install_poetry_shim(self, environment: BaseEnvironment) -> None: + """Make `poetry run python ...` work in a venv that poetry never created. + + OpenHands' LocalRuntime starts its action-execution server with: + + ['poetry', 'run', 'python', '-u', '-m', 'openhands.runtime.action_execution_server', ...] + + which assumes a poetry-managed source checkout. Installed from PyPI into a uv venv there is no + pyproject.toml anywhere above site-packages, so poetry refuses: + + server: Poetry could not find a pyproject.toml file in + /opt/openhands-venv/lib/python3.12/site-packages or its parents + server process exited + + The agent then waits for a server that will never come up, and tenacity converts that into + `RetryError[]` with the actual cause nowhere in the traceback. + + Rather than fabricate a pyproject.toml (which makes poetry resolve and possibly reinstall a + dependency tree), shim the one invocation OpenHands makes: drop the `run` verb and exec the + venv's own interpreter. Everything the server needs is already installed there. + """ + shim = ( + "#!/bin/sh\n" + '[ "$1" = "run" ] && shift\n' + f'[ "$1" = "python" ] && {{ shift; exec {self.VENV}/bin/python "$@"; }}\n' + f'exec {self.VENV}/bin/"$@"\n' + ) + # Installed as ROOT into /usr/local/bin, and over any existing poetry. + # + # A first attempt wrote it to ~/.local/bin and changed nothing: the error was + # "Poetry could not find a pyproject.toml", not "poetry: command not found", so a real poetry + # is already on PATH ahead of ~/.local/bin. Shadowing it is the only way the shim is reached. + # /usr/local/bin precedes ~/.local/bin on every image we run. + # The launch command activates the venv, putting its own Poetry first. + for directory in (f"{self.VENV}/bin", "/usr/local/bin", "/usr/bin"): + await self.exec_as_root( + environment, + command=( + f"mkdir -p {directory} && cat > {directory}/poetry <<'SHIM'\n{shim}SHIM\n" + f"chmod 0755 {directory}/poetry" + ), + ) + + +class InterceptSweAgent(SweAgent): + """swe-agent, given a git repo to work in so Harbor's working code path is taken. + + Harbor builds the repo argument as: + + "$(if [ -d /testbed ]; then echo '--env.repo.type=preexisting --env.repo.repo_name=/testbed'; " + "else echo '--env.repo.path=$(pwd)'; fi)" + + The else-branch is broken: `$(pwd)` sits inside SINGLE quotes, so it is never expanded and the + literal string is passed through. swe-agent then resolves it relative to its cwd and dies: + + git.exc.NoSuchPathError: /workdir/$(pwd) + + Underneath that is a second problem: swe-agent is a SWE-bench agent and requires a git repository, + while DataAgent tasks are a CSV and a question. + + Both are solved by satisfying the `[ -d /testbed ]` test that Harbor already checks. We `git init` + the task's own /workdir and expose it as /testbed, so Harbor takes its preexisting-repo branch + (which has no quoting bug) and the agent still works where the task data and /workdir/answer.txt + live. Nothing in Harbor changes. + """ + + async def setup(self, environment: BaseEnvironment) -> None: + await super().setup(environment) + await self.exec_as_root( + environment, + command=( + "set -eu; mkdir -p /workdir; cd /workdir; " + # A repo with no commit still fails some checks, so make one. + "git rev-parse --git-dir >/dev/null 2>&1 || { " + " git init -q .; " + " git config user.email harbor@example.com; git config user.name harbor; " + " touch .harbor-keep; git add -A; git commit -qm 'harbor: initial' || true; }; " + "[ -e /testbed ] || ln -s /workdir /testbed" + ), + ) + + +# Runs INSIDE the sandbox. Truncates `openclaw.txt` after the last line that is exactly `}`, i.e. +# the closing brace of openclaw's pretty-printed `--json` envelope. Deliberately not a JSON parser: +# re-implementing Harbor's scan here is exactly what we are trying to avoid, so this only removes +# the trailing lines and then lets Harbor's own parser do the parsing. +_OPENCLAW_TRIM_TRAILING_LOG = """ +from pathlib import Path + +p = Path("/logs/agent/openclaw.txt") +if p.is_file(): + lines = p.read_text(encoding="utf-8", errors="replace").rstrip().splitlines() + for i in range(len(lines) - 1, -1, -1): + if lines[i] == "}": + if i < len(lines) - 1: + p.write_text("\\n".join(lines[: i + 1]) + "\\n", encoding="utf-8") + break +""" + + +def _export_openclaw_sqlite_transcript(log_dir="/logs/agent"): + """Run inside the sandbox; export native SQLite records using OpenClaw's CLI. + + OpenClaw 2026.9.4 returns a session key in agentMeta.sessionFile. Harbor + 0.20.0 treats that value as a filesystem path and loses per-call metrics. + Keep legacy JSONL captures, and use the official export for session keys. + """ + import json + import subprocess + from pathlib import Path + + root = Path(log_dir) + target = root / "openclaw.session.jsonl" + if target.is_file(): + return + text = (root / "openclaw.txt").read_text().strip() + decoder = json.JSONDecoder() + envelope = None + for start in range(len(text) - 1, -1, -1): + if text[start] != "{": + continue + try: + candidate, length = decoder.raw_decode(text[start:]) + except ValueError: + continue + if isinstance(candidate, dict) and not text[start + length :].strip(): + envelope = candidate + break + if not envelope: + return + meta = envelope.get("meta", {}).get("agentMeta", {}) + key = meta.get("sessionFile") + if not isinstance(key, str) or not key.startswith("agent:"): + return + result = subprocess.run( + [ + "openclaw", + "sessions", + "export-trajectory", + "--session-key", + key, + "--workspace", + str(root), + "--json", + ], + check=True, + capture_output=True, + text=True, + timeout=60, + ) + summary = json.loads(result.stdout) + if summary.get("sessionId") != meta.get("sessionId"): + raise ValueError("OpenClaw exported a different session") + branch = json.loads( + (Path(summary["outputDir"]) / "session-branch.json").read_text() + ) + entries = branch.get("entries") + if ( + not isinstance(entries, list) + or not entries + or not all(isinstance(e, dict) for e in entries) + ): + raise ValueError("OpenClaw export has no native transcript entries") + # Preserve native messages, tool IDs and usage; never split aggregate usage. + temporary = target.with_suffix(".tmp") + temporary.write_text("".join(json.dumps(entry) + "\n" for entry in entries)) + temporary.replace(target) + + +class InterceptOpenClaw(OpenClaw): + """openclaw, with its config actually present inside the sandbox. + + Harbor writes the merged config to the HOST trial dir and then copies it from a CONTAINER path: + + upload_path = self.logs_dir / "openclaw.upload.json" # host + "mkdir -p ~/.openclaw && cp /logs/agent/openclaw.upload.json ~/.openclaw/openclaw.json" + + with the comment "trial mounts logs here as /logs/agent". That holds for a bind-mounted docker + runtime. **E2B has no bind mounts**, so the file exists on the host and nowhere in the sandbox: + + cp: cannot stat '/logs/agent/openclaw.upload.json' + + Supplying `openclaw_config` does not help, because the problem is not that the config is empty. + + Fix: build the same config Harbor would and upload it to the container path during setup, so the + copy in `run()` finds it. Harbor's own `_build_full_openclaw_config` is reused, so the content + stays whatever Harbor intended, merges included. + """ + + DEFAULT_VERSION = "2026.9.4" + NODE_VERSION = "24.16.0" + + def __init__(self, *args: Any, **kwargs: Any): + kwargs.setdefault("version", self.DEFAULT_VERSION) + super().__init__(*args, **kwargs) + + async def exec_as_agent( + self, environment, command, env=None, cwd=None, timeout_sec=None + ): + # Harbor hardcodes Node 22; the observed package requires Node >=24.16. + import re + + command = re.sub( + r"\bnvm (install|use) 22\b", + lambda match: f"nvm {match[1]} {self.NODE_VERSION}", + command, + ) + return await super().exec_as_agent( + environment, command=command, env=env, cwd=cwd, timeout_sec=timeout_sec + ) + + async def setup(self, environment: BaseEnvironment) -> None: + await super().setup(environment) + + payload = json.dumps(self._build_full_openclaw_config(), indent=2) + "\n" + local = Path(self.logs_dir) / self._UPLOAD_CONFIG_FILENAME + local.parent.mkdir(parents=True, exist_ok=True) + local.write_text(payload, encoding="utf-8") + + target = f"{self._CONTAINER_LOGS_AGENT}/{self._UPLOAD_CONFIG_FILENAME}" + await self.exec_as_root( + environment, + command=f"mkdir -p {self._CONTAINER_LOGS_AGENT} && " + f"chmod 777 {self._CONTAINER_LOGS_AGENT}", + ) + await environment.upload_file(local, target) + + async def _copy_openclaw_session_file_to_agent_logs( + self, environment: BaseEnvironment, env: dict[str, str] + ) -> None: + """Strip openclaw's trailing stderr line so Harbor can parse its own capture file. + + UPSTREAM HARBOR BUG. Delete this override once Harbor's parser tolerates trailing text. + + Harbor runs openclaw as (openclaw.py:947-953): + + openclaw agent --local --json ... 2>&1 &1` merges its STDERR into the same file. After + the envelope is flushed, openclaw logs one info-level line to stderr: + + [agents/agent-command] [agent] run ended with stopReason=stop + + Harbor then parses that file with a rule requiring the JSON object to consume the entire + remaining suffix (`_openclaw_decode_last_json_dict_suffix`, and the identical loop inside + `_openclaw_container_copy_session_transcript`). One trailing line defeats both, so the + container-side copy hits `sys.exit(0)` and `populate_context_post_run` returns at + `if not envelope: return` -- no `openclaw.session.jsonl` and no `trajectory.json` at all, + while the session file sits on disk the whole time. Verified by stream: the line appears in + stderr and never in stdout. + + Probably unnoticed upstream because the line is suppressed for `stopReason == "end_turn"` + (dist/agent-command:454). Anthropic reports `end_turn`; every OpenAI-compatible provider + reports `stop`, so for us it is always printed. + + The fix is deliberately NOT a local re-implementation of Harbor's parser. Removing the + trailing lines leaves Harbor's own scan -- container-side and host-side -- to run unmodified, + so any upstream improvement to it is still inherited. + """ + try: + await self.exec_as_agent( + environment, + command="python3 -c " + shlex.quote(_OPENCLAW_TRIM_TRAILING_LOG), + env=env, + ) + except Exception as exc: # noqa: BLE001 - a missing trace must not fail a good rollout + # Loud, unlike Harbor's silent `sys.exit(0)`: losing the trajectory is the whole bug. + self.logger.warning( + "could not trim openclaw.txt (%s); ATIF trajectory will likely be missing", + str(exc)[:160], + ) + await super()._copy_openclaw_session_file_to_agent_logs(environment, env) + import inspect + + script = inspect.getsource(_export_openclaw_sqlite_transcript) + script += "\n_export_openclaw_sqlite_transcript()\n" + try: + await self.exec_as_agent( + environment, + command=". ~/.nvm/nvm.sh && nvm use 22 && python3 -c " + + shlex.quote(script), + env=env, + timeout_sec=90, + ) + except Exception as exc: # noqa: BLE001 - capture validation rejects missing per-call evidence + self.logger.warning( + "OpenClaw native transcript export failed: %s", str(exc)[:160] + ) + + +class InterceptCline(ClineCli): + """cline-cli, given a base URL through the only channel it has: its settings store. + + Harbor forwards exactly `{PROVIDER, API_KEY, MODELID}` and runs + + cline -P -k $API_KEY -m $MODELID --json --yolo + + There is no base-URL flag and no base-URL env var, so cline resolves the provider's REAL endpoint + and dies with the session id as a bearer token: + + Incorrect API key provided: s55f2f5a… You can find your API key at + https://platform.openai.com/account/api-keys + + Cline's OpenAI-Compatible provider takes Base URL + key + model id from its settings store + (`~/.cline/data/globalState.json`), not from the CLI. Harbor writes that file itself at the start + of `create_run_agent_commands`, so anything written earlier is overwritten. Instead we let + Harbor's command run and INSERT a merge step between it and the agent invocation, which keeps + Harbor's own keys (`welcomeViewCompleted`, `isNewUser`) intact. + """ + + def __init__( + self, *args: Any, intercept_config: dict[str, str] | None = None, **kwargs: Any + ): + self._intercept_config = intercept_config or {} + # Harbor uses a separate cline_version knob; honor the shared exact-version pin. + if kwargs.get("version") and not kwargs.get("cline_version"): + kwargs["cline_version"] = kwargs["version"] + super().__init__(*args, **kwargs) + + def create_run_agent_commands(self, instruction: str): + commands = list(super().create_run_agent_commands(instruction)) + base_url = self._intercept_config.get("base_url") + api_key = self._intercept_config.get("api_key") + model = self._intercept_config.get("model") + if not (base_url and api_key and model) or not commands: + return commands + + # Merge rather than replace: Harbor's globalState keys must survive. + settings = { + "openAiBaseUrl": f"{base_url}/v1", + "openAiApiKey": api_key, + "openAiModelId": model, + "apiProvider": "openai", + } + merge = ( + "python3 - <<'__HARBOR_CLINE_SETTINGS__'\n" + "import json, pathlib\n" + "p = pathlib.Path.home() / '.cline' / 'data' / 'globalState.json'\n" + "p.parent.mkdir(parents=True, exist_ok=True)\n" + "try:\n" + " cfg = json.loads(p.read_text())\n" + "except Exception:\n" + " cfg = {}\n" + f"cfg.update({json.dumps(settings)})\n" + "p.write_text(json.dumps(cfg))\n" + "__HARBOR_CLINE_SETTINGS__" + ) + # Current Cline stores provider configuration separately from the legacy globalState. + # Use its supported noninteractive auth configuration command; credentials stay in env. + auth = ExecInput( + command='export NVM_DIR="$HOME/.nvm"; ' + 'if [ -s "$NVM_DIR/nvm.sh" ]; then . "$NVM_DIR/nvm.sh"; nvm use 22 >/dev/null 2>&1 || true; fi; ' + 'cline auth --provider openai-compatible --apikey "$API_KEY" ' + '--modelid "$MODELID" --baseurl "$OPENENV_CLINE_BASE_URL"', + env={ + "API_KEY": api_key, + "MODELID": model, + "OPENENV_CLINE_BASE_URL": f"{base_url}/v1", + }, + ) + commands.insert(1, ExecInput(command=merge)) + commands.insert(2, auth) + return commands + + +def _isolated_process_group(command: str) -> str: + import shlex + + launcher = ( + "import subprocess,sys; " + "result=subprocess.run(['bash','-c',sys.argv[1]],start_new_session=True); " + "sys.exit(128-result.returncode if result.returncode < 0 else result.returncode)" + ) + return f"python3 -c {shlex.quote(launcher)} {shlex.quote(command)}" + + +class InterceptKimi(KimiCli): + """kimi-cli, surviving the stream reset its own teardown causes. + + Harbor runs kimi as (kimi_cli.py:379-394): + + (echo $PROMPT; sleep 86400) | kimi --wire --yolo --afk ... | ( + while IFS= read -r line; do ... case "$line" in *'"id":"1"'*) break ;; esac; done + ...; kill 0) + + `sleep 86400` holds stdin open for a day, and `kill 0` tears down the whole process group once + the terminating wire event arrives. Harbor already expects part of the fallout and swallows + `NonZeroAgentExitCodeError` for "exit 143" (SIGTERM). What it does not expect is that killing the + group also kills the E2B exec stream mid-flight, so the HTTP/2 connection on the HOST side dies: + + httpcore.RemoteProtocolError: + (raised in .venv312/site-packages/httpcore/_async/http2.py, i.e. OUR process, not the sandbox) + + That propagates out of `run`, so Harbor abandons the trial and never runs the verifier. Every one + of 11 kimi trials died this way, each AFTER completing real work (one had 37 captured turns), and + no other harness has ever produced this error on the same E2B backend, which is what identifies + it as kimi's teardown rather than transport flakiness. + + By the time it fires, kimi has already written its wire output to /logs/agent/, so the trajectory + and answer are on disk and the trial can be graded normally. Swallowing it here is the same + judgement Harbor already made for exit 143, applied to the other half of the same teardown. + + The same teardown has more than one spelling, which is what `_TEARDOWN_ERRORS` is for. Against + Harbor 0.20.0 every kimi rollout instead raised + + httpx.ConnectError: Error reading content + + so the original `RemoteProtocolError`-only guard no longer matched and all 15 cells of a + compatibility matrix failed — each one AFTER capturing real work (up to 12 turns and 1320 + trainable tokens, `atif=match` throughout). One transport layer's way of saying "the stream you + were reading went away" is not stable across versions, so the guard lists the ways rather than + assuming one. + + Still deliberately narrow, and the safety net is downstream rather than here: if the sandbox had + genuinely been unreachable, the agent would have made no model calls, and `check_rollout`'s + `no_turns` FATAL fails the rollout anyway. So swallowing a transport error cannot promote a + never-ran rollout to a graded one. Anything outside this list still raises. + """ + + # (exception class name, substring that identifies it as the exec stream dying) + _TEARDOWN_ERRORS = ( + ("RemoteProtocolError", "StreamReset"), + ("ConnectError", "Error reading content"), + ) + + async def exec_as_agent( + self, environment, command, env=None, cwd=None, timeout_sec=None + ): + # The wire terminal event triggers `kill 0`. Give that process tree its own session, + # preserving the sandbox exec transport and Harbor's expected exit-143 handling. + if "kimi --" in command and "--wire" in command and "kill 0" in command: + command = _isolated_process_group(command) + return await super().exec_as_agent( + environment, command=command, env=env, cwd=cwd, timeout_sec=timeout_sec + ) + + async def run(self, instruction, environment, context) -> None: # type: ignore[override] + try: + await super().run(instruction, environment, context) + except Exception as exc: # noqa: BLE001 - re-raised below unless it is the known teardown + name, text = type(exc).__name__, str(exc) + if not any( + name == cls and marker in text for cls, marker in self._TEARDOWN_ERRORS + ): + raise + # Expected: `kill 0` took the exec stream down with the process group. + + +# ------------------------------------------------------------------------------------------------ +# pi +# ------------------------------------------------------------------------------------------------ +# pi, taught to talk to our intercept. Registered via `AgentConfig.import_path`, no Harbor patch. +# +# THE PROBLEM. Harbor's `pi` wrapper forwards a fixed list of API-key variables and nothing else +# (installed/pi.py:100-137). It has no base-URL handling at all, and unlike opencode there is no +# `_build_register_config_command` hook to write a provider config. Pointed at our intercept, pi ignores +# `OPENAI_BASE_URL`, calls api.openai.com with our session id as the key, and dies: +# +# OpenAI API error (401): Incorrect API key provided: sc6f2e5a… +# You can find your API key at https://platform.openai.com/account/api-keys +# +# THE FIX. pi reads custom providers from `~/.pi/agent/models.json` +# (https://pi.dev/docs/latest/custom-provider). Harbor gives us no hook to write it, but it does let an +# agent be supplied by `import_path`, so we subclass `Pi`, write the file in `setup()`, and register +# the subclass. Harbor is untouched. +# +# AgentConfig(import_path="harnesses.pi_agent:InterceptPi", ...) +# +# This is the general escape hatch for any harness whose config Harbor does not know how to write: +# subclass locally, override `setup()`, register by import path. +# +# TWO DETAILS THAT MATTER. +# +# `api` must be `openai-completions`. Left to its own devices pi picks `openai-responses` for a provider +# named `openai` (we watched it do exactly that: `"api":"openai-responses"` in its session log). The +# intercept handles both, but chat-completions is the dialect with the least translation and by far the +# most mileage on it. +# +# The provider is NOT named `openai`. A distinct name keeps pi off its built-in OpenAI defaults, the +# same reason opencode's provider is called `intercepted`. +PROVIDER = "intercept" +MODELS_JSON = "~/.pi/agent/models.json" + + +def build_models_json(base_url: str, api_key: str, model: str) -> str: + return json.dumps( + { + "providers": { + PROVIDER: { + "baseUrl": f"{base_url}/v1", + "api": "openai-completions", + "apiKey": api_key, + "models": [{"id": model}], + } + } + }, + indent=2, + ) + + +class InterceptPi(Pi): + """`Pi` that writes a custom-provider config into the sandbox before running. + + Config arrives through `AgentConfig.kwargs` as `intercept_config`, mirroring how opencode + receives `opencode_config`, so the seam table stays uniform across harnesses. + """ + + def __init__( + self, *args: Any, intercept_config: dict[str, str] | None = None, **kwargs: Any + ): + self._intercept_config = intercept_config or {} + super().__init__(*args, **kwargs) + + async def setup(self, environment: BaseEnvironment) -> None: + await super().setup(environment) + + base_url = self._intercept_config.get("base_url") + api_key = self._intercept_config.get("api_key") + model = self._intercept_config.get("model") + if not (base_url and api_key and model): + # Refuse quietly rather than run against api.openai.com with a session id as the key, + # which is what happens by default and costs a sandbox to discover. + raise ValueError( + "InterceptPi requires intercept_config with base_url, api_key, model" + ) + + payload = shlex.quote(build_models_json(base_url, api_key, model)) + await self.exec_as_agent( + environment, + command=f"mkdir -p ~/.pi/agent && printf '%s' {payload} > {MODELS_JSON}", + ) diff --git a/src/openenv/harbor/models.py b/src/openenv/harbor/models.py new file mode 100644 index 0000000000..a517301def --- /dev/null +++ b/src/openenv/harbor/models.py @@ -0,0 +1,352 @@ +"""Wire types for harbor_env. + +Two shapes matter. `HarborTaskRef` is what the Task API hands out during discovery, one per dataset +item. `HarborRolloutResult` is what one `run_rollout` returns: the reward, and enough token detail to +train on. + +Everything here is JSON-serialisable by construction — `run_rollout` returns +`result.model_dump_json()` and the client re-validates, matching how `opencode_env` and `pi_env` do +it. There is no shared memory between server and client. +""" + +from __future__ import annotations + +from typing import Any + +from openenv.core.env_server.types import State +from pydantic import BaseModel, Field + + +class HarborTaskRef(BaseModel): + """One task, as returned by the Task API. What a trainer's dataset holds per row.""" + + index: int + task_id: str + task_name: str + dataset: str = "" + instruction: str = "" + + +class HarborTurn(BaseModel): + """One model call, captured exactly. The unit a trainer consumes. + + `prompt_token_ids` is the engine's own tokenisation of everything before this turn, not a local + re-render. That distinction is the whole point of the capture layer: re-tokenising a prompt + offline drifts from what the model actually saw (measured at 0/6 exact on Qwen3.5 until thinking + was disabled), and a drifted prompt silently forks a long conversation into short fragments. + """ + + turn: int + role: str = "agent" + finish_reason: str | None = None + prompt_token_ids: list[int] = Field(default_factory=list) + completion_token_ids: list[int] = Field(default_factory=list) + per_token_logps: list[float] = Field(default_factory=list) + # Full prompt+completion mask; None supports older serialized Harbor results. + loss_mask: list[int] | None = None + node_id: str = "" + n_tools: int = 0 + discarded: bool = False + # Whether THIS turn's logprobs may be trained on. False when ingest rejected them (`check_turn` + # drops `sampled_logprobs` but keeps the sampled ids, so the tokens remain valid context for later + # turns) — after which `sequence_for` masks the turn out and zero-fills its logprobs. Without this + # flag those zeros are indistinguishable from a genuine logprob of 0.0, i.e. a p=1.0 token, and a + # per-turn trainer would take them at face value. + trainable: bool = True + # Submitted inference policy, after overrides and compatibility edits. A processed logprob + # includes these transformations. Preserve the harness's original request separately. + sampling_params: dict[str, Any] = Field(default_factory=dict) + requested_sampling_params: dict[str, Any] = Field(default_factory=dict) + + # What the model actually produced, in readable form. Only the assistant's own output is kept, + # never the prompt side: the prompt is already present as token ids and repeating it as text + # would roughly double the payload for no new information. This is what makes a result + # inspectable without a tokenizer, and it is what a reward function keys on when it needs to + # know which tool was called rather than how many tokens were spent. + text: str = "" + tool_calls: list[dict[str, Any]] = Field(default_factory=list) + # What the harness ASKED for on this turn. Carried because a consumer that wants TRL's + # `TraceEntry` shape needs `request.messages` and `request.tools`, and without them the wire + # result cannot be converted at all — the token fields alone do not say what prompt produced + # them. It also makes retokenization skew measurable against `prompt_token_ids` above, which is + # the only way to know whether re-rendering a prompt locally is lossless for a model + harness + # pair rather than assuming it either way. + request_messages: list[dict[str, Any]] = Field(default_factory=list) + request_tools: list[dict[str, Any]] | None = None + + +class HarborConversation(BaseModel): + """One complete conversation from a rollout, exactly as the harness assembled it. + + A rollout can contain several: a root is a conversation that started from a fresh prompt, so + subagents and auxiliary calls each get their own. `messages` is the full list including the + system prompt and every tool result, which is what makes a finished rollout readable rather than + a column of token counts. + """ + + root_id: str = "" + role: str = "agent" # agent | auxiliary | discarded + n_turns: int = 0 + messages: list[dict[str, Any]] = Field(default_factory=list) + + +class HarborStepResult(BaseModel): + """One step of a multi-step task. Harbor gates progression on `min_reward`.""" + + name: str = "" + rewards: dict[str, float] = Field(default_factory=dict) + passed: bool = True + + +class HarborRolloutResult(BaseModel): + """Everything one rollout produced. + + A failed rollout is still a valid result: `ok=False`, `error` set, `reward=None`. Nothing raises + across the server boundary, because a rollout exception reaching a trainer is what hangs every + rank at the NCCL barrier forever. + """ + + # identity + task_id: str = "" + task_name: str = "" + dataset: str = "" + harness: str = "" + sandbox: str = "" + trial_name: str = "" + session_id: str = "" + + # outcome — forwarded from Harbor's verifier, never recomputed here + reward: float | None = None + rewards: dict[str, float] = Field(default_factory=dict) + reward_key: str = "" + step_results: list[HarborStepResult] = Field(default_factory=list) + + # What kind of rollout this is, and why. `train` carries the token fields below; `eval` carries + # everything except them. This is not a flag anyone sets: it is decided by what the inference + # endpoint could return, probed before the server started, and it travels with the result so that + # no consumer has to infer trainability from the emptiness of a list. + rollout_type: str = "train" + capture_level: str = "tokens" + # Params the upstream rejected and how the proxy worked around them. A dropped `temperature` + # changes the sampling distribution, which makes an eval number irreproducible if unrecorded. + param_fixes: list[str] = Field(default_factory=list) + + @property + def trainable(self) -> bool: + return self.rollout_type == "train" + + # capture + turns: list[HarborTurn] = Field(default_factory=list) + conversations: list[HarborConversation] = Field(default_factory=list) + n_turns: int = 0 + n_roots: int = 0 + # Terminal responses emitted by the proxy; never model tokens or training turns. + budget_stop_count: int = 0 + n_trainable_tokens: int = 0 + multi_turn: bool = False + atif: str = "none" + # Which independent record the capture was checked against: `atif` for a harness trajectory, + # a reader name (e.g. `pi_session`) when the harness records the same thing under another + # format, or empty when it records nothing comparable. + trace_source: str = "" + findings: list[str] = Field(default_factory=list) + + # timings and diagnostics. There is no metrics endpoint and no structured logging in the env + # server, so observability has to ride back inside the payload or it does not exist. + wall_s: float = 0.0 + phase_timings: dict[str, float] = Field(default_factory=dict) + agent_log_tail: str = "" + + # failure + ok: bool = True + error: str | None = None + exception_type: str | None = None + + @property + def solved(self) -> bool: + """Graded AND positive. `reward is None` means the verifier never ran, which is not a zero.""" + return self.reward is not None and self.reward > 0 + + +class HarborState(State): + """Per-session counters. Mutated inside the tool, since `step` only dispatches.""" + + rollouts_completed: int = 0 + last_reward: float | None = None + last_task_id: str | None = None + last_trial_name: str | None = None + llm_url: str = "" + intercept_url: str = "" + + +def _assistant_text(response: dict[str, Any]) -> str: + """The assistant's own words, flattened across the shapes the four dialects produce.""" + content = response.get("content") + if isinstance(content, list): # anthropic / responses send block lists + return " ".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("text") + ) + return content if isinstance(content, str) else "" + + +def _tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]: + """Tool calls as `{name, arguments}`, normalised across dialects. + + Kept as data rather than a rendered string: a reward function that wants to check which tool ran + should not have to parse a display format. + """ + out: list[dict[str, Any]] = [] + for call in response.get("tool_calls") or []: + function = call.get("function") or {} + name = function.get("name") or call.get("name") + if not name: + continue + out.append( + { + "name": str(name), + "arguments": function.get("arguments", call.get("arguments", "")), + } + ) + # Anthropic does not use `tool_calls`: it puts tool use in the content block list. Reading only + # the chat-completions shape leaves claude-code's actions out of the result entirely, so + # `contract.json` and the rendered conversation both show it as a stream of text that did + # nothing. + content = response.get("content") + if isinstance(content, list): + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "tool_use" + and block.get("name") + ): + out.append( + {"name": str(block["name"]), "arguments": block.get("input", "")} + ) + return out + + +def conversations_from_document(document: dict[str, Any]) -> list[HarborConversation]: + """Rebuild the full conversations, system prompt and tool results included. + + The deepest node of a chain already carries the whole conversation in `request_messages`, since + each call replays everything before it. So the last node per root plus its own response is the + complete transcript, with no stitching and no risk of drifting from what was actually sent. + """ + by_node = {t["node_id"]: t for t in document.get("turns", [])} + # One per root, not one per sequence. A fork produces several paths through the same root, and + # each replays the same conversation up to the branch point, so emitting one per sequence shows + # the reader near-identical transcripts and calls both of them the main conversation. The + # longest path is the complete one. + best: dict[str, HarborConversation] = {} + + for sequence in document.get("sequences", []): + node_ids = sequence.get("node_ids") or [] + if not node_ids: + continue + last = by_node.get(node_ids[-1], {}) + messages = list(last.get("request_messages") or []) + response = last.get("response_message") or {} + if response: + messages.append({**response, "role": response.get("role", "assistant")}) + if not messages: + continue + root_id = str(sequence.get("root_id", "")) or node_ids[0] + candidate = HarborConversation( + root_id=root_id, + role=str(sequence.get("role", "agent")), + n_turns=int(sequence.get("n_turns", len(node_ids))), + messages=messages, + ) + current = best.get(root_id) + if current is None or len(candidate.messages) > len(current.messages): + best[root_id] = candidate + return list(best.values()) + + +def turns_from_document(document: dict[str, Any]) -> list[HarborTurn]: + """Flatten a capture document into per-turn training rows. + + Only `agent` sequences become turns. Auxiliary calls are dropped here rather than marked, + because they are not the agent working on the task and must never carry its reward — a + next-speaker classification credited with solving a task is a reward-hacking gift. + """ + by_node = {t["node_id"]: t for t in document.get("turns", [])} + rows: list[HarborTurn] = [] + # Forked paths share their prefix, and each live path is exported as its own sequence, so the + # same node appears in more than one of them. Emit each node once: a duplicated row is the same + # model call credited twice, which quietly doubles its weight in the gradient. + seen: dict[str, list[int]] = {} + index = 0 + + for sequence in document.get("sequences", []): + if sequence.get("role") != "agent": + continue + input_ids = sequence["input_ids"] + logprobs = sequence["logprobs"] + for node_id in sequence["node_ids"]: + node = by_node.get(node_id, {}) + response = node.get("response_message") or {} + + # Each turn's own counts, not a walk over runs of the loss mask. A sequence is built as + # (context, sampled) per node, so the cumulative offset where a turn's sampled tokens + # begin is exactly the length of that turn's prompt, which the document already records. + # + # The previous version zipped `node_ids` against `turn_lengths`, where `turn_lengths` + # counts runs of mask-1. A turn whose logprobs were missing contributes mask-0 and so no + # run at all, which made the two lists different lengths: `zip` then stopped early and + # every turn after the bad one was dropped or attributed to the wrong node. Turns with + # no context between them merged into one run for the same reason. + n_prompt = int(node.get("n_prompt", 0)) + n_sampled = int(node.get("n_sampled", 0)) + end = n_prompt + n_sampled + + # Reconciliation can mask only part of a completion. Preserve each bit; `trainable` + # means some supervision remains, and cannot replace the per-token mask. + mask = sequence.get("loss_mask") or [] + span = mask[n_prompt:end] + if ( + n_prompt < 0 + or n_sampled < 0 + or end > len(input_ids) + or len(span) != n_sampled + ): + raise ValueError(f"incomplete token or mask span for node {node_id}") + if node_id in seen: + if seen[node_id] != span: + raise ValueError( + f"inconsistent completion masks for shared node {node_id}" + ) + continue + seen[node_id] = list(span) + trainable = bool(span) and any(m == 1 for m in span) + + rows.append( + HarborTurn( + turn=index, + node_id=node_id, + finish_reason=node.get("finish_reason"), + # Every turn, not just the first. This is the engine's own tokenisation of + # everything the model saw before it generated, which is what the training + # contract promises and what a per-turn trainer consumes. + prompt_token_ids=input_ids[:n_prompt], + completion_token_ids=input_ids[n_prompt:end], + # Straight off the node, which already recorded exactly what was sent upstream. + request_messages=list(node.get("request_messages") or []), + request_tools=node.get("request_tools"), + per_token_logps=node.get("sampled_logprobs") + or logprobs[n_prompt:end], + loss_mask=[0] * n_prompt + list(span), + n_tools=node.get("n_tools", 0), + discarded=bool(node.get("discarded")), + trainable=trainable, + sampling_params=node.get("sampling_params") or {}, + requested_sampling_params=node.get("requested_sampling_params") + or {}, + text=_assistant_text(response), + tool_calls=_tool_calls(response), + ) + ) + index += 1 + return rows diff --git a/src/openenv/harbor/nemo_profile.py b/src/openenv/harbor/nemo_profile.py new file mode 100644 index 0000000000..f4046fb33d --- /dev/null +++ b/src/openenv/harbor/nemo_profile.py @@ -0,0 +1,26 @@ +"""Opt-in NeMo ReAct configuration for qualification on file-based tasks.""" + +import yaml +from harbor.agents.installed.nemo_agent import NemoAgent + + +class NemoShellProfile(NemoAgent): + """Reuse Harbor's installation and endpoint configuration with a native NeMo agent.""" + + def _generate_config_yaml(self, model_name: str, api_key: str) -> str: + config = yaml.safe_load(super()._generate_config_yaml(model_name, api_key)) + llm_name = next(iter(config["llms"])) + config["llms"][llm_name]["temperature"] = 0.8 + config["functions"] = { + "shell": {"_type": "openenv_sandbox_shell", "timeout": 60} + } + config["workflow"] = { + "_type": "react_agent", + "llm_name": llm_name, + "tool_names": ["shell"], + "use_native_tool_calling": True, + "max_tool_calls": 17, + "max_history": 1000, + "additional_instructions": "Use the shell tool to inspect task files and complete the requested work. Follow the task's submission protocol.", + } + return yaml.safe_dump(config, sort_keys=False) diff --git a/src/openenv/harbor/proc_env_context.py b/src/openenv/harbor/proc_env_context.py new file mode 100644 index 0000000000..54f15cc0df --- /dev/null +++ b/src/openenv/harbor/proc_env_context.py @@ -0,0 +1,133 @@ +"""Per-rollout `os.environ` reads, so credential-by-env harnesses stop serialising. + +Three validated harnesses — claude-code, gemini-cli and goose — read `os.environ` inside their +`run()` to assemble the env dict they hand to the sandbox: + + api_key = os.environ.get("OPENAI_API_KEY") # goose.py:653 + "ANTHROPIC_BASE_URL": os.environ.get(...) # claude_code.py:1393 + for var in auth_vars: env[var] = os.environ[var] # gemini_cli.py:824 + +And in this system **the API key IS the rollout's session id**, which is how one capture proxy +multiplexes N concurrent rollouts. So each concurrent rollout needs a DIFFERENT value of the same +variable at the same instant, in one process. `os.environ` is process-global, so the only correct +answer used to be `_PROC_ENV_LOCK` — serialise them, and accept that three harnesses run one rollout +at a time while the other twelve run in parallel. + +The observation that removes the lock: those wrappers only ever READ, and only to build a dict. They +do not need the value to be globally visible — they need it to be visible *to them, now*. That is a +context-local read, and `contextvars` are exactly that, propagating into asyncio tasks and into +`asyncio.to_thread` (which copies the context). + +So `os.environ` is replaced by a mapping that consults a context-local overlay first and the real +environment second. Each rollout sets its own overlay; concurrent rollouts see different values from +the same expression; nothing global is mutated, and no lock is needed. + +Two deliberate properties: + + * **Iteration and `copy()` return the MERGED view.** `subprocess` builds a child's environment from + `os.environ` unless told otherwise, so a proxy that hid the overlay from iteration would silently + launch subprocesses without the credentials — the failure would look like a bad key, not a bad + proxy. + * **Writes go to the real environment.** Only reads are context-local. Code that sets a variable + expecting it to persist keeps working, which matters because this replaces a global object used by + every library in the process. + +`OPENENV_CONCURRENT_PROC_ENV=0` disables it and restores the lock, because this swaps out a global that +the whole process reads and a single bad interaction is worth being able to switch off without a +rollback. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +from collections.abc import Iterator, MutableMapping +from contextvars import ContextVar + +logger = logging.getLogger(__name__) + +_overlay: ContextVar[dict[str, str] | None] = ContextVar( + "openenv_env_overlay", default=None +) +_installed = False +_real_environ: MutableMapping[str, str] | None = None + + +class _ContextEnviron(MutableMapping): + """`os.environ` whose reads consult a context-local overlay first.""" + + def __init__(self, base: MutableMapping[str, str]) -> None: + self._base = base + + # --- reads: overlay wins --------------------------------------------- + def __getitem__(self, key: str) -> str: + over = _overlay.get() + if over is not None and key in over: + return over[key] + return self._base[key] + + def __iter__(self) -> Iterator[str]: + over = _overlay.get() or {} + seen = set() + for key in list(self._base): + seen.add(key) + yield key + for key in over: + if key not in seen: + yield key + + def __len__(self) -> int: + over = _overlay.get() or {} + return len(set(self._base) | set(over)) + + def __contains__(self, key: object) -> bool: + over = _overlay.get() + return (over is not None and key in over) or key in self._base + + def copy(self) -> dict[str, str]: + """Merged, because `subprocess` uses this to build a child's environment.""" + merged = dict(self._base) + merged.update(_overlay.get() or {}) + return merged + + # --- writes: real environment ---------------------------------------- + def __setitem__(self, key: str, value: str) -> None: + self._base[key] = value + + def __delitem__(self, key: str) -> None: + del self._base[key] + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return f"_ContextEnviron({len(self)} vars, overlay={bool(_overlay.get())})" + + +def enabled() -> bool: + return os.environ.get("OPENENV_CONCURRENT_PROC_ENV", "1") != "0" + + +def install() -> bool: + """Swap `os.environ` for the context-aware proxy. Idempotent; returns whether it is active.""" + global _installed, _real_environ + if _installed: + return True + if not enabled(): + return False + _real_environ = os.environ + os.environ = _ContextEnviron(_real_environ) # type: ignore[assignment] + _installed = True + logger.info( + "os.environ reads are context-local; credential-by-env harnesses no longer serialise" + ) + return True + + +@contextlib.contextmanager +def overlay(values: dict[str, str]): + """Make `values` visible to `os.environ` reads in THIS context only.""" + parent = _overlay.get() or {} + token = _overlay.set({**parent, **values}) + try: + yield + finally: + _overlay.reset(token) diff --git a/src/openenv/harbor/qualification.py b/src/openenv/harbor/qualification.py new file mode 100644 index 0000000000..eb5c3d1652 --- /dev/null +++ b/src/openenv/harbor/qualification.py @@ -0,0 +1,154 @@ +"""Evidence gates for provider evaluation, exact capture, and optimizer qualification. + +An installed adapter or reachable endpoint is not an end-to-end qualification. +Live reports are external artifacts tied to model, harness, source and task versions. +""" + +from __future__ import annotations + +from typing import Any + +from .contract import to_trace_entries +from .models import HarborRolloutResult + +PROVIDERS = ("openai", "anthropic", "hf", "vllm") +STATUSES = frozenset( + { + "not_run", + "in_progress", + "failed", + "blocked", + "eval_pass", + "capture_and_reader_pass", + "optimizer_pass", + } +) + + +def evaluate_eval_capture(result: HarborRolloutResult) -> dict[str, bool]: + rejected = False + try: + entries = to_trace_entries(result) + except ValueError: + entries = [] + rejected = True + return { + "rollout_completed": result.ok, + "verifier_graded": result.reward is not None, + "model_calls_captured": result.n_turns > 0, + "eval_label": result.rollout_type == "eval", + "no_training_export": rejected + and not entries + and result.n_trainable_tokens == 0, + "no_capture_fatal": not any("[FATAL]" in f for f in result.findings), + } + + +def qualification_rows( + harnesses: list[str], report: dict[str, Any] | None = None +) -> list[list[str]]: + """Render recorded evidence without promoting legacy adapter status to provider passes.""" + records = {} + for cell in (report or {}).get("cells", []): + key = (cell.get("harness"), cell.get("provider")) + if key in records: + raise ValueError("duplicate harness/provider evidence") + if key[1] not in PROVIDERS: + raise ValueError(f"unknown qualification provider: {key[1]}") + status = cell.get("status", "not_run") + if status not in STATUSES: + raise ValueError(f"unknown qualification status: {status}") + if status.endswith("pass") and not cell.get("evidence"): + raise ValueError("qualification pass requires evidence") + if status == "optimizer_pass": + proof = cell.get("optimizer_evidence") or {} + if ( + cell.get("optimizer_validated") is not True + or proof.get("matches_current_captures") is not True + or not proof.get("result") + or not proof.get("inputs") + or not proof.get("scope") + or not proof.get("model") + or not proof.get("revision") + or not isinstance(proof.get("rows"), int) + or proof["rows"] <= 0 + ): + raise ValueError( + "optimizer pass requires matching, scoped optimizer evidence" + ) + records[key] = status + return [ + [name, *(records.get((name, provider), "not_run") for provider in PROVIDERS)] + for name in sorted(harnesses) + ] + + +def harness_maturity_rows( + harnesses: list[str], report: dict[str, Any] | None = None +) -> list[list[str]]: + """Classify recorded profiles, not universal or production-scale reliability. + + Partial support remains experimental. Four terminal failures make an adapter + unstable for this matrix, without claiming its vendor can never support it. + """ + rows = [] + for name, *statuses in qualification_rows(harnesses, report): + if statuses == ["eval_pass", "eval_pass", "eval_pass", "optimizer_pass"]: + tier = "stable" + reason = "All four tested profiles pass, including current-capture optimizer replay." + elif all(status in {"failed", "blocked"} for status in statuses): + tier = "unstable" + reason = "No tested provider profile passes; excluded from the stable set." + else: + tier = "experimental" + reason = "Partial support or validation pending; explicit opt-in only." + rows.append([name, tier, reason]) + return rows + + +def qualification_details(report: dict[str, Any] | None = None) -> list[list[str]]: + """Expose the scope and provenance behind the summary statuses. + + File references identify externally verified artifacts; this renderer does not + re-run jobs or assert that a saved report matches the currently selected endpoint. + """ + qualification_rows([], report) # Apply identical validation to both UI views. + rows = [] + for cell in (report or {}).get("cells", []): + if cell.get("status", "not_run") == "not_run": + continue + config = cell.get("configuration") or {} + proof = cell.get("optimizer_evidence") or {} + harness = cell.get("harness", "") + matching = proof.get("matches_current_captures") is True + model = config.get("model") or (proof.get("model") if matching else "") + pinned = (config.get("version_pins") or {}).get(harness) + observed = sorted( + { + str(item["observed_version"]) + for item in cell.get("capture_provenance", []) + if item.get("observed_version") + } + ) + version = "pinned: " + str(pinned) if pinned else "pin not recorded" + if observed: + version += "; observed: " + ", ".join(observed) + rows.append( + [ + harness, + cell["provider"], + cell.get("status", "not_run"), + model or "not recorded", + version, + str(cell.get("tasks_completed", "not recorded")), + str(config.get("acp_profile") or config.get("nemo_profile") or ""), + ("current captures: " if matching else "previous captures only: ") + + str(proof.get("scope", "")) + if proof + else "not validated", + str(proof.get("revision", "")), + "; ".join(str(path) for path in cell.get("evidence", [])), + str(cell.get("reason") or ""), + ] + ) + return sorted(rows, key=lambda row: (row[0], row[1])) diff --git a/src/openenv/harbor/rollout.py b/src/openenv/harbor/rollout.py new file mode 100644 index 0000000000..afedf0ed49 --- /dev/null +++ b/src/openenv/harbor/rollout.py @@ -0,0 +1,777 @@ +"""Run one Harbor trial through the capture proxy and return trainable output. + +This is where every piece meets: a task from the dataset, a harness from the seam table, a sandbox +from Harbor, and a capture session whose id doubles as the agent's API key. + + task dir ──┐ + harness ───┼──> TrialConfig ──> Trial.run() ──> TrialResult (reward) + sandbox ───┘ │ + └──> agent talks to the intercept + (api key == session id) + │ + RolloutGraph ──> HarborRolloutResult + +**Nothing raises out of `run_rollout`.** A failed rollout returns `ok=False` with `reward=None`. That +is not defensive habit, it is the reason this layer exists: in the white-box predecessor a rollout +exception reached the trainer and hung every rank at the NCCL barrier forever, so every method there +had to be individually wrapped. Behind a result object that failure mode cannot occur. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import re +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Callable + +from openenv.core.harness.capture.export import export_session + +from . import seams +from .atif import load_trace, reconcile +from .models import ( + conversations_from_document, + HarborRolloutResult, + HarborStepResult, + HarborTurn, + turns_from_document, +) + +# Harbor's own retry is deliberately off. A Harbor-level retry re-runs the agent against the SAME +# capture session, so two attempts merge into one graph and the trace cross-check compares a +# two-attempt capture against a one-attempt trajectory. One attempt = one session = one rollout; +# retries belong to the caller, with a fresh session each time. +_NO_HARBOR_RETRY = 0 + + +# Some agents read os.environ at CONSTRUCTION, before any sandbox exists — Harbor's claude-code +# wrapper decides there which credentials to forward. For those, `agent_env` alone is too late, so +# the seam also carries a process-env channel. +# +# Others read it at RUN time instead: Harbor's goose wrapper does so inside its own `run` +# (goose.py:653). So the override is held across `Trial.create` AND `trial.run()` whenever a seam +# carries `proc_env`, because restoring it in between handed goose the operator's real provider key +# and earned a 401 from our own proxy on every call. +# +# That channel is global, so two concurrent rollouts of a proc-env harness would overwrite each +# other's session key, and holding it across the whole trial means those rollouts serialise. That is +# not an implementation shortcut but a property of the agent: it reads a process-global variable at +# run time, so per-rollout isolation is impossible without a process per rollout. Seams that pass +# credentials through `agent_env` — most of them — neither take the lock nor wait on it. +# +# A `threading.Lock`, not an `asyncio.Lock`. What is being protected is `os.environ`, which is global +# to the PROCESS, not to an event loop, and rollouts arrive on several loops: the env server answers +# each request on its own loop, and any caller using `asyncio.run` per rollout creates another. An +# asyncio lock binds to the first loop that uses it and then raises +# "is bound to a different event loop" for every other one, which fails 100% of concurrent rollouts +# while passing every sequential test. +_PROC_ENV_LOCK = threading.Lock() + + +async def _acquire_proc_env() -> None: + """Take `_PROC_ENV_LOCK` without leaking it if the caller is cancelled. + + `asyncio.to_thread` is not cancellable: cancelling the await abandons the future, but the worker + thread runs on and still takes the lock. Nobody is left holding a frame that releases it, so a + single cancelled proc-env rollout — a client timeout, a trainer shutting a worker down — would + wedge every later goose rollout on a lock with no owner. Hence the done-callback: whoever ends up + acquiring is the one who releases. + """ + acquiring = asyncio.ensure_future(asyncio.to_thread(_PROC_ENV_LOCK.acquire)) + try: + await asyncio.shield(acquiring) + except asyncio.CancelledError: + acquiring.add_done_callback( + lambda fut: _PROC_ENV_LOCK.release() if not fut.cancelled() else None + ) + raise + + +@contextlib.contextmanager +def process_env(seam_name: str, env: dict[str, str]): + """Set the seam's process-level env vars for the duration of the block, then put them back. + + Restoring matters because this is global state. Without it the last rollout's session id stays + in `os.environ` for the life of the process, so anything afterwards, including the next + rollout's grader, sees another harness's credentials. That is the same class of cross-talk the + lock exists to prevent, just spread over time instead of across threads. + + OPENAI_API_KEY is shared with the task grader: Harbor forwards it into the sandbox and the + DataAgent grader's LLM-judge tier fires on `if os.environ.get("OPENAI_API_KEY")`. Overwriting it + with a session id makes the judge 401, so every semantically-correct-but-not-exact answer scores + 0 — which reads as a weak model rather than a broken harness, and poisons the RL baseline. + """ + grader_key = os.environ.get("OPENAI_API_KEY") + previous = {key: os.environ.get(key) for key in env} + for key, value in env.items(): + if key == "OPENAI_API_KEY" and grader_key and value != grader_key: + print( + f"[{seam_name}] WARNING: this seam overwrites OPENAI_API_KEY, which the grader " + "uses for its LLM-judge tier. Judging is disabled for this run (exact-match and " + "numeric tolerance still apply)." + ) + os.environ[key] = value + try: + yield + finally: + for key, was in previous.items(): + if was is None: + os.environ.pop(key, None) + else: + os.environ[key] = was + + +def _finite_reward(key: str, value: Any) -> float: + """A verifier's value as a real float, or raise. + + Args: + key (`str`): + Reward name, for the error message — a caller needs to know WHICH key was unusable. + value: + Whatever the task's verifier put in its reward dict. + + Returns: + `float`: The coerced value. + + Raises: + TypeError: If the value cannot be a float at all. + ValueError: If it coerces to inf or nan. + """ + number = float(value) # raises TypeError/ValueError, caught by the caller + if number != number or number in (float("inf"), float("-inf")): + raise ValueError(f"{key}={value!r} is not finite") + return number + + +def _pick_reward( + rewards: dict[str, float], reward_key: str = "" +) -> tuple[float | None, str]: + """Collapse Harbor's reward dict to the one scalar an RL trainer consumes. + + Refuses rather than guesses. Harbor lets a task emit any keys it likes, and inventing a rule to + combine them is inventing reward semantics — which is exactly how a previous run got reward + hacked: a `+0.2 for submitting anything` term made the policy learn to quick-submit, training + reward looked healthy, and eval collapsed from 0.740 to 0.178. + + Args: + rewards (`dict[str, float]`): + The verifier's reward dict, as Harbor produced it. + reward_key (`str`, *optional*): + Force a specific key, or a comma-separated preference order. Required when the dict + has several and none is named `reward`. Selection happens on the same verifier result. + + Returns: + `tuple[float | None, str]`: The chosen value and the key it came from. + + Raises: + ValueError: If the key is ambiguous and none was given. + """ + if not rewards: + return None, "" + if reward_key: + # Preserve literal keys (including commas) before interpreting a preference list. + candidates = [reward_key] + [key.strip() for key in reward_key.split(",")] + for key in candidates: + if key in rewards: + return float(rewards[key]), key + raise ValueError(f"reward_key {reward_key!r} not in {sorted(rewards)}") + if len(rewards) == 1: + key = next(iter(rewards)) + return float(rewards[key]), key + if "reward" in rewards: + return float(rewards["reward"]), "reward" + raise ValueError( + f"task produced several rewards {sorted(rewards)} and none is named 'reward'. " + "Pass reward_key= to say which one is the training signal." + ) + + +def build_trial_config( + *, + task_dir: Path | str, + harness: str, + sandbox: str, + intercept_url: str, + session_id: str, + model: str, + trial_name: str, + trials_dir: Path | str, + keep_sandbox: bool = False, + agent_timeout_sec: float | None = None, + agent_step_limit: int | None = None, + force_build: bool = False, + training: bool = False, + harness_profile: str | None = None, +) -> Any: + """Assemble Harbor's `TrialConfig` for one rollout. + + The seam decides how this particular agent learns about the proxy — an env var, a constructor + kwarg, or a config file written by a subclass. That is the only per-agent knowledge involved; + everything downstream is identical. + """ + from harbor.models.trial.config import ( + AgentConfig, + EnvironmentConfig, + TaskConfig, + TrialConfig, + VerifierConfig, + ) + + seam = seams.get(harness, profile=harness_profile) + model_name, kwargs, agent_env, _proc_env = seam.resolve( + base_url=intercept_url, + session=session_id, + model=model, + step_limit=agent_step_limit, + training=training, + ) + + # Deployment-level pins use Harbor's existing InstalledAgent(version=...) support. + # Unlisted harnesses retain Harbor's default; malformed pins must not reach an installer. + versions = json.loads(os.environ.get("OPENENV_HARBOR_AGENT_VERSIONS", "{}")) + if not isinstance(versions, dict) or any( + not isinstance(name, str) + or not isinstance(version, str) + or re.fullmatch(r"[0-9]+(?:\.[0-9]+){1,3}(?:[-+][A-Za-z0-9.-]+)?", version) + is None + for name, version in versions.items() + ): + raise ValueError( + "OPENENV_HARBOR_AGENT_VERSIONS must map harness names to exact numeric versions" + ) + if harness in versions: + kwargs = {**kwargs, "version": versions[harness]} + + agent = AgentConfig( + name=seam.import_path or harness, + import_path=seam.import_path, + model_name=model_name, + kwargs=kwargs, + env=agent_env, + override_timeout_sec=agent_timeout_sec, + ) + return TrialConfig( + task=TaskConfig(path=Path(str(task_dir))), + agent=agent, + environment=EnvironmentConfig( + type=sandbox, + import_path=( + "openenv.harbor.e2b_stream:E2BStreamingEnvironment" + if sandbox == "e2b" + and os.environ.get("OPENENV_E2B_STREAM_UPLOADS") == "1" + else None + ), + delete=not keep_sandbox, + force_build=force_build, + ), + verifier=VerifierConfig(), + trial_name=trial_name, + trials_dir=Path(str(trials_dir)), + ) + + +async def run_rollout( + *, + task_dir: Path | str, + harness: str, + sandbox: str, + registry: Any, + intercept_url: str, + model: str, + trials_dir: Path | str, + dataset: str = "", + reward_key: str = "", + keep_sandbox: bool = False, + agent_timeout_sec: float | None = None, + agent_step_limit: int | None = None, + force_build: bool = False, + session_prefix: str = "oe", + capture_level: str = "tokens", + upstream: Any = None, + inference: Any = None, + sampling: dict[str, Any] | None = None, + purpose: str = "auto", + eval_sampling: dict[str, Any] | None = None, + harness_profile: str | None = None, + on_session_created: Callable[[str], None] | None = None, +) -> HarborRolloutResult: + """Run one rollout end to end. Never raises. + + Args: + task_dir (`Path` or `str`): + The Harbor task directory to run. + harness (`str`): + A seam name (`opencode`, `claude-code`, ...) or a `module:Class` import path. + sandbox (`str`): + A Harbor `EnvironmentType`, e.g. `e2b` or `modal`. + registry (`SessionRegistry`): + The live capture registry; a session is minted here and its id becomes the agent's key. + intercept_url (`str`): + Public URL of the capture proxy, as the sandbox must reach it. + model (`str`): + Served model id. Normalised per harness by the seam. + trials_dir (`Path` or `str`): + Where Harbor writes trial artifacts. + reward_key (`str`, *optional*): + Which reward key is the training signal, for multi-reward tasks. + keep_sandbox (`bool`, *optional*, defaults to `False`): + Leave the sandbox alive after the run, for debugging. + capture_level (`str`, *optional*, defaults to `"tokens"`): + What the inference endpoint can return, as probed at startup. Below `tokens` this is an + eval rollout: reward and full trace, no token fields. + upstream (`Upstream`, *optional*): + The engine this rollout's captured calls go to, already resolved and probed by the caller. + `None` uses the capture server's default engine. + inference (`InferenceClient`, *optional*): + The proxy's upstream client, read at the end for the parameter workarounds it had to + apply. Passed rather than looked up so this function keeps no handle on the server. + sampling (`dict`, *optional*): + Explicit training policy. Requires a finite positive temperature and full-vocabulary + sampling. Overrides harness sampling settings and records both requested and submitted + values. Must match the trainer's recompute temperature. Also selects the seam's native + training settings; OpenCode disables delegation, questions and web fetch. + on_session_created (`Callable[[str], None]`, *optional*): + Receive this rollout's capture session ID before the agent starts. Live views must + follow this ID rather than discover unrelated sessions from the shared registry. + + Returns: + [`HarborRolloutResult`]: Reward, per-turn token ids and logprobs, and validation findings. + """ + from openenv.core.harness.capture.sessions import rollout_type_for + + try: + rollout_type = rollout_type_for(purpose, capture_level) + if purpose == "eval" and sampling is not None: + raise ValueError("eval purpose cannot apply a training sampling override") + except ValueError as exc: + return HarborRolloutResult( + ok=False, + error=str(exc), + harness=harness, + sandbox=sandbox, + capture_level=capture_level, + rollout_type="eval", + ) + task_dir = Path(str(task_dir)) + started = time.monotonic() + + # The session id lands in Harbor's E2B sandbox metadata (it is derived from trial_name), which + # is what later makes it possible to reap only the sandboxes this server created. + trial_name = f"{session_prefix}-{task_dir.name[:28]}-{uuid.uuid4().hex[:8]}" + # `upstream` names the engine for THIS rollout. Passing it here is what lets one server serve a + # train-tier and an eval-tier engine at once: the tier is measured per engine when the session is + # created, and travels on the session rather than on the server. + try: + session = registry.create( + session_id=None, + upstream=upstream, + capture_level=capture_level, + max_model_calls=agent_step_limit or 0, + sampling=sampling, + purpose=purpose, + eval_sampling=eval_sampling, + harness=harness, + sandbox=sandbox, + task=task_dir.name, + ) + except ValueError as exc: + return HarborRolloutResult( + task_id=str(task_dir), + task_name=task_dir.name, + dataset=dataset, + harness=harness, + sandbox=sandbox, + capture_level=capture_level, + rollout_type=rollout_type, + ok=False, + error=str(exc), + exception_type=type(exc).__name__, + ) + try: + # The session is the single source of truth for the level from here on: the caller resolved the + # engine and its measured tier before calling, and a rollout must never claim a level the engine + # was not measured at. + # `getattr`, because `registry` is deliberately untyped: callers pass their own registry (the + # trainer-side runner does), and a session object that predates per-session levels should degrade + # to the caller's hint rather than crash the rollout. + capture_level = getattr(session, "capture_level", "") or capture_level + + result = HarborRolloutResult( + task_id=str(task_dir), + task_name=task_dir.name, + dataset=dataset, + harness=harness, + sandbox=sandbox, + trial_name=trial_name, + session_id=session.session_id, + capture_level=capture_level, + rollout_type=rollout_type, + ) + + trial_result = None + try: + if on_session_created is not None: + on_session_created(session.session_id) + from harbor.trial.trial import Trial + + # Collapse identical per-task templates onto one alias when asked. Must happen before the + # first `Trial.create`, and is idempotent, so calling it per rollout is free. + from .shared_template import enable_shared_templates + + enable_shared_templates() + + config_kwargs = dict( + task_dir=task_dir, + harness=harness, + sandbox=sandbox, + intercept_url=intercept_url, + session_id=session.session_id, + model=model, + trial_name=trial_name, + trials_dir=trials_dir, + keep_sandbox=keep_sandbox, + agent_timeout_sec=agent_timeout_sec, + agent_step_limit=agent_step_limit, + force_build=force_build, + training=sampling is not None, + harness_profile=harness_profile, + ) + + # Held across construction because Harbor's wrappers read `os.environ` while building the + # agent, so the variables must still be in place when `Trial.create` runs. It is a blocking + # acquire inside an async function, which is acceptable only because construction does no + # I/O worth speaking of: the sandbox is booted later, by `trial.run()`, outside the lock. + # Harbor's wrappers read `os.environ` while constructing the agent, so the seam's + # process-level vars have to be in place across `Trial.create` and are put back after. + seam = seams.get(harness, profile=harness_profile) + *_, proc_env = seam.resolve( + base_url=intercept_url, session=session.session_id, model=model + ) + if not proc_env: + config = build_trial_config(**config_kwargs) + trial = await Trial.create(config) + trial_result = await trial.run() + else: + # The override has to span `run()`, not just construction. Harbor's goose wrapper reads + # `os.environ["OPENAI_API_KEY"]` inside its own `run` (goose.py:653), long after + # construction is done — so restoring the env before `run()` handed it the operator's REAL + # provider key instead of the capture session id, and our own proxy correctly answered + # + # 401 unknown API key; register a session via POST /sessions + # + # on every call. It failed identically against all five upstreams in a compatibility + # matrix, which is what identified it as the key rather than any endpoint. + # + # The cost is real and unavoidable: `os.environ` is process-global, so two concurrent + # rollouts of a proc-env seam genuinely cannot each have their own key. Those rollouts + # serialise. Seams that carry no `proc_env` — the large majority, which pass credentials + # through `agent_env` — take neither the lock nor the wait. + # + # Acquired via `to_thread` so a blocking `acquire()` cannot stall the event loop while + # another rollout holds the lock for the length of a full trial. A bare `acquire()` here + # would deadlock the server the first time two goose rollouts overlapped on one loop. + from .proc_env_context import ( + install as install_ctx_env, + overlay as ctx_env_overlay, + ) + + if install_ctx_env(): + # No lock. `os.environ` reads are context-local now, so concurrent rollouts of a + # credential-by-env harness each see their OWN session key from the same expression. + # This is what stops claude-code, gemini-cli and goose serialising behind one another. + with ctx_env_overlay(proc_env): + config = build_trial_config(**config_kwargs) + trial = await Trial.create(config) + trial_result = await trial.run() + else: + # Fallback: mutate the real environment under a lock, one rollout at a time. + await _acquire_proc_env() + try: + with process_env(seam.name, proc_env): + config = build_trial_config(**config_kwargs) + trial = await Trial.create(config) + trial_result = await trial.run() + finally: + _PROC_ENV_LOCK.release() + except Exception as exc: # noqa: BLE001 - a rollout failure is a RESULT, never an exception + result.ok = False + result.error = str(exc)[:600] + result.exception_type = type(exc).__name__ + + result.wall_s = round(time.monotonic() - started, 2) + + # --- reward, forwarded verbatim ------------------------------------- + if trial_result is not None: + verifier = getattr(trial_result, "verifier_result", None) + rewards = dict(getattr(verifier, "rewards", None) or {}) if verifier else {} + # Inside a try, and finite-checked. A verifier is task-supplied code that can put anything in + # this dict, and this line sits between the trial's try/except and `_pick_reward`'s, so a + # value like None, "N/A" or a nested dict used to raise straight out of `run_rollout`, through + # the MCP tool, and into the trainer — which is the every-rank-hangs-on-the-NCCL-barrier + # failure this whole HTTP boundary exists to make impossible. + # + # inf and nan are rejected too, though they coerce fine: inf reads as solved downstream, and + # nan poisons any average computed over a batch of rewards. + # + # PER KEY, not all-or-nothing. A dict comprehension inside one try meant a single unusable + # key discarded EVERY key and failed the whole rollout -- so a suite emitting + # `tool_efficiency: null` alongside a perfectly good `correctness` lost the correctness too. + # That is not hypothetical: it dropped 86 of 250 tasks from one scored run while the summary + # reported clean numbers over the remaining two thirds. An unmeasured key is an EXCLUSION, + # never a zero, so the key is dropped and named in `findings` rather than coerced to 0.0. + # The rollout only fails if the key actually being trained on is the unusable one, which + # `_pick_reward` decides below. + unusable: list[str] = [] + usable: dict[str, float] = {} + for key, value in rewards.items(): + try: + usable[key] = _finite_reward(key, value) + except (TypeError, ValueError) as exc: + unusable.append(f"{key}={value!r} ({exc})") + result.rewards = usable + if unusable: + result.findings.append( + "[WARN] verifier keys dropped as unusable: " + "; ".join(unusable) + ) + try: + result.reward, result.reward_key = _pick_reward( + result.rewards, reward_key + ) + except ValueError as exc: + result.ok = False + result.error = str(exc) + for step in getattr(trial_result, "step_results", None) or []: + step_rewards = dict( + getattr(getattr(step, "verifier_result", None), "rewards", None) + or {} + ) + try: + coerced = {k: _finite_reward(k, v) for k, v in step_rewards.items()} + except (TypeError, ValueError): + # A bad per-step reward is worth dropping, not worth failing the rollout: the + # headline reward above is what trains, and gating on it is already handled. + coerced = {} + result.step_results.append( + HarborStepResult( + name=getattr(step, "name", "") or "", + rewards=coerced, + ) + ) + info = getattr(trial_result, "exception_info", None) + if info is not None and result.error is None: + result.ok = False + result.exception_type = getattr(info, "exception_type", None) + result.error = str(getattr(info, "exception_message", ""))[:600] + + # --- capture -------------------------------------------------------- + try: + # `include_messages` is what puts the assistant's own output in the result. + # Only the response side is kept downstream (see `turns_from_document`), so + # the payload grows by the completion text, not by the whole conversation. + document = export_session( + session, include_messages=True, capture_level=capture_level + ) + stats = document.get("stats", {}) + result.n_turns = stats.get("n_turns", 0) + result.n_roots = stats.get("n_roots", 0) + result.budget_stop_count = document.get("budget_stop_count", 0) + # Counted over the DEDUPED turns, not over sequences. + # + # `stats["n_trainable_tokens"]` sums `n_trainable` per sequence, and forked paths share their + # prefix — so a node reached by several sequences is counted once per sequence. `turns` (below) + # deliberately emits each node once, because a duplicated row is the same model call credited + # twice and quietly doubles its weight in a gradient. Reporting the sequence-wise sum next to + # the deduped turns meant the two disagreed: measured on a forked gemini-cli rollout, 6845 + # reported against 6201 actually present, and 32950 against 31720 on a longer one. + # + # A consumer comparing this field against the turns beside it has to find them consistent, so + # this is the deduped figure. The sequence-wise total is still in `stats` for a consumer that + # trains on `sequences` instead, where per-sequence counting is the correct reading. + # Set once `result.turns` exists — see below, where it is filled in from the document after + # aux masking. Reading it here made the total always 0, because `turns` is still empty at this + # point in the function. + result.multi_turn = result.n_turns > result.n_roots + result.findings.extend( + f for f in document.get("validation", []) if not f.startswith("[INFO]") + ) + # Sequence-level findings were never surfaced. `check_sequence` FATALs — a positive logprob, a + # length mismatch, nothing trainable — live on the row rather than in the document's own + # validation list, so a rollout carrying one of them came back with a clean `findings` list and + # `ok=True`. The row is already marked untrainable; this makes the reason visible. + for row in document.get("sequences", []): + for finding in row.get("validation", []): + if not finding.startswith("[INFO]"): + result.findings.append( + f"[sequence {row.get('root_id', '?')}] {finding}" + ) + + trial_dir = _trial_dir(trial_result, trials_dir, trial_name) + # Not every harness writes ATIF. Three of the sixteen (hermes, openclaw, pi) emit no + # `trajectory.json`, which left them with no independent check at all. pi does record the + # same information in its own session log, so `load_trace` falls back to that; hermes writes + # a zero-byte file and openclaw only echoes back its config, so for those two there is + # genuinely nothing to compare against and `none` is the honest answer. + atif, trace_source = load_trace(trial_dir) if trial_dir else (None, "") + report = reconcile(document, atif) + result.atif = ( + "none" if atif is None else ("match" if report.ok else "MISMATCH") + ) + result.trace_source = trace_source + result.findings += [ + str(f) for f in report.findings if not str(f).startswith("[INFO]") + ] + + # Calls the harness's own trace does not count as agent steps are auxiliary; drop them so + # they cannot be credited with the reward earned by solving the task. + if report.aux_node_ids: + aux = set(report.aux_node_ids) + for sequence in document["sequences"]: + if sequence["role"] != "agent": + continue + nodes = set(sequence["node_ids"]) + if nodes <= aux: + sequence["role"] = "auxiliary" + elif nodes & aux: + # Detection is per node; demotion was per sequence, so a sequence MIXING an + # auxiliary node with real agent turns stayed `agent` in full and shipped the aux + # node as a training turn credited with the task's reward. That worked only under + # the unstated assumption that aux calls always form their own single-node root, + # and it erred unsafe when they did not. + # + # Masked at token level rather than dropped, because the rest of the sequence is + # genuine agent work: the aux node's sampled tokens stop being targets while + # remaining context, which is exactly how `sequence_for` treats a turn it cannot + # trust. Anything else would either discard real turns or train on an aux call. + _mask_out_nodes(document, sequence, nodes & aux) + + result.turns = turns_from_document(document) + # Counted over the DEDUPED turns, not over sequences. + # + # `stats["n_trainable_tokens"]` sums `n_trainable` per sequence, and forked paths share their + # prefix — so a node reached by several sequences is counted once per sequence, while `turns` + # deliberately emits each node once (a duplicated row is the same model call credited twice, + # which quietly doubles its weight in a gradient). Reporting the sequence-wise sum beside the + # deduped turns meant the two disagreed: 6845 reported against 6201 present on one forked + # rollout, 32950 against 31720 on a longer one. + # + # It has to be computed HERE rather than beside the other stats, because `turns` does not + # exist until this line. The sequence-wise total stays in `stats` for a consumer that trains + # on `sequences`, where counting per sequence is the correct reading. + result.n_trainable_tokens = sum( + sum(t.loss_mask) + if t.loss_mask is not None + else len(t.completion_token_ids) + for t in result.turns + if t.trainable and not t.discarded + ) + # Every conversation, not only the trainable ones: an auxiliary call that + # went wrong is exactly what someone reading a bad rollout needs to see. + result.conversations = conversations_from_document(document) + if not report.ok: + result.ok = False + result.error = result.error or "capture failed validation" + + # A FATAL from the document's own validation means the capture is unusable, and until now it + # was recorded in `findings` without touching `ok`. Only the trace-reconciliation report + # could set `ok`, so a rollout that reached the verifier but produced NO model calls came + # back `ok=True` with zero trainable tokens, and reconciliation agreed because both sides + # were empty. One such rollout even carried reward=1.0, which is the worst shape available: + # a trainer filtering on `ok` would accept a row with nothing in it and a positive reward. + fatal = [f for f in result.findings if "[FATAL" in f] + if fatal: + result.ok = False + result.error = result.error or fatal[0] + + # A train rollout that produced no trainable sequence is a failed train rollout, however + # healthy it looks: every row was masked out, or auxiliary, or discarded. Checked only on the + # train path, since having nothing trainable is the defining property of an eval rollout. + if ( + result.rollout_type == "train" + and result.ok + and not document.get("trainable") + ): + result.ok = False + result.error = ( + result.error + or "no trainable sequence survived: every captured path was masked out, " + "auxiliary or discarded" + ) + except Exception as exc: # noqa: BLE001 + result.ok = False + result.error = ( + result.error or f"capture export failed: {type(exc).__name__}: {exc}" + ) + + # Read after the rollout, not before: the fixes are discovered from the provider's own 400s as + # calls are made. A rewritten request is a changed experiment — dropping `temperature` alters the + # sampling distribution — so it travels with the result rather than living only in a log. + if inference is not None: + result.param_fixes = [str(f) for f in getattr(inference, "param_fixes", [])] + + # A rollout that produced no reward is not a zero: the verifier never ran. Keeping the two + # distinct is what stops a dead sandbox being scored as a wrong answer. + if result.ok and result.reward is None and trial_result is not None: + result.findings.append( + "[WARN] ungraded: the verifier produced no reward for this trial" + ) + return result + finally: + registry.delete(session.session_id) + + +def _mask_out_nodes( + document: dict[str, Any], sequence: dict[str, Any], node_ids: set[str] +) -> None: + """Zero the loss mask over the given nodes' sampled spans, in place. + + Their tokens stay in `input_ids` as context — the model did condition on them — but stop being + targets, and the sequence's trainable count is corrected to match. + """ + by_node = {t["node_id"]: t for t in document.get("turns", [])} + mask = sequence.get("loss_mask") + if not mask: + return + for node_id in sequence["node_ids"]: + if node_id not in node_ids: + continue + node = by_node.get(node_id, {}) + n_prompt = int(node.get("n_prompt", 0)) + n_sampled = int(node.get("n_sampled", 0)) + # `n_prompt` IS the sequence-coordinate start of this turn's sampled span — no running offset + # is needed, and tracking one was wrong. + # + # A node's `prompt_ids` is the whole conversation prefix, and `sequence_for` lays a child out as + # (interstitial context, sampled) where the context is `prompt_ids[len(parent.end_ids):]`. The + # two cancel: cumulative-before-sampled for turn k is + # n_prompt(k-1) + n_sampled(k-1) + (n_prompt(k) - n_prompt(k-1) - n_sampled(k-1)) = n_prompt(k) + # The first version advanced an offset as if each turn were only prompt-plus-sampled, so from + # the second turn on `start` landed on the interstitial context: it zeroed positions that were + # already 0 and left the aux node's real completion tokens at mask 1, which meant this function + # silently did nothing and aux calls kept being credited with the task's reward. + # + # `turns_from_document` slices the same way, so the two cannot disagree. + for i in range(n_prompt, min(n_prompt + n_sampled, len(mask))): + mask[i] = 0 + sequence["n_trainable"] = sum(mask) + sequence["trainable"] = bool(sequence["n_trainable"]) and sequence.get( + "trainable", True + ) + + +def _trial_dir( + trial_result: Any, trials_dir: Path | str, trial_name: str +) -> Path | None: + """Where Harbor wrote this trial's artifacts, including its trajectory.""" + uri = getattr(trial_result, "trial_uri", None) if trial_result is not None else None + if uri: + return Path(str(uri).replace("file://", "")) + candidate = Path(str(trials_dir)) / trial_name + return candidate if candidate.is_dir() else None + + +__all__ = ["run_rollout", "build_trial_config", "HarborRolloutResult", "HarborTurn"] diff --git a/src/openenv/harbor/runner.py b/src/openenv/harbor/runner.py new file mode 100644 index 0000000000..5962f68e10 --- /dev/null +++ b/src/openenv/harbor/runner.py @@ -0,0 +1,207 @@ +"""Drive rollouts without a server: boot capture, run tasks, tear down. + +`openenv harbor rollout` uses this. It exists so the whole path (LLM, capture proxy, forwarding, +seam, Harbor trial, sandbox, verifier, reconciliation) can be exercised with no env server in +the way. When something breaks, that halves the search space immediately: if this works and `serve` does +not, the problem is the serving layer and nothing below it. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from openenv.core.harness.capture import CaptureServer + +from .models import HarborRolloutResult +from .rollout import run_rollout +from .tasks import resolve_task_dirs + + +# Re-exported, not defined here. `CaptureServer` moved to `openenv.core.harness.capture` when a +# second environment needed an in-process proxy: it never touched a Harbor type, so living under +# `harbor/` forced unrelated callers to import Harbor for a class that uses none of it. +__all__ = ["CaptureServer", "run_batch"] + + +async def run_batch( + *, + llm_url: str, + dataset: str, + task_indices: list[int], + harness: str = "opencode", + sandbox: str = "e2b", + model: str | None = None, + port: int = 8100, + expose: str = "gradio", + trials_dir: Path | None = None, + reward_key: str = "", + keep_sandbox: bool = False, + force_build: bool = False, + env_file: str | None = None, + api_key: str | None = None, + auth_header: str = "Authorization", + provider: str = "openai", +) -> list[HarborRolloutResult]: + """Run `task_indices` from `dataset` and print a per-rollout report. + + Args: + llm_url (`str`): + OpenAI-spec inference endpoint. + dataset (`str`): + Dataset spec (HF repo id, local dir, or Harbor `name@version`). + task_indices (`list[int]`): + Which tasks to run, by index into the resolved dataset. + harness (`str`, *optional*, defaults to `"opencode"`): + Seam name or `module:Class`. + sandbox (`str`, *optional*, defaults to `"e2b"`): + Harbor environment type. + expose (`str`, *optional*, defaults to `"gradio"`): + How the sandbox reaches the capture proxy: `gradio`, `cloudflare` or `direct`. + + Returns: + `list[HarborRolloutResult]`: One per index, in order. + """ + # Imported here, not at module scope: a hosted deployment mounts the capture proxy on its own + # app and never forwards, so `forwarding` is not shipped there. + from openenv.core.harness.capture.forwarding import make_forwarder + + from .startup import prepare + + caps = prepare( + llm_url=llm_url, + model=model, + datasets=[dataset], + env_file=env_file, + require_llm=True, + quiet=False, + api_key=api_key, + auth_header=auth_header, + provider=provider, + ) + model = caps.llm.get("model") or model or "" + capture_level = caps.llm.get("capture_level") or "tokens" + # `prepare` already read the dotenv, so a key that lives only in --env-file is visible now. + api_key = api_key or os.environ.get("OPENENV_LLM_API_KEY") or None + + if sandbox not in caps.available_sandboxes: + detail = next( + (s.detail for s in caps.sandboxes if s.name == sandbox), "not checked" + ) + raise RuntimeError(f"sandbox {sandbox!r} is not usable here: {detail}") + + task_dirs = resolve_task_dirs(dataset) + trials_dir = trials_dir or Path("/tmp/openenv-harbor-trials") + trials_dir.mkdir(parents=True, exist_ok=True) + + capture = CaptureServer( + llm_url=llm_url, + model=model, + port=port, + api_key=api_key, + auth_header=auth_header, + provider=provider, + capture_level=capture_level, + ) + capture.start() + # The capture proxy is already listening on a bound port in a background thread, so an exception + # between here and the `try` below would leave that thread up and the port held — and the next + # attempt would then die on "port already in use" rather than on the real error. Forwarder setup + # is the risky part (cloudflared spawns a binary, gradio opens a tunnel), so it goes under its own + # guard. `HarborService.start` guards the same pair the same way. + try: + forwarder = make_forwarder(expose) + public_url = forwarder.start(port) + except BaseException: + capture.stop() + raise + print(f"\ncapture :{port} -> {public_url} ({forwarder.name})") + print(f"trials {trials_dir}\n") + + results: list[HarborRolloutResult] = [] + try: + for i in task_indices: + if not 0 <= i < len(task_dirs): + print(f" skip index {i}: out of range (dataset has {len(task_dirs)})") + continue + task_dir = task_dirs[i] + print(f"[{harness} / {sandbox}] task {i}: {task_dir.name} ...", flush=True) + result = await run_rollout( + task_dir=task_dir, + harness=harness, + sandbox=sandbox, + registry=capture.registry, + intercept_url=public_url, + model=model, + trials_dir=trials_dir, + dataset=dataset, + reward_key=reward_key, + keep_sandbox=keep_sandbox, + force_build=force_build, + capture_level=capture_level, + inference=capture.inference, + ) + results.append(result) + print(" " + _summarise(result)) + for finding in result.findings[:3]: + print(f" {finding[:150]}") + finally: + # `capture.stop()` releases the port, so it must run even if the forwarder's own teardown + # throws — otherwise a failing tunnel shutdown strands the proxy for the rest of the process. + try: + forwarder.stop() + finally: + capture.stop() + + print("\n" + _report(results)) + return results + + +def _summarise(r: HarborRolloutResult) -> str: + reward = "None" if r.reward is None else f"{r.reward:.2f}" + mode = "multi-turn" if r.multi_turn else "per-turn" + status = "ok" if r.ok else f"FAILED ({r.exception_type or 'error'})" + # An eval rollout has no trainable tokens by construction, so printing `tokens=0` next to a + # healthy reward invites the reading that capture broke. Name the rollout type instead. + detail = ( + f"tokens={r.n_trainable_tokens:<6}" + if r.rollout_type == "train" + else f"EVAL/{r.capture_level:<7}" + ) + return ( + f"{status:<26} reward={reward:<6} turns={r.n_turns:<3} roots={r.n_roots:<3} " + f"{mode:<11} {detail} atif={r.atif:<9} {r.wall_s:.0f}s" + + (f"\n {r.error[:180]}" if r.error else "") + ) + + +def _report(results: list[HarborRolloutResult]) -> str: + if not results: + return "no rollouts ran" + ok = sum(1 for r in results if r.ok) + graded = [r for r in results if r.reward is not None] + solved = sum(1 for r in graded if r.reward and r.reward > 0) + lines = [ + "=" * 78, + f"capture {ok}/{len(results)} usable", + f"solved {solved}/{len(graded)} graded" + + ( + f" ({len(results) - len(graded)} ungraded — the verifier never ran)" + if len(graded) != len(results) + else "" + ), + ] + if all(r.rollout_type == "eval" for r in results): + lines.append( + f"tokens none — these are EVAL rollouts ({results[0].capture_level}); " + f"{sum(r.n_turns for r in results)} turns captured as trace only" + ) + else: + lines.append( + f"tokens {sum(r.n_trainable_tokens for r in results)} trainable across " + f"{sum(r.n_turns for r in results)} turns" + ) + # Capture quality and task success are independent, and conflating them has burned us before: + # a perfectly captured rollout can score 0 because the model was wrong. + lines.append("NOTE: capture and reward are independent measurements.") + return "\n".join(lines) diff --git a/src/openenv/harbor/seams.py b/src/openenv/harbor/seams.py new file mode 100644 index 0000000000..b31d22bf03 --- /dev/null +++ b/src/openenv/harbor/seams.py @@ -0,0 +1,966 @@ +"""How each Harbor harness is pointed at the intercept server. + +This is the ONLY per-agent knowledge in the stack, and it is deliberately data. For every Harbor +agent the only thing that differs is which env var or config key carries the base URL and the API +key. Sandbox, capture, stitching, masking and validation are identical downstream. + +A seam has: + env env vars set in OUR process. Harbor's installed agents read os.environ at + construction and forward the provider-relevant subset into the sandbox. + model_fmt what to pass as Harbor's `model_name`. Several agents derive their provider from + the prefix, so this is load-bearing rather than cosmetic. + dialect the wire format we expect. Informational: the server detects per request. Recorded + so a surprise in capture is checkable against what we predicted. + kwargs optional (base_url, session, model) -> dict merged into AgentConfig.kwargs, for + agents needing more than env vars. + status what to trust, and the only field a caller should filter on: + + "validated" an end-to-end run passed the capture contract AND the ATIF + cross-check. Safe for cross-model and cross-harness comparison. + "unstable:" runs and grades, but cannot be budgeted -- it ignores the step + cap, so one rollout can consume a sweep. Reachable explicitly; + deliberately absent from the validated set and the UI picker. + "unsupported:" observed to produce unusable results. Worse than an error + when it scores a countable 0.0, because that reads as a weak + model rather than a broken harness. + "blocked:" cannot run here at all -- missing credentials or registry entry. + "untested" no end-to-end run yet, however plausible it looks. + + Measured 2026-09-01/02 over 16,000 rollouts on HuggingEnvs/data-agent-harbor-test: + 10 validated, 2 unstable, 3 unsupported. + +The API key is always the intercept session id. That is the multiplexing scheme: one server, one +port, N concurrent rollouts, each identified by the key its agent was handed. + +Per-harness findings live in README.md (per-harness findings). Add to it as each agent is brought up. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Callable + +# opencode dispatches on the provider *id*, not the npm package: a provider named `openai` is routed +# to `provider.responses(model)` (the OpenAI Responses API), which @ai-sdk/openai-compatible does not +# implement, and the run dies with `Z.responses is not a function`. Any other name stays on +# chat-completions. Must match the prefix in the opencode seam's model_fmt. +OPENCODE_PROVIDER = "intercepted" + +# Must match `install_fixes.PROVIDER`. Duplicated as a literal rather than imported: `pi_agent` imports +# harbor, and pulling that into this module would make the seam table unusable without Harbor +# installed. Kept honest by `tests/test_seams.py`. +PI_PROVIDER = "intercept" + + +def agent_facing_model(served_model: str) -> str: + """The model name to hand a harness, derived from what the engine actually serves. + + THE PROBLEM. A vLLM started without `--served-model-name` serves under its full repo id, e.g. + `Qwen/Qwen3.5-9B`. Every seam then formats that into its own provider prefix and produces a + two-slash name: + + model_fmt="openai/{model}" -> "openai/Qwen/Qwen3.5-9B" + + Harnesses disagree about what that means. gemini-cli requires exactly `provider/model_name` and + rejects anything else ("Model name must be in the format provider/model_name"); cline-cli wants + `provider:model` with a colon; several split on the FIRST slash and several on the LAST, so the + same string resolves to different models depending on the agent. All of them are "working as + documented" — the string is just ambiguous. + + WHY STRIPPING IS SAFE. The harness-facing name and the upstream name are already decoupled: the + intercept overwrites `chat_request["model"]` with the configured served id on every request + (`capture/server.py`), so whatever a harness puts on the wire is replaced before it reaches the + engine. The harness-facing name only has to be something the harness can parse and route to us; + it never has to match the engine. + + So: take the leaf. `Qwen/Qwen3.5-9B` -> `Qwen3.5-9B`, and a name with no slash is unchanged. + This is preferred over relaunching the engine with `--served-model-name` because it works against + an engine you do not control, including a shared or hosted one. + """ + if not served_model or not served_model.strip(): + raise ValueError( + "served model name is empty; the engine reported no model to route to" + ) + leaf = served_model.strip().rstrip("/").rsplit("/", 1)[-1] + if not leaf: + raise ValueError( + f"cannot derive a harness-facing model name from {served_model!r}" + ) + # HF provider routes (for example :together) are meaningful upstream, but + # Harbor's hosted_vllm model parser rejects colons and identifiers >=64 chars. + # The capture server restores the exact served ID; this is only a local alias. + import hashlib + import re + + if len(leaf) >= 64 or re.search(r"[^A-Za-z0-9._-]", leaf): + stem = re.sub(r"[^A-Za-z0-9._-]", "-", leaf)[:54] + digest = hashlib.sha256(leaf.encode()).hexdigest()[:8] + return f"{stem}-{digest}" + return leaf + + +def _pi(base_url: str, session: str, model: str) -> dict[str, Any]: + """pi's provider config, carried to our `InterceptPi` subclass via AgentConfig.kwargs.""" + return { + "intercept_config": {"base_url": base_url, "api_key": session, "model": model} + } + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Seam: + """One harness's wiring. Three channels, in order of preference. + + `agent_env` is the RIGHT one and should be the default choice. It becomes `AgentConfig.env`, + which Harbor injects into the sandbox via `agent_environment.scoped_exec_env(agent.extra_env)` + (trial.py:469 for run, :1212 for setup). Two properties make it strictly better than `env`: + + * it works for ANY installed agent, even one whose own code never forwards that variable. + Harbor's `pi`, for instance, forwards only API keys and has no base-URL handling at all; + `agent_env` reaches it anyway because Harbor sets it on every exec in the agent phase. + * it is scoped to the AGENT PHASES ONLY. The verifier runs outside that `with` block, so + setting OPENAI_API_KEY here cannot reach the grader. + + `env` sets variables in OUR process instead. Needed only where the agent reads them at + construction time (before any sandbox exists). It is dangerous: OPENAI_API_KEY set this way + leaks into the grader, whose LLM-judge tier then 401s and silently scores 0 on every answer that + is correct but not an exact string match. + + `kwargs` becomes `AgentConfig.kwargs`, for agents needing structured config (opencode's provider + block). + """ + + name: str + dialect: str + model_fmt: str = "{model}" + # When set, Harbor builds the agent from this class rather than its registered name. The escape + # hatch for a harness whose config Harbor has no seam to write (see pi). + import_path: str | None = None + agent_env: dict[str, str] = field(default_factory=dict) + env: dict[str, str] = field(default_factory=dict) + kwargs: Callable[[str, str, str], dict[str, Any]] | None = None + # Native harness settings used only when a rollout requests an explicit training policy. + training_kwargs: dict[str, Any] = field(default_factory=dict) + # How THIS harness expresses "stop after N agent steps", if it can. Per-agent knowledge, so it + # belongs here rather than in the caller. + # + # A cap matters for training, not just for cost. AsyncGRPO packs every turn of a rollout into one + # row, and each turn re-sends the whole conversation, so packed length grows with the SQUARE of + # the turn count: a 58-turn rollout is an order of magnitude larger than a 17-turn one and OOMs + # the loss step while every log line looks healthy. Harbor itself has no step cap — only a + # timeout — and an earlier Qwen3-4B run micro-stepped to 451 turns and 10.1M prompt tokens. + step_limit: Callable[[int], dict[str, Any]] | None = None + status: str = "untested" + notes: str = "" + + def resolve( + self, + *, + base_url: str, + session: str, + model: str, + step_limit: int | None = None, + training: bool = False, + ) -> tuple[str, dict[str, Any], dict[str, str], dict[str, str]]: + """-> (model_name, AgentConfig.kwargs, AgentConfig.env, os.environ vars). + + `model` is normalised through `agent_facing_model` first, so a served id like + `Qwen/Qwen3.5-9B` cannot leak an extra slash into a harness's model string. + + A `step_limit` this seam cannot express is WARNED about rather than dropped: silently ignoring + it would let a caller believe its rollouts are bounded when they are not, and the symptom + surfaces much later as an OOM in the loss step. + """ + model = agent_facing_model(model) + fmt = {"base_url": base_url, "session": session, "model": model} + agent_env = {k: v.format(**fmt) for k, v in self.agent_env.items()} + proc_env = {k: v.format(**fmt) for k, v in self.env.items()} + extra = self.kwargs(base_url, session, model) if self.kwargs else {} + if training: + extra = _deep_merge(extra, self.training_kwargs) + if step_limit: + if self.step_limit is None: + logger.warning( + "%s has no native step-limit setting; the capture proxy enforces the " + "model-call budget and the agent timeout still applies", + self.name, + ) + else: + extra = _deep_merge(extra, self.step_limit(step_limit)) + return self.model_fmt.format(model=model), extra, agent_env, proc_env + + +def _deep_merge(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]: + """Merge nested config without clobbering a sibling key. + + A shallow update of `{"config": {...}}` would replace a seam's whole config block with the step + limit alone, which is how a base_url quietly disappears. + """ + merged = dict(base) + for key, value in extra.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def _mini_swe_agent_step_limit(limit: int) -> dict[str, Any]: + """mini-swe-agent takes a YAML config; Harbor's wrapper accepts it as a `config` mapping and dumps + it (`harbor/agents/installed/mini_swe_agent.py`). `agent.step_limit` is the key it reads.""" + return {"config": {"agent": {"step_limit": limit}}} + + +def _grok_build(base_url: str, session: str, model: str) -> dict[str, Any]: + """Use Harbor's supported config merge, including all auxiliary model routes.""" + return { + "grok_config": { + "models": { + key: model + for key in ( + "default", + "session_summary", + "image_description", + "web_search", + ) + }, + "model": { + model: { + "name": model, + "model": model, + "base_url": f"{base_url}/v1", + "env_key": "OPENAI_API_KEY", + "api_backend": "chat_completions", + } + }, + } + } + + +def _opencode(base_url: str, session: str, model: str) -> dict[str, Any]: + """opencode needs a full provider block; Harbor exposes exactly the right seam for it. + + Harbor writes `provider..options.baseURL` and nothing else (opencode.py:440-447), leaving + opencode to resolve the model through its built-in provider against the models.dev registry. A + locally-served model is not in that registry, so opencode emits step-start/step-finish with zero + tokens and never issues a request: a silent no-op, the worst kind to debug. + + So we supply the provider block outright. `opencode_config` deep-merges LAST (opencode.py:453), + overriding Harbor's generated block without patching Harbor. + """ + return { + "opencode_config": { + "provider": { + OPENCODE_PROVIDER: { + "npm": "@ai-sdk/openai-compatible", + "name": "Harbor Intercept", + "options": { + "baseURL": f"{base_url}/v1", + "apiKey": session, + "timeout": 600_000, + }, + "models": {model: {"name": model}}, + } + } + } + } + + +def acp_opencode_config(base_url: str, session: str, model: str) -> dict[str, Any]: + """Explicit ACP qualification profile using OpenCode's native ACP command. + + Qualifies Harbor's ACP transport with this pinned implementation only; + it does not imply that arbitrary ACP registry entries support the proxy. + """ + import json + + config = _opencode(base_url, session, model)["opencode_config"] + config.update( + model=f"{OPENCODE_PROVIDER}/{model}", + small_model=f"{OPENCODE_PROVIDER}/{model}", + permission="allow", + autoupdate=False, + ) + return { + "auth_policy": "disabled", + "registry_entry": { + "id": "opencode", + "name": "OpenCode (ACP qualification profile)", + "version": "1.18.30", + "description": "OpenCode native ACP, routed through OpenEnv capture", + "distribution": { + "npx": { + "package": "opencode-ai@1.18.30", + "args": ["acp"], + "env": {"OPENCODE_CONFIG_CONTENT": json.dumps(config)}, + } + }, + }, + } + + +def _terminus(base_url: str, session: str, model: str) -> dict[str, Any]: + """Terminus runs host-side, in OUR process, so there is no sandbox-side CLI to configure. + + It drives a TmuxSession over environment.exec and calls litellm directly: `api_base` is a + constructor argument. The API key is not; LiteLLM collects surplus kwargs into `_llm_kwargs` and + splats them into the call (lite_llm.py:77,649), so `llm_kwargs` is the channel. + + `model_info` is mandatory, not optional: litellm refuses to route a model name it has no + cost/context metadata for, and a locally-served name is never in its registry. + + Summarisation off: `Terminus2.run` appends subagent rollout details to the main ones + (terminus_2.py:1615), and a summariser is a different task. Training its turns with this + rollout's reward is exactly the contamination to avoid. Compaction also breaks prefix stitching. + """ + return { + "api_base": f"{base_url}/v1", + "llm_kwargs": {"api_key": session}, + "model_info": { + "max_input_tokens": 65536, + "max_output_tokens": 8192, + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + }, + "collect_rollout_details": True, # native capture, as a second cross-check + "enable_summarize": False, + "proactive_summarization_threshold": 0, + } + + +SEAMS: dict[str, Seam] = { + # --- priority order for bring-up --------------------------------------- + "opencode": Seam( + name="opencode", + dialect="openai_chat", + model_fmt=OPENCODE_PROVIDER + "/{model}", + # NO env vars. The provider block carries baseURL and apiKey, and setting OPENAI_API_KEY + # here actively breaks grading: Harbor forwards it into the sandbox, where the DataAgent + # grader's tier-3 LLM judge runs `if os.environ.get("OPENAI_API_KEY")` and gets our session + # id instead of a real key. It 401s, the judge is skipped, and every answer that is right + # but not exact-match silently scores 0. Reward corruption, not a crash. + # Naming the provider something other than `openai` also stops Harbor forwarding the key at + # all (agents/installed/opencode.py:516), which is the general trick: alias the provider and + # the grader's key survives untouched. + env={}, + kwargs=_opencode, + # Reuse the DataAgent training profile: no delegation, interactive questions or web fetch. + # ATIF omits nested OpenCode sessions, so those rollouts cannot be attributed for training. + training_kwargs={ + "opencode_config": { + "tools": {"task": False, "question": False, "webfetch": False} + } + }, + status="validated", + notes="Needed 3 global server fixes: SSE replay, stream_options strip, session-id priority.", + ), + "pi": Seam( + name="pi", + status="validated", + dialect="openai_chat", + # Harbor's pi wrapper cannot express a custom endpoint at all, so this seam supplies the + # agent CLASS instead: a local subclass that writes ~/.pi/agent/models.json in setup(). + # See harnesses/pi_agent.py. Harbor stays unmodified. + import_path="openenv.harbor.install_fixes:InterceptPi", + # pi requires `provider/model`; the provider must match the one in models.json. + model_fmt=PI_PROVIDER + "/{model}", + kwargs=_pi, + notes="No base-URL seam in Harbor's wrapper; needs a models.json written into the sandbox. " + "Defaults to the Responses API unless api=openai-completions is pinned.", + ), + "claude-code": Seam( + name="claude-code", + status="validated", + dialect="anthropic", + # No /v1 suffix: the Anthropic SDK appends its own path. + # ANTHROPIC_* does not collide with the grader (which reads OPENAI_API_KEY), so the process + # channel is safe here; agent_env is set too because it is what actually reaches the sandbox. + agent_env={ + "ANTHROPIC_BASE_URL": "{base_url}", + "ANTHROPIC_API_KEY": "{session}", + }, + env={"ANTHROPIC_BASE_URL": "{base_url}", "ANTHROPIC_API_KEY": "{session}"}, + notes="Calls /v1/messages/count_tokens (handled as an aux route). Injects an env/time block " + "in its system prompt: watch n_roots for nonce breakage.", + ), + "codex": Seam( + name="codex", + status="validated", + dialect="openai_responses", + # Key goes ONLY through agent_env: in the process env it would overwrite the grader's + # OPENAI_API_KEY and silently disable the LLM-judge tier. + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + # codex prefers a Responses WEBSOCKET transport, which the capture proxy does not serve, and + # only falls back to HTTPS after five failed upgrades — by which point it has abandoned the + # task. Not worked around here: `supports_websockets=false` needs a CUSTOM provider, since + # codex refuses to override a built-in one ("model_providers contains reserved built-in + # provider IDs: `openai`"), and a custom provider needs its base_url threaded through six + # chained -c overrides. Affects hosted OpenAI only; every other upstream is fine. + notes="Responses dialect: exercises a different transform than chat-completions.", + ), + "gemini-cli": Seam( + name="gemini-cli", + status="validated", + dialect="google", + # Requires `provider/model` (gemini_cli.py:781) or it raises before install even matters: + # ValueError: Model name must be in the format provider/model_name + # The first failure here LOOKED like an nvm install problem because the job log echoes the + # install command; the run never got that far. Read result.json's exception_info, not the log. + model_fmt="google/{model}", + # Harbor's wrapper declares only `curl` as a system dep, but nvm's installer pipes into + # bash, so in an image without bash it fails with a misleading "NVM failed to load". + # The subclass adds bash and otherwise defers to Harbor. See harnesses/install_fixes.py. + import_path="openenv.harbor.install_fixes:InterceptGeminiCli", + agent_env={ + "GOOGLE_GEMINI_BASE_URL": "{base_url}", + "GEMINI_API_KEY": "{session}", + "GOOGLE_API_KEY": "{session}", + }, + env={ + "GOOGLE_GEMINI_BASE_URL": "{base_url}", + "GEMINI_API_KEY": "{session}", + "GOOGLE_API_KEY": "{session}", + }, + notes="generateContent dialect; key arrives as x-goog-api-key. Model is carried in the URL " + "path rather than the body: expect that to be the first thing to break.", + ), + # --- second wave -------------------------------------------------------- + "terminus-2": Seam( + name="terminus-2", + status="validated", + dialect="openai_chat", + model_fmt="hosted_vllm/{model}", + kwargs=_terminus, + notes="Host-side agent: no sandbox involved in LLM traffic. Also emits RolloutDetail.", + ), + "openhands": Seam( + name="openhands", + dialect="openai_chat", + model_fmt="openai/{model}", + # Harbor installs `openhands-ai` unpinned and verifies with `python -m openhands.core.main`, + # but V1 moved the core out to openhands-sdk, so latest fails with + # `ModuleNotFoundError: No module named 'openhands.core'`. The subclass pins the last V0 + # release (0.49.0). See harnesses/install_fixes.py. + import_path="openenv.harbor.install_fixes:InterceptOpenHands", + # OpenHands does NOT use OPENAI_*. It reads LLM_MODEL / LLM_BASE_URL / LLM_API_KEY, all via + # `_get_env` (openhands.py:873,920,931), so agent_env reaches them. LLM_MODEL is taken from + # model_name directly, and litellm needs the provider prefix to route. + agent_env={"LLM_BASE_URL": "{base_url}/v1", "LLM_API_KEY": "{session}"}, + notes="LLM_* env vars, not OPENAI_*. Harbor has a 'dummy-key-for-local-vllm' fallback.", + ), + "mini-swe-agent": Seam( + name="mini-swe-agent", + status="validated", + dialect="openai_chat", + model_fmt="openai/{model}", + # Needs `provider/model`. It reads MSWEA_API_KEY, and otherwise derives the key variable from + # the model name via litellm (`openai/` -> OPENAI_API_KEY). Base URL goes to litellm, which + # accepts OPENAI_API_BASE or OPENAI_BASE_URL; set both, the unused one is harmless. + # All through agent_env so the grader's OPENAI_API_KEY is never touched. + agent_env={ + "MSWEA_API_KEY": "{session}", + "OPENAI_API_KEY": "{session}", + "OPENAI_API_BASE": "{base_url}/v1", + "OPENAI_BASE_URL": "{base_url}/v1", + }, + step_limit=_mini_swe_agent_step_limit, + notes="MSWEA_API_KEY plus a model-derived key var; litellm under the hood.", + ), + "qwen-coder": Seam( + name="qwen-coder", + status="validated", + dialect="openai_chat", + # Cleanest seam of the lot: declarative EnvVar descriptors for api_key -> OPENAI_API_KEY and + # base_url -> OPENAI_BASE_URL with env_fallback (qwen_code.py:39-45), then passed explicitly + # as --openai-api-key / --openai-base-url. agent_env feeds `_get_env` directly. + agent_env={"OPENAI_API_KEY": "{session}", "OPENAI_BASE_URL": "{base_url}/v1"}, + ), + "swe-agent": Seam( + name="swe-agent", + status="unstable:unbounded-turns", + dialect="openai_chat", + model_fmt="openai/{model}", + agent_env={"OPENAI_API_KEY": "{session}", "OPENAI_BASE_URL": "{base_url}/v1"}, + # Harbor's repo argument has a quoting bug in its else-branch (`'--env.repo.path=$(pwd)'` + # inside single quotes), giving `git.exc.NoSuchPathError: /workdir/$(pwd)`. The subclass makes + # /workdir a git repo and exposes it as /testbed so Harbor takes its WORKING branch. + import_path="openenv.harbor.install_fixes:InterceptSweAgent", + # Second bug, and the one that made swe-agent produce exactly ONE turn per task while every + # capture check passed. swe-agent asks litellm to cost each call; litellm has no pricing row + # for a locally-served name and raises, which swe-agent treats as fatal: + # + # sweagent.exceptions.ModelConfigurationError: Error calculating cost: + # This model isn't mapped yet. model=openai/Qwen3.5-9B ... + # please make sure you set `per_instance_cost_limit` and `total_cost_limit` to 0 + # + # Harbor already sets exactly these to 0, but only under `if is_hosted_vllm:` (swe_agent.py + # :437-443), and our model_fmt is `openai/` so that branch never runs. They are declared + # CLI_FLAGS, so passing them as kwargs reaches the same flags. `build_cli_flags` skips only + # None (base.py:651), so "0" is emitted rather than dropped as falsy. + kwargs=lambda base_url, session, model: { + "per_instance_cost_limit": "0", + "total_cost_limit": "0", + "max_input_tokens": "0", + }, + notes=( + "Works, but IGNORES the step cap: 12 requested, 37-45 turns run. Cost " + "cannot be bounded, so it is unsafe in a sized sweep. Also needs a git " + "repo, which DataAgent tasks are not." + ), + ), + "goose": Seam( + name="goose", + status="unsupported:erratic-cost", + dialect="openai_chat", + model_fmt="openai/{model}", + # goose is the one that reads `os.environ.get("OPENAI_API_KEY")` DIRECTLY (goose.py:678) and + # raises if unset, so agent_env cannot reach it and the process env is forced. That disables + # the grader's LLM-judge tier for goose runs; exact-match and numeric tolerance still apply. + # `runner.apply_seam_env` warns when this happens so it is never a silent reward change. + env={"OPENAI_API_KEY": "{session}", "OPENAI_BASE_URL": "{base_url}/v1"}, + agent_env={"OPENAI_BASE_URL": "{base_url}/v1"}, + notes=( + "Unbudgetable: 6 turns on one rollout and 347 on another of the SAME task. " + "A sweep cannot be sized when one harness can consume 50x its expected " + "wall-clock." + ), + ), + # --- tier 2: seams derived from each wrapper, none validated yet ---------- + # These follow the same two shapes seen everywhere: a key + base URL pair, delivered through + # `_get_env` (so agent_env works) or `os.environ` (so the process env is forced). Where the + # wrapper reads os.environ directly, both channels are set and the grader warning fires. + # hermes: REMOVED, not merely untested. Measured across 5 DataAgent tasks it failed 5/5 before the + # agent started — `exit 127` from + # `curl -fsSL .../NousResearch/hermes-agent/main/scripts/install.sh | bash` — so every attempt cost + # a sandbox and several minutes to learn nothing. The capture layer reported it correctly ("the + # intercept saw no model calls: the agent never reached it"), which is how it was found. The + # `InterceptHermes` subclass is gone with it; re-adding both needs the install to work first. + "vibe": Seam( + name="vibe", + status="validated", + dialect="openai_chat", + model_fmt="openai/{model}", + # vibe defaults to Mistral's API unless VIBE_API_BASE or OPENAI_BASE_URL is set (vibe.py:76-96), + # and resolves its key from the var named by VIBE_API_KEY_ENV. + # Its own error names the valid values: + # ValueError: Unknown Vibe backend 'openai'; valid backends are 'mistral' and 'generic' + # (use 'generic' for any OpenAI-compatible endpoint) + agent_env={ + "VIBE_API_BASE": "{base_url}/v1", + "OPENAI_BASE_URL": "{base_url}/v1", + "VIBE_API_KEY_ENV": "OPENAI_API_KEY", + "OPENAI_API_KEY": "{session}", + "VIBE_BACKEND": "generic", + }, + ), + "openclaw": Seam( + name="openclaw", + status="unsupported:launch-failure", + dialect="openai_chat", + model_fmt="openai/{model}", + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + # Harbor writes a merged config to openclaw.upload.json and copies it into the sandbox, but + # only when there IS a config. With none, setup dies on + # cp: cannot stat '/logs/agent/openclaw.upload.json' + # A provider block gives it something to write AND points openclaw at us. + # Schema is `models.providers.`, NOT a top-level `providers`. A top-level one is + # accepted by Harbor's merge and then rejected by openclaw itself: + # OpenClaw config is invalid + # Problem: - : Invalid input + # Harbor's own `_align_provider_models` reads cfg["models"]["providers"][provider] and fills + # in a `models` array beside `baseUrl`, which is the shape mirrored here. The provider name + # must match the model prefix so `_model_provider()` resolves it. + kwargs=lambda base_url, session, model: { + "openclaw_config": { + "models": { + "providers": { + "openai": { + "baseUrl": f"{base_url}/v1", + "apiKey": session, + "api": "openai-completions", + "models": [{"id": model, "name": model}], + } + } + } + }, + # Harbor defaults `--thinking high`, which openclaw rejects for a custom provider: + # Error: Thinking level "high" is not supported for openai/Qwen3.5-9B. Use one of: off. + # It is a CliFlag, so kwargs can override it. Also correct for us: we serve with thinking + # disabled, so anything else would be asking for a mode the model is not running in. + "thinking": "off", + }, + # Harbor writes the config to the HOST logs dir then copies it from the CONTAINER path, + # which assumes a bind mount. E2B has none, so the subclass uploads it into the sandbox. + import_path="openenv.harbor.install_fixes:InterceptOpenClaw", + notes=( + "`nvm use 22 && openclaw agent --local ...` exits 1 and the agent never " + "makes a model call, so a suite's missing-answer default scores it a " + "countable 0.0 -- worse than an error." + ), + ), + "kimi-cli": Seam( + name="kimi-cli", + status="unsupported:no-reward", + notes=( + "Reached 14 turns then returned no reward at all (`rewards={}`) on both " + "probe tasks, so nothing it produces is scorable." + ), + dialect="openai_chat", + model_fmt="openai/{model}", + # Harbor DELIBERATELY unsets OPENAI_BASE_URL / OPENAI_API_KEY before spawning kimi + # (`_KIMI_ENV_OVERRIDES_TO_NEUTRALIZE`), because kimi-cli's own + # `augment_provider_with_env_vars` silently overrides its config file from those vars — a + # globally-injected OpenAI key would hijack an OpenRouter run (MoonshotAI/kimi-cli#1165). + # So an env-var seam is not merely ignored here, it is actively erased. That is why every + # attempt showed 0 turns. + # + # The config file is what wins, and Harbor exposes both values as plain constructor kwargs: + # base_url = self._base_url or pcfg["base_url"] (kimi_cli.py:208) + # api_key = self._api_key or (kimi_cli.py:169) + kwargs=lambda base_url, session, model: { + "base_url": f"{base_url}/v1", + "api_key": session, + }, + # Second, separate bug. Harbor's run command ends with `kill 0`, which takes the E2B exec + # stream down along with the process group and raises RemoteProtocolError(StreamReset) in + # OUR process. That killed all 11 kimi trials AFTER the agent had finished its work, so the + # verifier never ran and every reward came back None. The subclass swallows only that one + # error, mirroring Harbor's own handling of the exit-143 half of the same teardown. + import_path="openenv.harbor.install_fixes:InterceptKimi", + ), + "mimo": Seam( + # mimo is an opencode fork and inherits its provider dispatch, so it inherits the same trap: + # a provider literally named `openai` routes to `provider.responses(model)`, which + # @ai-sdk/openai-compatible does not implement, and the run dies with + # TypeError: Z.responses is not a function + # having never reached the intercept (0 turns). + # + # Harbor tries to avoid this: it writes `npm: @ai-sdk/openai-compatible` into the provider + # block, but only when it finds a base URL, and it looks in OUR PROCESS env + # (`os.environ.get(f"{provider.upper()}_BASE_URL")`, mimo.py:389) rather than in the sandbox + # env we set. `agent_env` never reaches os.environ, so that branch does not run and mimo + # falls back to its built-in openai provider. Same shape as the goose problem. + # + # Fixed the way opencode is fixed, and for the same reason: a provider id that is NOT + # "openai", with the block supplied outright via the `mimo_config` kwarg. That deep-merges + # LAST (mimo.py:402), so it wins without patching Harbor and without putting OPENAI_API_KEY + # in our process env where the verifier's LLM judge would inherit it. + name="mimo", + dialect="openai_chat", + model_fmt=OPENCODE_PROVIDER + "/{model}", + kwargs=lambda base_url, session, model: { + "mimo_config": { + "provider": { + OPENCODE_PROVIDER: { + "npm": "@ai-sdk/openai-compatible", + "name": "Harbor Intercept", + "options": { + "baseURL": f"{base_url}/v1", + "apiKey": session, + "timeout": 600_000, + }, + "models": {model: {"name": model}}, + } + } + } + }, + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + ), + "trae-agent": Seam( + # RESPONSES, not chat. Mislabelled `openai_chat` for a night because the config says + # `provider: openai` and everything else with that label speaks chat. The access log settled + # it: exactly one `POST /v1/responses` against 465 chat-completions calls, and that one was + # trae. Its client reads `usage.input_tokens_details.cached_tokens`, a Responses-only field. + name="trae-agent", + status="unstable:unbounded-turns", + notes=( + "Works, but IGNORES the step cap: 12 requested, 112-200 turns run -- ~20x " + "the stable harnesses. One rollout can consume a whole sweep's budget." + ), + dialect="openai_responses", + model_fmt="openai/{model}", + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + ), + # --- remaining ATIF agents ------------------------------------------------ + # Goal is every ATIF-capable agent Harbor registers (25). These nine had no seam; the ones with a + # recognisable LLM config get one, and the vendor-service ones are attempted anyway so their block + # reason is RECORDED rather than assumed. + "computer-1": Seam( + name="computer-1", + dialect="openai_chat", + model_fmt="hosted_vllm/{model}", + # Host-side like terminus-2: drives litellm from our process with an `api_base` kwarg, so + # there is no sandbox-side CLI to configure. + kwargs=lambda base_url, session, model: { + "api_base": f"{base_url}/v1", + "llm_kwargs": {"api_key": session}, + "model_info": { + "max_input_tokens": 262144, + "max_output_tokens": 8192, + "input_cost_per_token": 0.0, + "output_cost_per_token": 0.0, + }, + }, + notes="Host-side agent, litellm. The other RolloutDetail emitter besides terminus-2.", + ), + "eve": Seam( + name="eve", + dialect="openai_chat", + model_fmt="openai/{model}", + # NOT a general coding agent. `_validate_path` requires a local Eve PROJECT directory with + # package.json plus an agent/ dir (or flat agent.ts / instructions.md), and raises before any + # sandbox work -- the 7s failure with empty Harbor logs. Nothing to do with the intercept. + status="blocked:needs-eve-project", + # Reads OPENAI_BASE_URL / OPENAI_ENDPOINT / OPENAI_API_KEY. + agent_env={ + "OPENAI_BASE_URL": "{base_url}/v1", + "OPENAI_ENDPOINT": "{base_url}/v1", + "OPENAI_API_KEY": "{session}", + }, + env={"OPENAI_BASE_URL": "{base_url}/v1"}, + ), + "cursor-cli": Seam( + name="cursor-cli", + dialect="openai_chat", + model_fmt="openai/{model}", + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + status="blocked:credentials", + # CONFIRMED by running it, not assumed: + # ValueError: CURSOR_API_KEY environment variable is required. + # It authenticates against Cursor's own service before any model call, so pointing it at a + # local endpoint cannot help. Nothing about the intercept is implicated. + notes="BLOCKED: requires CURSOR_API_KEY (Cursor account). Verified, not assumed.", + ), + "acp": Seam( + name="acp", + dialect="openai_chat", + model_fmt="openai/{model}", + # A LAUNCHER for ACP-speaking agents, not an agent. Needs `registry_entry` / + # `registry_entry_path` describing a distribution ("ACP registry entry must define at least + # one distribution"), and raises immediately without one. + status="blocked:needs-registry-entry", + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + notes="Agent Client Protocol runner; needs an ACP-speaking agent configured underneath.", + ), + "devin": Seam( + name="devin", + dialect="openai_chat", + model_fmt="openai/{model}", + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + notes="Cognition hosted service; expected to need vendor credentials.", + ), + "copilot-cli": Seam( + name="copilot-cli", + dialect="openai_chat", + model_fmt="openai/{model}", + agent_env={ + "COPILOT_PROVIDER_TYPE": "openai", + "COPILOT_PROVIDER_BASE_URL": "{base_url}/v1", + "COPILOT_PROVIDER_API_KEY": "{session}", + "COPILOT_MODEL": "{model}", + "COPILOT_OFFLINE": "true", + }, + notes="Native BYOK provider configuration routes model requests through capture; no GitHub model service required.", + ), + "antigravity-cli": Seam( + name="antigravity-cli", + dialect="google", + model_fmt="google/{model}", + agent_env={ + "GOOGLE_GEMINI_BASE_URL": "{base_url}", + "GEMINI_API_KEY": "{session}", + "GOOGLE_API_KEY": "{session}", + "AGY_ADC_AUTH": "false", + }, + notes="Harbor's native Gemini base-URL configuration and custom model registration route requests through capture.", + ), + "grok-build": Seam( + name="grok-build", + dialect="openai_chat", + model_fmt="openai/{model}", + agent_env={"OPENAI_API_KEY": "{session}"}, + kwargs=_grok_build, + notes="Harbor grok_config points the primary and auxiliary models to capture using its supported custom endpoint configuration.", + ), + "rovodev-cli": Seam( + name="rovodev-cli", + dialect="openai_chat", + model_fmt="openai/{model}", + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + notes="Atlassian; needs ROVODEV_USER_API_TOKEN + ROVODEV_USER_EMAIL.", + ), + # Found only via Harbor's SUPPORTS_ATIF flag; an import grep missed all four. + "cline-cli": Seam( + name="cline-cli", + dialect="openai_chat", + # COLON, not slash. Its own error is explicit: + # ValueError: model_name must be in format 'provider:model-id', got: 'openai/Qwen3.5-9B' + # The only harness so far that does not use `provider/model`. + model_fmt="openai:{model}", + agent_env={"OPENAI_BASE_URL": "{base_url}/v1", "OPENAI_API_KEY": "{session}"}, + # Harbor gives cline NO base-URL channel (only PROVIDER/API_KEY/MODELID), so it calls the + # real OpenAI. The subclass merges Cline's own settings store between Harbor's write and the + # run. See harnesses/install_fixes.py. + import_path="openenv.harbor.install_fixes:InterceptCline", + kwargs=lambda base_url, session, model: { + "intercept_config": { + "base_url": base_url, + "api_key": session, + "model": model, + } + }, + ), + "nemo-agent": Seam( + name="nemo-agent", + dialect="openai_chat", + model_fmt="openai/{model}", + # Defaults to `llm_type: "nim"` (NVIDIA NIM), so it never reads OPENAI_* and the OpenAI seam + # was silently ignored. Its own module docstring gives the recipe: + # harbor run --agent nemo-agent --model openai/gpt-4o --ak llm_type=openai + # `--ak` is AgentConfig.kwargs, so this selects the OpenAI-compatible provider. + kwargs=lambda base_url, session, model: {"llm_type": "openai"}, + agent_env={ + "OPENAI_BASE_URL": "{base_url}/v1", + "OPENAI_API_KEY": "{session}", + "OPENAI_API_BASE": "{base_url}/v1", + }, + ), + "openhands-sdk": Seam( + name="openhands-sdk", + status="validated", + dialect="openai_chat", + model_fmt="openai/{model}", + # The V1 SDK packaging, so it should NOT need the V0 pin the classic wrapper does. + agent_env={ + "LLM_BASE_URL": "{base_url}/v1", + "LLM_API_KEY": "{session}", + "OPENAI_BASE_URL": "{base_url}/v1", + "OPENAI_API_KEY": "{session}", + }, + ), + "antigravity-sdk": Seam( + name="antigravity-sdk", + dialect="google", + model_fmt="google/{model}", + # It said so itself: `GEMINI_API_KEY environment variable must be set`. My first seam gave it + # only OPENAI_*, which is why it looked like a vendor-credential block when it is really a + # Google-family agent. Same seam as gemini-cli, which is validated. + agent_env={ + "GOOGLE_GEMINI_BASE_URL": "{base_url}", + "GEMINI_API_KEY": "{session}", + "GOOGLE_API_KEY": "{session}", + }, + env={ + "GOOGLE_GEMINI_BASE_URL": "{base_url}", + "GEMINI_API_KEY": "{session}", + "GOOGLE_API_KEY": "{session}", + }, + notes="Google Antigravity SDK; takes the gemini-cli seam, not an OpenAI one.", + ), +} + +# Every ATIF-capable agent Harbor registers. This IS the goal. +# +# Source of truth is Harbor's own `SUPPORTS_ATIF` class flag, not a grep for trajectory imports. +# The grep undercounted (25 vs 29): it missed antigravity-sdk, cline-cli, nemo-agent and +# openhands-sdk, which build ATIF without importing the models in a way grep could see. Re-derive with: +# +# getattr(import_class(AgentFactory._AGENT_MAP[a], label="agent"), "SUPPORTS_ATIF", False) +# +# computer-1 is deliberately excluded: not needed for this goal. +# Every agent whose Harbor class sets SUPPORTS_ATIF, minus two deliberate exclusions. +# +# `computer-1` is out of scope by request. +# +# `openhands` (V0) is out because Harbor registers it and `openhands-sdk` as two SEPARATE agents, +# not as old and new names for one. V0 bundles the full `openhands-ai` with its own Docker runtime; +# the SDK runs directly in the container. Running a Docker runtime inside a sandbox that is already +# a container is the wrong shape for this work, and the SDK validated 5/5 with zero fixes while V0 +# needed four packaging patches and still did not come through. Its Seam and InterceptOpenHands +# subclass are kept so `--agents openhands` still works; it is simply not a target. +# `nemo-agent` is out as well. Harbor generates its NAT config as +# `workflow: {_type: chat_completion}`: a single-shot LLM call with no tools and no loop, so ONE +# turn is correct behaviour, not a failure. Making it agentic needs a react_agent workflow plus +# NAT's `code_execution` tool, which requires a separate sandbox service (docker or Piston) that +# would not even have the task's CSV. Bring-your-own-workflow, like eve and acp. +# `antigravity-sdk` is out too. Its Go `localharness` binary receives a correctly-translated +# functionCall (verified by direct probe) and then ends the conversation without executing it, the +# SDK's "Received tool call %s but no tool runner is configured. Yielding to user." path. That is +# inside google-antigravity, and Harbor's runner sets the root logger to ERROR so the warning that +# would confirm it never reaches a log. Seam kept; not a target. +ATIF_AGENTS = [ + "acp", + "antigravity-cli", + "claude-code", + "cline-cli", + "codex", + "copilot-cli", + "cursor-cli", + "devin", + "eve", + "gemini-cli", + "goose", + "grok-build", + "kimi-cli", + "mimo", + "mini-swe-agent", + "openclaw", + "opencode", + "openhands-sdk", + "qwen-coder", + "rovodev-cli", + "swe-agent", + "terminus-2", + "trae-agent", + "vibe", +] + +PRIORITY = [ + "opencode", + "pi", + "claude-code", + "codex", + "gemini-cli", + "terminus-2", + "openhands-sdk", + "mini-swe-agent", +] + + +def get(name: str, *, profile: str | None = None) -> Seam: + if name not in SEAMS: + raise KeyError(f"no seam for {name!r}. Known: {sorted(SEAMS)}") + if profile is None: + return SEAMS[name] + if name == "acp" and profile == "opencode-1.18.30": + from dataclasses import replace + + return replace( + SEAMS[name], + model_fmt=OPENCODE_PROVIDER + "/{model}", + kwargs=acp_opencode_config, + ) + if name == "nemo-agent" and profile == "shell-1.9.0": + from dataclasses import replace + from pathlib import Path + + package = ( + Path(__file__).resolve().parents[3] / "examples/harbor/nemo_shell_profile" + ) + if not (package / "pyproject.toml").is_file(): + raise ValueError( + "NeMo shell profile requires the example workflow package in this checkout" + ) + return replace( + SEAMS[name], + import_path="openenv.harbor.nemo_profile:NemoShellProfile", + kwargs=lambda base_url, session, model: { + "llm_type": "openai", + "version": "1.9.0", + "workflow_package": str(package), + }, + ) + raise ValueError(f"unsupported harness profile {profile!r} for {name!r}") diff --git a/src/openenv/harbor/serving.py b/src/openenv/harbor/serving.py new file mode 100644 index 0000000000..6c9cb2145f --- /dev/null +++ b/src/openenv/harbor/serving.py @@ -0,0 +1,324 @@ +"""Serve Harbor tasks: Task API, MCP rollouts, and a human UI. + +Locally this is two ports: + + :port env server Task API + MCP + UI (faces the trainer / a browser) + :capture_port capture proxy (faces the sandbox, published) + +Two ports on purpose. The sandbox is off-cluster and must reach the capture proxy over a public URL; +the env server has no business being publicly reachable, and sharing one port would expose it as +soon as the capture proxy became reachable. + +On a hosted platform that inverts. A Space gets exactly one public URL and exposes one port, so +there is no second port to publish and nothing to forward. The capture app is mounted onto the env +server's own app at `CAPTURE_MOUNT` instead, and the sandbox reaches it at `/capture`. +The proxy still refuses unregistered callers, which is what keeps a public mount from becoming an +open relay. +""" + +from __future__ import annotations + +import os +import secrets +import threading +from typing import Any + +from openenv.core.harness.capture import CaptureServer + +# Where the capture app is mounted when the env server hosts it directly. +CAPTURE_MOUNT = "/capture" + + +def space_public_url() -> str: + """The public URL of the Space this process is running in, or `""` when it is not on one. + + Returns: + `str`: e.g. `https://owner-name.hf.space`, with no trailing slash. + """ + host = os.environ.get("SPACE_HOST", "").strip() + if host: + return "https://" + host.rstrip("/").removeprefix("https://").removeprefix( + "http://" + ) + # SPACE_HOST is the direct answer, but SPACE_ID is the variable that is always set, so derive + # the hostname the same way `auto.auto_env` does. + space_id = os.environ.get("SPACE_ID", "").strip() + if space_id and "/" in space_id: + slug = space_id.replace("/", "-").replace("_", "-").replace(".", "-").lower() + return f"https://{slug}.hf.space" + return "" + + +class HarborService: + """Long-lived state for a serving process: capture proxy, forwarding, datasets. + + Held at module scope by `serve_harbor` so that the environment instances OpenEnv builds + per-request can reach it. They must not own it: `/metadata` and `/schema` construct a throwaway + environment on every call, so anything expensive on `__init__` would be paid per docs hit. + """ + + _instance: "HarborService | None" = None + + def __init__( + self, + *, + llm_url: str = "", + model: str = "", + datasets: list[str], + capture_port: int = 8100, + expose: str = "gradio", + api_key: str | None = None, + auth_header: str = "Authorization", + provider: str = "openai", + capture_level: str = "tokens", + max_output_tokens: int | None = 8192, + ) -> None: + # The management routes are the trainer's control plane; the proxy route is the agent's data + # plane. Published or mounted, both are reachable by anyone who has the URL, so the control + # plane gets its own key — minted here rather than configured, because nothing outside this + # process needs to know it and an operator who has to invent one will skip it. + # Honours $OPENENV_CAPTURE_ADMIN_KEY so an operator who wants to call the management routes + # can choose the key; otherwise a random one, which locks the routes without putting a secret + # nobody asked for into the logs. + self.admin_key = os.environ.get( + "OPENENV_CAPTURE_ADMIN_KEY" + ) or secrets.token_urlsafe(24) + self.llm_url = llm_url + self.model = model + self.datasets = datasets + self.capture_level = "text" if provider == "anthropic" else capture_level + self.capture = CaptureServer( + llm_url=llm_url, + model=model, + port=capture_port, + max_output_tokens=max_output_tokens, + api_key=api_key, + auth_header=auth_header, + provider=provider, + capture_level=capture_level, + admin_key=self.admin_key, + ) + self._expose_kind = expose + self.public_url = "" + self.mounted = False + self._forwarder: Any = None + self._lock = threading.Lock() + + def start(self) -> str: + """Make the capture proxy reachable from the sandbox and return its public URL. + + Two different situations, and conflating them is what makes the hosted case awkward: + + - **Hosted** (a Space). The platform already gives this process one public URL and exposes + exactly one port. So the capture app is mounted onto the env server's own app under + `CAPTURE_MOUNT` and reached at `/capture`. No second port, no forwarding, nothing + for the platform to object to. + - **Local.** The sandbox runs off-cluster and cannot reach `127.0.0.1`, so the capture port + is published by whichever forwarder was selected. + """ + public = space_public_url() + if public: + # The env server's app serves it; `build_app` performs the mount. + self.mounted = True + self.public_url = f"{public}{CAPTURE_MOUNT}" + return self.public_url + + from openenv.core.harness.capture.forwarding import make_forwarder + + self.capture.start() + # A half-started service is worse than a failed one: the capture server owns a port and a + # background thread, so leaving it up after the forwarder fails makes the next attempt fail + # too, on a port conflict that has nothing to do with the real error. + try: + self._forwarder = make_forwarder(self._expose_kind) + self.public_url = self._forwarder.start(self.capture.port) + except BaseException: + self._forwarder = None + self.capture.stop() + raise + return self.public_url + + def stop(self) -> None: + """Tear both halves down, even if the first half refuses to go. + + The capture port is released in a `finally` for the same reason `start()` unwinds on failure: + a forwarder that raises on shutdown (a wedged tunnel process, a dead subprocess) would + otherwise leave the proxy holding its port, and the next `start()` fails on a port conflict + that says nothing about what actually went wrong. + """ + try: + if self._forwarder is not None: + self._forwarder.stop() + finally: + self._forwarder = None + self.capture.stop() + + @classmethod + def current(cls) -> "HarborService | None": + return cls._instance + + @classmethod + def set_current(cls, service: "HarborService") -> None: + cls._instance = service + + +def serve_harbor( + *, + llm_url: str = "", + datasets: list[str], + model: str | None = None, + host: str = "0.0.0.0", + port: int = 8000, + capture_port: int = 8100, + expose: str = "gradio", + env_file: str | None = None, + api_key: str | None = None, + auth_header: str = "Authorization", + provider: str = "openai", + max_output_tokens: int | None = 8192, +) -> None: + """Boot the capture proxy, then serve the env server with the UI mounted. + + Args: + llm_url (`str`): + OpenAI-spec inference endpoint. + datasets (`list[str]`): + Dataset specs to serve as splits. + port (`int`, *optional*, defaults to `8000`): + Env server port. + capture_port (`int`, *optional*, defaults to `8100`): + Capture proxy port. This is the one published to the sandbox. Ignored on a hosted + platform, where the proxy is mounted on the env server's own app instead. + expose (`str`, *optional*, defaults to `"gradio"`): + How the sandbox reaches the capture proxy locally: `gradio`, `cloudflare` or `direct`. + api_key (`str`, *optional*): + Credential for the inference endpoint. Defaults to `$OPENENV_LLM_API_KEY`. Never reaches + the sandbox: the agent's key is a capture session id. + auth_header (`str`, *optional*, defaults to `"Authorization"`): + Header to send `api_key` under. + """ + import uvicorn + + from .startup import prepare + + caps = prepare( + llm_url=llm_url, + model=model, + datasets=datasets, + env_file=env_file, + # A served deployment does not need an engine to be useful: rollouts name their own, and it + # is probed per engine when the session is created. Demanding one here coupled a server whose + # real cost is its dataset tree to the boot order of a vLLM that restarts every run. + require_llm=bool(llm_url), + quiet=False, + api_key=api_key, + auth_header=auth_header, + provider=provider, + ) + model = caps.llm.get("model") or model or "" + # `tokens` only when an engine was actually measured at it. With no default engine the default + # level must be the weakest, so a rollout that somehow reaches the default is never mistaken for + # a trainable one. + capture_level = caps.llm.get("capture_level") or ("tokens" if llm_url else "text") + # `prepare` has loaded the dotenv by now, so a key that lives only in --env-file is visible. + api_key = api_key or os.environ.get("OPENENV_LLM_API_KEY") or None + + service = HarborService( + llm_url=llm_url, + model=model, + datasets=datasets, + capture_port=capture_port, + expose=expose, + api_key=api_key, + auth_header=auth_header, + provider=provider, + capture_level=capture_level, + max_output_tokens=max_output_tokens, + ) + public = service.start() + HarborService.set_current(service) + + where = "mounted on this app" if service.mounted else f":{capture_port}" + print(f"\ncapture {where} -> {public}") + print( + " session routes are gated; set OPENENV_CAPTURE_ADMIN_KEY to call them yourself" + ) + if capture_level != "tokens": + # Repeated after the capabilities report, because this is the last line before the server + # starts serving and it changes what every rollout from it is worth. + print( + f"mode EVAL ONLY (capture_level={capture_level}) — rollouts carry reward and " + "trace, nothing trainable" + ) + print( + f"server http://{host}:{port} (UI at /web, Task API at /{{env}}/splits)" + ) + print("Ctrl-C to stop\n") + + # The UI is the whole point of this entry point, so turn it on rather than making the operator + # discover an env var. + os.environ.setdefault("ENABLE_WEB_INTERFACE", "true") + + app = build_app(datasets=datasets, llm_url=llm_url, model=model, llm=caps.llm) + try: + uvicorn.run(app, host=host, port=port, log_level="info") + finally: + service.stop() + + +def build_app( + *, + datasets: list[str], + llm_url: str = "", + model: str = "", + llm: dict[str, Any] | None = None, +) -> Any: + """The FastAPI app: Task API + MCP + the Gradio UI.""" + from openenv.core.env_server.http_server import create_app + from openenv.core.env_server.mcp_environment import ( + CallToolAction, + CallToolObservation, + ) + + from .environment import HarborEnvironment + from .ui import harbor_gradio_builder + + HarborEnvironment.configure( + datasets=datasets, llm_url=llm_url, model=model, llm=llm + ) + + def gradio_builder( + _web_manager: Any = None, + _action_fields: Any = None, + _metadata: Any = None, + _is_chat: Any = None, + display_title: str = "", + _quick_start: Any = None, + ) -> Any: + """OpenEnv calls this positionally with six web-interface arguments. + + Only the title is useful here: the Harbor UI drives rollouts through its own handlers rather + than the generic action-field form, because a rollout is one long tool call, not a step. + """ + return harbor_gradio_builder(datasets=datasets, title=display_title or "Harbor") + + app = create_app( + HarborEnvironment, + CallToolAction, + CallToolObservation, + env_name="harbor_env", + max_concurrent_envs=int(os.getenv("MAX_CONCURRENT_ENVS", "4")), + gradio_builder=gradio_builder, + custom_tab_name="Harbor", + custom_tab_primary=True, + show_default_tab=False, + ) + + # When the platform gives us a single port, the capture proxy rides on this app instead of + # being published separately. Mounting strips the prefix, so the proxy's own catch-all still + # sees `/v1/chat/completions` and every dialect keeps working unchanged. + service = HarborService.current() + if service is not None and service.mounted: + app.mount(CAPTURE_MOUNT, service.capture.app) + + return app diff --git a/src/openenv/harbor/shared_template.py b/src/openenv/harbor/shared_template.py new file mode 100644 index 0000000000..4fa9205562 --- /dev/null +++ b/src/openenv/harbor/shared_template.py @@ -0,0 +1,106 @@ +"""One E2B template for a whole suite, instead of one per task. + +Harbor names each sandbox template `f"{environment_name}__{env_hash}"` and takes `environment_name` +from the task (`harbor/trial/trial.py:628` passes `self.task.short_name`). For a suite whose tasks all +share one image that is 2238 identical templates: measured on +`AdithyaSK/data_agent_rl_environment_train`, all 2238 Dockerfiles hash to ONE value and the +environment directories are byte-identical, and the aliases Harbor built differed only in their task +prefix — `0000_555_555434_qa_3__016e9c9f617d` and `0000_650_650548_qa_2__016e9c9f617d` carry the same +hash. + +The cost of that is not just build time. Harbor decides whether to build from +`AsyncTemplate.alias_exists()`, which goes true the moment a build STARTS, so a GRPO group hitting a +task for the first time races itself: the losers skip the build and 404 with +`tag 'default' does not exist`. Sharing one alias means the template is built once, ever, and no group +can race a task's first visit. + +**The hash is what makes this safe.** `env_hash` stays in the alias, so a task whose environment +genuinely differs still gets its own template automatically — this collapses identical environments, +it does not force unlike ones together. A suite with three distinct images yields three templates, +whatever the shared name is. + +Opt-in through `HARBOR_SHARED_ENV_NAME`, the same variable an older Harbor honoured natively before +the knob was removed. The opt-in hook also retries transport failures on the read-only alias lookup +up to three attempts; sandbox creation and command execution retain Harbor's own retry policies. +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +import httpx + +logger = logging.getLogger(__name__) + +_applied = False + + +def enable_shared_templates(name: str = "") -> bool: + """Point every E2B template at one shared `environment_name`. Idempotent. + + Args: + name (`str`, *optional*): + The shared name. Defaults to `HARBOR_SHARED_ENV_NAME`; when neither is set this is a no-op + so the default behaviour is unchanged. + + Returns: + `bool`: whether sharing is now active. + """ + global _applied + shared = name or os.environ.get("HARBOR_SHARED_ENV_NAME", "") + if not shared or _applied: + return _applied + + try: + from harbor.environments import e2b + except Exception as exc: # noqa: BLE001 - a missing e2b extra must not break a docker run + logger.warning("cannot share templates: %s", exc) + return False + + original = e2b.E2BEnvironment.__init__ + original_template_exists = e2b.E2BEnvironment._does_template_exist + + async def template_exists(self): + # This is a read-only alias lookup. Retrying sandbox creation or commands here could + # duplicate side effects. In particular, a reused HTTP/2 connection can close on PING + # before this GET returns; let the pool acquire another connection for the next attempt. + for attempt in range(3): + try: + return await original_template_exists(self) + except httpx.TransportError as exc: + if attempt == 2: + raise + delay = 2**attempt + logger.warning( + "E2B template lookup transport failure (%s); retry %s/2 in %ss", + type(exc).__name__, + attempt + 1, + delay, + ) + await asyncio.sleep(delay) + + def __init__(self, *args, **kwargs): # noqa: N807 + # Keyword-only in practice: Harbor passes `environment_name=` from three call sites. Handled + # defensively anyway, because silently failing to rename would reintroduce per-task templates + # while looking like it worked. + if "environment_name" in kwargs: + kwargs["environment_name"] = shared + elif len(args) >= 2: + args = (args[0], shared, *args[2:]) + else: + logger.warning( + "E2BEnvironment was constructed without a recognisable environment_name; " + "this rollout keeps its per-task template" + ) + return original(self, *args, **kwargs) + + e2b.E2BEnvironment.__init__ = __init__ + e2b.E2BEnvironment._does_template_exist = template_exists + _applied = True + logger.info( + "E2B templates share the name %r; the environment hash still distinguishes unlike images", + shared, + ) + return True diff --git a/src/openenv/harbor/startup.py b/src/openenv/harbor/startup.py new file mode 100644 index 0000000000..d63ee44015 --- /dev/null +++ b/src/openenv/harbor/startup.py @@ -0,0 +1,212 @@ +"""Everything that must be true before the server accepts a request. + +Four checks, in the order that fails cheapest first: + +1. **LLM** — is it reachable, and what can it return? A vLLM without + `--return-tokens-as-token-ids --logprobs-mode processed_logprobs` answers every request perfectly + well and returns no ids, so every rebuilt training row would be empty with nothing reporting an + error. That failure has no loud edge, so the endpoint is probed first and the answer — the capture + level — is attached to everything this server later produces. Only an *unreachable* endpoint is + fatal: one that cannot return token ids is an eval backend, and saying so loudly beats refusing to + start, since every hosted provider lands in that category. +2. **sandbox credentials** — via Harbor's own `preflight()`, so the message names the exact missing + variable rather than us guessing at one. +3. **datasets** — resolved and downloaded up front. A 2000-task repo takes real time to fetch, and a + mistyped dataset name should fail here rather than on the first rollout. +4. **report** — print what is usable and, more usefully, what is not and why. + +A caller gets the same `Capabilities` object the server serves over the wire, so what is printed at +startup and what a client can query are the same data. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from openenv.core.harness.capture.validate_llm import ENGINE_HINT + +from .capabilities import Capabilities, capabilities + +# Load once, at import, so that Harbor's per-backend preflight sees the keys. Harbor reads +# credentials from the process environment and never from a file, so a `.env` that is not exported +# is invisible to it. +_ENV_LOADED = False + + +def load_env_file(path: str | Path | None = None) -> list[str]: + """Export `KEY=value` pairs from a dotenv file into the process environment. + + Existing variables win: an operator who exported something deliberately should not have it + silently replaced by a checked-in file. + + Args: + path (`str` or `Path`, *optional*): + The file to read. Defaults to `$OPENENV_ENV_FILE`, then `./.env`. + + Returns: + `list[str]`: Names of the variables that were set (values are never returned or logged). + """ + global _ENV_LOADED + candidate = Path(path or os.environ.get("OPENENV_ENV_FILE") or ".env").expanduser() + if not candidate.is_file(): + return [] + + applied: list[str] = [] + for raw in candidate.read_text(errors="replace").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + applied.append(key) + _ENV_LOADED = True + return applied + + +def prepare( + *, + llm_url: str | None = None, + model: str | None = None, + datasets: list[str] | None = None, + sandboxes: tuple[str, ...] | None = None, + env_file: str | Path | None = None, + require_llm: bool = True, + quiet: bool = False, + api_key: str | None = None, + auth_header: str = "Authorization", + provider: str = "openai", +) -> Capabilities: + """Run every startup check and return what this server can do. + + Args: + llm_url (`str`, *optional*): + OpenAI-spec inference endpoint. No default: callers pass it explicitly. + model (`str`, *optional*): + Served model id. Defaults to `$OPENENV_MODEL`, else the LLM's only served model. + datasets (`list[str]`, *optional*): + Dataset specs to serve. Defaults to `$OPENENV_DATASETS` (comma-separated). + sandboxes (`tuple[str, ...]`, *optional*): + Backends to check. Defaults to `$OPENENV_SANDBOXES`, else the known set. + env_file (`str` or `Path`, *optional*): + Dotenv file to load before checking credentials. + require_llm (`bool`, *optional*, defaults to `True`): + Require a reachable endpoint. This no longer means "must be trainable": an endpoint that + cannot return token ids is an eval backend, and refusing it would rule out every hosted + provider. It must still answer. + quiet (`bool`, *optional*, defaults to `False`): + Suppress the printed report. + api_key (`str`, *optional*): + Upstream credential. Defaults to `$OPENENV_LLM_API_KEY`. + auth_header (`str`, *optional*, defaults to `"Authorization"`): + Header to send `api_key` under. + + Returns: + [`Capabilities`]: Harnesses, sandboxes, datasets and LLM status, including `capture_level`. + + Raises: + RuntimeError: If `require_llm` and no URL was given, or the endpoint is unreachable. + """ + load_env_file(env_file) + + model = model or os.environ.get("OPENENV_MODEL", "") + # Read after `load_env_file`, so a key in the dotenv is picked up like every other credential. + api_key = api_key or os.environ.get("OPENENV_LLM_API_KEY", "") or None + if datasets is None: + raw = os.environ.get("OPENENV_DATASETS", "") + datasets = [d.strip() for d in raw.split(",") if d.strip()] + if sandboxes is None: + raw = os.environ.get("OPENENV_SANDBOXES", "") + sandboxes = tuple(s.strip() for s in raw.split(",") if s.strip()) or None + + if require_llm and not llm_url: + # A serving deployment no longer needs one. The hazard this guarded against — an unset + # endpoint yielding rollouts that look fine and carry no token ids — is now handled where it + # belongs: every rollout names its engine, that engine is probed when the session is created, + # and the measured tier travels with the result. So an engineless server is a server waiting + # to be told which engine to use, not a misconfigured one. + # + # Callers that genuinely need an engine up front (`harbor rollout`, which runs a batch itself + # and has nowhere else to get one) still pass `require_llm=True` and still get this. + raise RuntimeError( + "no LLM URL given. Pass --llm-url (or llm_url=) explicitly; there is no default, " + "because this entry point runs rollouts itself and has no session to take an engine " + "from. A served deployment (`harbor serve`) does not need one: rollouts name their own." + ) + + llm: dict[str, Any] = {} + if llm_url: + # Imported from the module rather than the package: the package re-exports a *function* + # named `validate_llm`, which shadows the same-named submodule. + from openenv.core.harness.capture.validate_llm import list_models, validate_llm + + # With no model given, ask the LLM what it serves. Convenient, and it also removes the + # commonest startup mistake: guessing a short alias for a server that publishes its full + # repo id. + if not model: + served = list_models(llm_url, api_key=api_key, auth_header=auth_header) + model = served[0] if len(served) == 1 else "" + + report = ( + validate_llm( + llm_url, + model, + api_key=api_key, + auth_header=auth_header, + provider=provider, + ) + if model + else None + ) + if report is None: + llm = { + "url": llm_url, + "model": "", + "ok": False, + "findings": [ + "no model given and the endpoint does not serve exactly one; pass --model" + ], + "authenticated": bool(api_key), + } + else: + llm = { + "url": llm_url, + "model": report.model, + "ok": report.ok, + "findings": report.findings, + "served_models": report.served_models, + "capture_level": report.capture_level, + "rollout_type": report.rollout_type, + "trainable": report.trainable, + "reachable": report.reachable, + "param_fixes": report.param_fixes, + "logprobs_mode": report.logprobs_mode, + "tool_support": report.tool_support, + # A boolean, never the key: `Capabilities` is rendered to stdout and served over the + # wire by `/metadata`. + "authenticated": bool(api_key), + } + + kwargs: dict[str, Any] = {"datasets": datasets, "llm": llm} + if sandboxes: + kwargs["sandboxes"] = sandboxes + caps = capabilities(**kwargs) + + if not quiet: + print(caps.render()) + + # Only unreachability is fatal now. An endpoint that answers but cannot return token ids is an + # eval backend, which is a supported way to run this server — the tier is stamped on `caps`, on + # `/health` and on every result, and no training contract is ever built from it. Refusing here + # instead would mean OpenAI, Anthropic and HF Inference Providers could not be used at all. + if require_llm and llm and not llm.get("reachable"): + raise RuntimeError( + "inference endpoint is not usable:\n " + + "\n ".join(llm.get("findings") or ["unreachable"]) + + "\n\n" + + ENGINE_HINT + ) + return caps diff --git a/src/openenv/harbor/tasks.py b/src/openenv/harbor/tasks.py new file mode 100644 index 0000000000..f29b4e4bc3 --- /dev/null +++ b/src/openenv/harbor/tasks.py @@ -0,0 +1,302 @@ +"""Dataset discovery: resolve Harbor task sets and serve them over OpenEnv's Task API. + +`HarborTaskProvider` satisfies `openenv.core.env_server.interfaces.TaskProvider`, so the HTTP routes +(`/{env}/splits`, `/{env}/tasks`, `/{env}/task`, ...) come for free once an environment exposes it. + +Two constraints from that Protocol shape the design, and both are easy to violate: + + * **it must be side-effect free.** Discovery must not boot a sandbox or start a job. + * **it must work on a freshly constructed instance.** The route handlers build a throwaway + environment per request purely to answer, so anything expensive has to be cached at module level + rather than on `self`, or every `/task` call re-downloads a dataset. + +Three source kinds, resolved by shape: + + AdithyaSK/data_agent_rl_environment_train HF dataset repo -> snapshot_download + /path/to/tasks local directory + terminal-bench@1.0 Harbor registry name@version + +Harbor has no HuggingFace path of its own — its datasets are git repos or Harbor Hub packages. But an +HF dataset laid out as `tasks//` is already a directory of Harbor task dirs, so downloading it +and pointing Harbor's local mode at the result needs no new concepts. +""" + +from __future__ import annotations + +import os +import threading +from pathlib import Path +from typing import Any + +from .models import HarborTaskRef + +# Resolution is expensive (a download on first use) and the Task API constructs a throwaway +# environment per HTTP request, so the cache has to outlive the instance. +_CACHE: dict[str, list[Path]] = {} +_LOCK = threading.Lock() + + +def _is_hf_repo(spec: str) -> bool: + """`org/name`, not a path and not `name@version`.""" + return ( + "/" in spec + and "@" not in spec + and not spec.startswith((".", "/", "~")) + and len(spec.split("/")) == 2 + ) + + +# Validating a task means reading and parsing several files inside it. That is 0.8 ms per task on +# local SSD, so 2s for a 2238-task suite, and a mounted bucket is an order of magnitude slower per +# read: listing a dataset then costs minutes and the Task API times out before answering. +# +# So discovery lists directories and does not validate. A task that is malformed surfaces as a failed +# rollout, with Harbor's own error, instead of being silently absent from the listing. That is also +# the more honest behaviour: filtering during discovery makes a broken task look like it was never +# in the dataset, and shifts the index of every task after it. +_VALIDATE_TASKS = os.environ.get("OPENENV_VALIDATE_TASKS", "").lower() in ( + "1", + "true", + "yes", +) + + +def _task_dirs_from_directory( + root: Path, *, validate: bool | None = None +) -> list[Path]: + """Task dirs under `root`, preferring a `tasks/` subdir when present. + + Args: + root (`Path`): + Dataset root, either containing `tasks/` or being the task directory itself. + validate (`bool`, *optional*): + Check each directory with Harbor's `Task.is_valid_dir`. Defaults to + `$OPENENV_VALIDATE_TASKS`, off, because it costs a file read per task and discovery is + on the latency path for every `/splits` and `/task` call. + """ + base = root / "tasks" if (root / "tasks").is_dir() else root + candidates = sorted( + p for p in base.iterdir() if p.is_dir() and not p.name.startswith(".") + ) + if not (_VALIDATE_TASKS if validate is None else validate): + return candidates + try: + from harbor.models.task.task import Task + except ImportError: + return candidates + return [p for p in candidates if Task.is_valid_dir(p, disable_verification=True)] + + +def resolve_task_dirs(spec: str, *, refresh: bool = False) -> list[Path]: + """Resolve a dataset spec to an ordered list of Harbor task directories. + + Order is stable (sorted by directory name) because a task's *index* is its identity everywhere + downstream — a trainer's dataset row, a `run_rollout` argument, a result. An unstable order would + silently change which task an index refers to between runs. + """ + with _LOCK: + if not refresh and spec in _CACHE: + return _CACHE[spec] + + path = Path(spec).expanduser() + if path.is_dir(): + dirs = _task_dirs_from_directory(path) + elif _is_hf_repo(spec): + dirs = _task_dirs_from_directory(_materialise_hf_dataset(spec)) + else: + dirs = _registry_task_dirs(spec) + + if not dirs: + raise ValueError( + f"no Harbor tasks found for {spec!r}. Expected an HF dataset repo laid out as " + "`tasks//`, a local directory of task dirs, or a Harbor registry `name@version`." + ) + + with _LOCK: + _CACHE[spec] = dirs + return dirs + + +# Task dirs must contain REAL FILES, not symlinks. +# +# The default HF cache is a symlink farm: every file under `snapshots//` points at +# `../../blobs/`. Harbor uploads a task's `tests/` directory to the sandbox by tarring it, and +# tar faithfully preserves symlinks — so the sandbox receives `test.sh -> ../../../blobs/09b32…`, +# pointing at a path that does not exist there. Bash reports a dangling symlink as +# "No such file or directory", which makes it look like the upload failed when the entry is right +# there in `ls`. +# +# That cost a long debugging session: `upload_dir` appeared to work, `chmod +x` as root succeeded, +# and only `ls -la` revealed the arrow. Backends differ in whether they hit it — E2B's upload path +# does not preserve symlinks, Modal's tar-based one does — so it presents as "Modal is broken". +# +# `local_dir=` makes huggingface_hub write real files instead of populating the symlink cache, which +# fixes it for every backend at once and needs no Harbor change. +_DATASET_ROOT = Path( + os.environ.get("OPENENV_DATASET_CACHE") + or (Path.home() / ".cache" / "openenv" / "harbor-datasets") +) + + +# A Harbor task suite is thousands of tiny files (a `task.toml`, a Dockerfile, a test script per +# task), so wall clock is dominated by per-file round trips rather than bytes. Raising concurrency is +# the lever that matters; `hf_transfer` optimises large-file throughput and does comparatively little +# here, but costs nothing when it is installed. +_DOWNLOAD_WORKERS = int(os.environ.get("OPENENV_DATASET_WORKERS", "32")) + + +def _materialise_hf_dataset(spec: str) -> Path: + """Download an HF dataset as real files and return its local root. + + Mounting beats downloading where it is available: a deployed Space can attach the dataset repo + as a read-only volume and pass the mount path as the dataset spec, which skips this entirely. + `openenv harbor push` does that automatically. This path is for local runs. + """ + from huggingface_hub import snapshot_download + + target = _DATASET_ROOT / spec.replace("/", "__") + target.mkdir(parents=True, exist_ok=True) + snapshot_download( + spec, + repo_type="dataset", + allow_patterns=["tasks/**"], + local_dir=str(target), + max_workers=_DOWNLOAD_WORKERS, + ) + return target + + +def has_symlinks(task_dir: Path) -> list[Path]: + """Any symlinks under `task_dir`. Non-empty means uploads to a tar-based backend will break.""" + return [p for p in task_dir.rglob("*") if p.is_symlink()] + + +def _registry_task_dirs(spec: str) -> list[Path]: + """A Harbor registry dataset, e.g. `terminal-bench@1.0`. Downloads on first use.""" + from harbor.models.job.config import DatasetConfig + from openenv.core.utils import run_async_safely + + name, _, version = spec.partition("@") + config = DatasetConfig(name=name, version=version or None) + # Discovery is reached from async callers too (`run_batch` is a coroutine), and `asyncio.run` + # cannot be called from a running loop. + task_configs = run_async_safely(config.get_task_configs(disable_verification=True)) + return [Path(str(t.get_local_path())) for t in task_configs] + + +def read_instruction(task_dir: Path, *, limit: int = 4000) -> str: + """The task's prompt, for previewing in discovery. Truncated: this is not the authoritative copy. + + The sandbox gets the real instruction from Harbor at run time. Serving a huge prompt over the + Task API for every listed task would make `list_tasks` enormous for no benefit. + """ + path = task_dir / "instruction.md" + if not path.is_file(): + return "" + text = path.read_text(errors="replace").strip() + return text if len(text) <= limit else text[:limit] + "\n…" + + +def prefetch(datasets: list[str]) -> dict[str, Any]: + """Resolve every dataset up front, downloading if needed. + + Called before the server accepts traffic. Two reasons it is worth doing eagerly rather than on + first use: a 2000-task HF repo takes real time to fetch, and a caller who mistypes a dataset + name should learn at startup rather than when the first rollout 404s. Failures are collected + rather than raised, so one bad dataset does not stop the server serving the good ones. + + Args: + datasets (`list[str]`): + Dataset specs — HF repo id, local path, or Harbor `name@version`. + + Returns: + `dict` mapping each spec to `{"num_tasks": int}` or `{"error": str}`. + """ + report: dict[str, Any] = {} + for spec in datasets: + try: + report[spec] = {"num_tasks": len(resolve_task_dirs(spec))} + except Exception as exc: # noqa: BLE001 - one broken dataset must not hide the others + report[spec] = {"error": f"{type(exc).__name__}: {str(exc)[:200]}"} + return report + + +class HarborTaskProvider: + """Serves one or more Harbor datasets as OpenEnv splits. + + A split IS a dataset spec: start the server with two datasets and you get two splits. That keeps + the mapping obvious in both directions — a split name is something you can paste back into + `--dataset` — rather than inventing a train/test split Harbor does not have. + """ + + def __init__(self, datasets: list[str] | None = None) -> None: + self._datasets = list(datasets or []) + + # --- TaskProvider protocol ------------------------------------------ + def list_splits(self) -> list[dict[str, Any]]: + splits = [] + for spec in self._datasets: + try: + n = len(resolve_task_dirs(spec)) + splits.append({"name": spec, "num_tasks": n}) + except Exception as exc: # noqa: BLE001 - a broken dataset must not hide the good ones + splits.append({"name": spec, "num_tasks": 0, "error": str(exc)[:200]}) + return splits + + def num_tasks(self, split: str) -> int: + return len(resolve_task_dirs(self._check(split))) + + def list_tasks(self, split: str) -> list[dict[str, Any]]: + spec = self._check(split) + return [ + self._ref(spec, i, d).model_dump() + for i, d in enumerate(resolve_task_dirs(spec)) + ] + + def get_task(self, split: str, index: int) -> dict[str, Any]: + spec = self._check(split) + dirs = resolve_task_dirs(spec) + if not 0 <= index < len(dirs): + raise IndexError( + f"task index {index} out of range for {spec!r} ({len(dirs)} tasks)" + ) + return self._ref(spec, index, dirs[index]).model_dump() + + def get_task_range( + self, split: str, start: int | None = None, stop: int | None = None + ) -> list[dict[str, Any]]: + spec = self._check(split) + dirs = resolve_task_dirs(spec) + return [ + self._ref(spec, i, d).model_dump() + for i, d in list(enumerate(dirs))[start:stop] + ] + + # --- internals ------------------------------------------------------- + def task_dir(self, split: str, index: int) -> Path: + """The on-disk task dir for an index. Used by the rollout path, not by discovery.""" + dirs = resolve_task_dirs(self._check(split)) + if not 0 <= index < len(dirs): + raise IndexError(f"task index {index} out of range ({len(dirs)} tasks)") + return dirs[index] + + def _check(self, split: str) -> str: + if not self._datasets: + raise ValueError("this server was started with no datasets; pass --dataset") + if not split: + return self._datasets[0] + if split not in self._datasets: + raise ValueError( + f"unknown split {split!r}; served splits are {self._datasets}" + ) + return split + + @staticmethod + def _ref(spec: str, index: int, task_dir: Path) -> HarborTaskRef: + return HarborTaskRef( + index=index, + task_id=str(task_dir), + task_name=task_dir.name, + dataset=spec, + instruction=read_instruction(task_dir), + ) diff --git a/src/openenv/harbor/ui.py b/src/openenv/harbor/ui.py new file mode 100644 index 0000000000..b86d64d4f7 --- /dev/null +++ b/src/openenv/harbor/ui.py @@ -0,0 +1,1511 @@ +"""Human-facing UI for a Harbor env server. + +Two columns: the LLM on the left, the task on the right. Validate, pick, run. + +Status text is deliberately terse. The long explanations belong in docs — what a person needs on +screen is whether it will work, what got rewritten, and which sandboxes are usable. + +Validation is a gate, not a hint: an LLM endpoint without token-id capture answers every request +normally and returns nothing trainable, so a rollout looks perfect and is worthless. + +Rich output (the rollout graph, per-turn tokens) is rendered as HTML rather than Gradio widgets, +because a conversation tree with branches and discarded retries is a shape, and a dataframe cannot +show a shape. +""" + +from __future__ import annotations + +import html +import json +import re +from typing import Any + +import gradio as gr + +_UNVALIDATED = "_Enter your LLM URL and press Validate._" + +_CSS = """ +.hb-wrap { max-width: 1400px; margin: 0 auto; } +.hb-card { border: 1px solid var(--border-color-primary); border-radius: 10px; padding: 14px 16px; } +.hb-dim { opacity: .6; } +.hb-kv { display: flex; gap: 22px; flex-wrap: wrap; margin: 4px 0 2px; } +.hb-kv b { font-variant-numeric: tabular-nums; } + +/* The two panels read as one undifferentiated wall of controls without a boundary; the border is + what makes "pick a model" and "pick a task" look like two separate decisions. */ +.hb-cell { border: 1px solid var(--border-color-primary); border-radius: 10px; + padding: 14px 16px; } +.hb-panel { border: 1px solid var(--border-color-primary) !important; + border-radius: 10px !important; padding: 16px !important; + background: var(--block-background-fill); } +.hb-cell { min-width: 0 !important; } +.hb-wrap .hb-panel + .hb-panel { margin-top: 12px; } +.hb-tx, .hb-card { overflow-wrap: anywhere; } +@media (max-width: 700px) { + .hb-cell, .hb-panel { padding: 12px !important; } + .hb-hero { flex-wrap: wrap; } + .hb-kv { gap: 12px; } +} +@media (prefers-reduced-motion: reduce) { + .hb-pulse, .hb-step.now .hb-dot { animation: none; } +} + +/* Live conversation. Roles are colour-coded down the left edge so the shape of the loop + (assistant calls a tool, tool answers, assistant calls again) is readable at a glance. */ +/* No max-height here. A fixed-height scroll box nests a second scroller inside the page: + the wheel gets captured while the pointer is over the conversation, and the page stops + growing so there is nothing left to scroll to. Let it run at natural height and let the + page do the scrolling. Length is bounded by the message cap, not by CSS. */ +.hb-tx { margin-top: 10px; } +.hb-msg { border-left: 3px solid var(--border-color-primary); padding: 6px 0 6px 10px; + margin: 8px 0; font-size: 13px; line-height: 1.45; } +.hb-msg pre { white-space: pre-wrap; word-break: break-word; margin: 4px 0 0; + font-size: 12px; opacity: .85; } +.hb-role { display: inline-block; font-size: 11px; text-transform: uppercase; + letter-spacing: .04em; opacity: .65; margin-bottom: 2px; } +.hb-assistant { border-left-color: #22c55e; } +.hb-tool { border-left-color: #38bdf8; } +.hb-user { border-left-color: #a78bfa; } +.hb-system { border-left-color: #94a3b8; opacity: .75; } +.hb-tc { margin-top: 4px; padding: 4px 8px; border-radius: 6px; + background: var(--background-fill-secondary); } +.hb-tc { display: block; } +.hb-tc b { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; } +.hb-arrow { opacity: .5; margin-right: 6px; } +.hb-tr { margin-top: 4px; padding: 4px 8px; border-radius: 6px; border-left: 2px solid #38bdf8; + background: var(--background-fill-secondary); } +/* No inner scroller here either, for the same reason as the conversation above, and the previous + version of this rule was the bug: `overscroll-behavior: contain` does not stop a box from + swallowing the page scroll, it is what *prevents* the wheel from chaining to the page once the + box reaches its own end. Tool output is clipped to 500 characters server side, but 500 + characters of shell output is 25 short lines, which overflowed the 220px cap and left the page + feeling frozen wherever the pointer happened to be. Length is bounded by the clip, not by CSS. */ +.hb-tr pre{ margin: 0; font-size: 11.5px; opacity: .8; } + +/* A run in flight should look like one. */ +.hb-live { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } +.hb-pulse { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; + animation: hb-blink 1.2s ease-in-out infinite; } +@keyframes hb-blink { 0%, 100% { opacity: 1; } 50% { opacity: .25; } } +.hb-drop-msg { opacity: .5; border-left-color: #ef4444; } + +/* Verdict. The outcome should be legible from across the room; the numbers behind it should not + compete with it for attention. */ +.hb-verdict { border-left-width: 4px; } +.hb-head { font-size: 17px; font-weight: 650; margin-bottom: 8px; } +.hb-good { border-left-color: #22c55e; } +.hb-warn { border-left-color: #f59e0b; } +.hb-bad { border-left-color: #ef4444; } +.hb-err { white-space: pre-wrap; word-break: break-word; font-size: 12px; margin: 10px 0 0; + padding: 8px 10px; border-radius: 6px; background: var(--background-fill-secondary); } + +/* A qualifier on the result: true, load-bearing, and not an error. Bordered rather than coloured + like a finding, so "this rollout is eval-only" does not read as "this rollout failed". */ +.hb-note { font-size: 12.5px; line-height: 1.5; margin: 10px 0 0; padding: 8px 11px; + border-radius: 6px; border: 1px solid var(--border-color-primary); + background: var(--background-fill-secondary); } +.hb-note code { font-size: 11.5px; } + +/* Hover explanations. `data-tip` rather than `title=` for the two long ones: the native tooltip + truncates, takes a second to appear, and cannot wrap a paragraph. Short hints use Gradio's own + `info=`, which renders under the label and needs no hover at all. */ +.hb-i { display: inline-flex; align-items: center; justify-content: center; cursor: help; + width: 15px; height: 15px; margin-left: 6px; border-radius: 50%; font-size: 10px; + font-weight: 700; font-style: normal; vertical-align: 1px; + border: 1px solid var(--border-color-primary); opacity: .75; position: relative; } +.hb-i:hover { opacity: 1; } +.hb-i::after { content: attr(data-tip); position: absolute; left: 50%; bottom: 130%; + transform: translateX(-50%); width: max-content; max-width: 320px; padding: 8px 10px; + border-radius: 6px; border: 1px solid var(--border-color-primary); + background: var(--background-fill-primary); color: var(--body-text-color); + font-size: 11.5px; font-weight: 400; line-height: 1.5; text-align: left; + white-space: pre-line; opacity: 0; visibility: hidden; transition: opacity .12s; + z-index: 40; box-shadow: 0 4px 14px rgba(0,0,0,.18); } +.hb-i:hover::after { opacity: 1; visibility: visible; } +/* The label row the icon sits on, so the icon lines up with a Gradio label rather than floating. */ +.hb-lbl { display: flex; align-items: center; font-size: 13px; font-weight: 600; + margin: 2px 0 -6px; } + +/* Findings carry severity: a FATAL means unusable, a WARN means read before training on it. */ +.hb-find { font-size: 12.5px; margin: 5px 0; line-height: 1.45; } +.hb-tag { display: inline-block; min-width: 46px; margin-right: 8px; padding: 1px 6px; + border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: .04em; + text-align: center; vertical-align: 1px; } +.hb-fatal .hb-tag { background: #ef4444; color: #fff; } +.hb-warn2 .hb-tag { background: #f59e0b; color: #1f2937; } +.hb-info .hb-tag { background: var(--background-fill-secondary); opacity: .7; } +.hb-info { opacity: .7; } + +/* Turn table: dense, aligned, and the numbers read as numbers. */ +.hb-tbl { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 13px; } +.hb-tbl th{ text-align: left; font-weight: 600; font-size: 11px; text-transform: uppercase; + letter-spacing: .04em; opacity: .55; padding: 4px 10px 6px 0; + border-bottom: 1px solid var(--border-color-primary); } +.hb-tbl td{ padding: 7px 10px 7px 0; border-bottom: 1px solid var(--border-color-primary); + vertical-align: top; } +.hb-tbl code { font-size: 12px; padding: 1px 6px; border-radius: 4px; + background: var(--background-fill-secondary); } +.hb-num { font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; + padding-right: 14px !important; } +.hb-prev { margin-top: 3px; font-size: 12px; } +.hb-drop-row { opacity: .45; } +.hb-drop-tag { background: #ef4444; color: #fff; } +.hb-conf { display: inline-block; width: 76px; height: 7px; border-radius: 4px; + background: var(--background-fill-secondary); overflow: hidden; vertical-align: middle; } +.hb-conf span { display: block; height: 100%; } + +/* Each conversation folds away; the main one starts open. */ +.hb-convo { margin-top: 10px; border-top: 1px solid var(--border-color-primary); padding-top: 8px; } +.hb-convo summary { cursor: pointer; padding: 4px 0; } + +/* Setup, before the agent has said anything. */ +.hb-steps { margin: 8px 0 0; } +.hb-step { display: flex; align-items: center; gap: 9px; padding: 3px 0; font-size: 13px; } +.hb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-color-primary); } +.hb-step.done .hb-dot { background: #22c55e; } +.hb-step.now .hb-dot { background: #f59e0b; animation: hb-blink 1.2s ease-in-out infinite; } +.hb-step.todo { opacity: .45; } + +/* The outcome, at a glance. */ +.hb-hero { display: flex; align-items: center; justify-content: space-between; gap: 20px; + padding-bottom: 12px; margin-bottom: 4px; + border-bottom: 1px solid var(--border-color-primary); } +.hb-badge { display: inline-flex; align-items: center; gap: 9px; font-size: 19px; + font-weight: 700; letter-spacing: -.01em; } +.hb-mark { display: inline-flex; align-items: center; justify-content: center; + width: 30px; height: 30px; border-radius: 50%; font-size: 15px; color: #fff; } +.hb-b-good .hb-mark { background: #22c55e; } +.hb-b-warn .hb-mark { background: #f59e0b; } +.hb-b-bad .hb-mark { background: #ef4444; } +.hb-score { text-align: right; line-height: 1.05; } +.hb-score-v { font-size: 42px; font-weight: 700; font-variant-numeric: tabular-nums; + letter-spacing: -.02em; } +.hb-score-c { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; opacity: .55; } +.hb-kv-big span { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; + opacity: .55; } +.hb-kv-big b { display: block; font-size: 19px; margin-top: 3px; text-transform: none; + letter-spacing: normal; opacity: 1; } +.hb-kv-big .hb-key b { color: var(--body-text-color); } +.hb-kv-big .hb-key { opacity: .85; } + +footer { display: none !important; } +""" + + +def _labelled(label: str, tip: str) -> str: + """A field label with a hover-explained `i` beside it. + + For the explanations too long to sit under a Gradio label as `info=` text — which is where every + one-liner belongs instead, since it needs no hover to be seen. + """ + return ( + f'
{html.escape(label)}' + f'i
' + ) + + +_KEY_TIP = ( + "Only needed for a hosted endpoint: OpenAI, Anthropic, HF Inference Providers.\n\n" + "It is sent to the inference endpoint by this server and nothing else. It is NOT the key the " + "agent receives — that one is a capture session id, minted per rollout, which is how one proxy " + "serves many rollouts and how an unregistered caller is rejected.\n\n" + "Leave empty for a local vLLM or SGLang." +) + +_LEVEL_TIP = ( + "There are two kinds of rollout, and the endpoint decides which you get.\n\n" + "TRAIN needs the engine to return token ids and per-token logprobs: vLLM started with " + "--return-tokens-as-token-ids --logprobs-mode processed_logprobs, or SGLang built from git " + "main. You get the reward, the trace, and the exact tokens and logprobs to train on.\n\n" + "EVAL is everything else, including a vLLM started without those flags. You get the reward and " + "the full trace; there are no token ids, so nothing is trainable. Logprobs alone do not help — " + "with no ids to pair them with there is nothing to align them to." +) + + +def _clip(text: Any, limit: int = 400) -> str: + """Escape and shorten a value for display, keeping the head where the meaning usually is.""" + body = text if isinstance(text, str) else json.dumps(text, default=str) + body = body.strip() + return html.escape(body[:limit]) + ("…" if len(body) > limit else "") + + +def _tool_calls(message: dict[str, Any]) -> list[dict[str, Any]]: + """Tool calls on a message, normalised across all four dialects. + + Chat-completions puts them in `tool_calls`; Anthropic puts them in the content block list as + `tool_use`. Reading only the former shows claude-code as a stream of text with no visible + actions, which is exactly the case the live view exists to make visible. + """ + out: list[dict[str, Any]] = [] + for call in message.get("tool_calls") or []: + function = call.get("function") or {} + name = function.get("name") or call.get("name") + if name: + out.append( + { + "name": str(name), + "arguments": function.get("arguments", call.get("arguments", "")), + } + ) + content = message.get("content") + if isinstance(content, list): + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "tool_use" + and block.get("name") + ): + out.append( + {"name": str(block["name"]), "arguments": block.get("input", "")} + ) + return out + + +def _message_text(message: dict[str, Any]) -> str: + """Readable text of a message, ignoring tool-call and tool-result blocks.""" + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") in ("tool_use", "tool_result"): + continue + if block.get("text"): + parts.append(str(block["text"])) + return " ".join(parts) + return "" + + +def _tool_results(message: dict[str, Any]) -> list[str]: + """What came back from a tool, in either the chat-completions or the Anthropic shape.""" + if message.get("role") == "tool": + return [ + _message_text(message) or json.dumps(message.get("content"), default=str) + ] + content = message.get("content") + if not isinstance(content, list): + return [] + out = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_result": + body = block.get("content") + if isinstance(body, list): + body = " ".join(b.get("text", "") for b in body if isinstance(b, dict)) + out.append(str(body if body is not None else "")) + return out + + +def _render_calls(calls: list[dict[str, Any]]) -> str: + return "".join( + f'
' + f"{html.escape(str(c.get('name', 'tool')))}" + f"
{_clip(c.get('arguments', ''), 600)}
" + for c in calls + ) + + +def _render_message(message: dict[str, Any], *, label: str = "") -> str: + """One row of the conversation: who spoke, what they said, what they invoked or returned.""" + role = str(message.get("role", "?")) + calls = _tool_calls(message) + results = _tool_results(message) + text = _message_text(message) + + # A user message carrying only tool results is the tool speaking, not the user; labelling it + # "user" makes the agent look like it is being prompted between every action. + shown_role = "tool" if results and role != "assistant" else role + # For a `role: tool` message the content IS the result, so rendering both duplicates it. + if shown_role == "tool": + text = "" + body = _clip(text, 700 if shown_role in ("user", "system") else 450) if text else "" + blocks = "".join( + f'
{_clip(r, 500)}
' for r in results + ) + if not body and not blocks and not calls: + return "" + return ( + f'
' + f'{html.escape(label or shown_role)}' + + (f"
{body}
" if body else "") + + _render_calls(calls) + + blocks + + "
" + ) + + +def _transcript_html(session: Any) -> str: + """The conversation as it stands right now: what the agent said, called, and got back. + + Counters answer "is it alive"; this answers "is it doing the right thing", which is the question + worth asking while a rollout is still running. The newest turn's `request_messages` already holds + the whole conversation the harness assembled, tool results included, so rendering that plus the + latest response needs no reconstruction from deltas. + """ + nodes = sorted(session.graph.nodes(), key=lambda n: n.index) + if not nodes: + return "" + latest = nodes[-1] + + rows = [ + row + for row in (_render_message(m) for m in (latest.request_messages or [])) + if row + ] + + response = latest.response_message or {} + tail = _render_message( + {**response, "role": "assistant"}, + label=f"assistant · completed call {latest.index + 1}", + ) + if tail: + rows.append(tail) + + # Only the tail is ever new, so cap from the front and say what was dropped. + shown = rows[-18:] + elided = ( + f'
… {len(rows) - len(shown)} earlier message(s)
' + if len(rows) > len(shown) + else "" + ) + # Count across the conversation, not just the response messages: Anthropic carries tool use in + # the assistant content blocks the harness replays back, so a response-only tally reads 0. + calls_so_far = sum( + len(_tool_calls(m)) for m in (latest.request_messages or []) + ) + len(_tool_calls(latest.response_message or {})) + return ( + f'
' + f'Live conversation' + f'turn {latest.index} · {calls_so_far} tool call(s) so far · ' + f"{latest.n_tools} tool(s) offered
{elided}{''.join(shown)}
" + ) + + +# Capture-session creation precedes sandbox allocation; it is not evidence that +# setup has finished. Only a completed captured call proves the agent is running. +_SETUP_STEPS = ( + "preparing the sandbox, task and agent", + "waiting for a completed model call", +) + + +def _steps_html(stage: int) -> str: + """The setup sequence, with the current stage marked.""" + rows = [] + for i, label in enumerate(_SETUP_STEPS): + cls = "done" if i < stage else ("now" if i == stage else "todo") + rows.append( + f'
' + f"{html.escape(label)}
" + ) + return f'
{"".join(rows)}
' + + +def _live_html( + harness: str, + sandbox: str, + phase: str, + elapsed: float, + stats: dict[str, Any] | None, + stage: int = -1, +) -> str: + """The running header: what is running, how far in, and what it has produced so far.""" + bits = [ + f'
' + f'
' + f"Running {html.escape(harness)} on " + f"{html.escape(sandbox)}" + f'{html.escape(phase)} · {elapsed:.0f}s
' + ] + # Before the first call there are no numbers worth showing, so show progress instead. A row of + # zeros for a minute reads as "stuck" when the sandbox is simply still booting. + if stage >= 0: + bits.append(_steps_html(stage)) + if stats: + bits.append( + '
' + + "".join(f"{k}
{v}
" for k, v in stats.items()) + + "
" + ) + bits.append("
") + return "".join(bits) + + +# `warn` is already a verdict tone; the finding variant needs its own class name. +_FINDING_CLASS = {"FATAL": "fatal", "WARN": "warn2", "INFO": "info"} + + +def _findings_html(findings: list[str]) -> str: + """Findings, grouped by how much they should worry you. + + They were previously all rendered the same dim grey and truncated to 220 characters, which put + "the intercept saw no model calls" and "3 roots across 7 turns" at equal weight. A FATAL means + the rollout is unusable; a WARN means read it before training on it. + """ + if not findings: + return "" + buckets: dict[str, list[str]] = {"FATAL": [], "WARN": [], "INFO": []} + for raw in findings: + level = ( + "FATAL" + if raw.startswith("[FATAL") + else "WARN" + if raw.startswith("[WARN") + else "INFO" + ) + buckets[level].append( + raw.split("]", 1)[-1].strip() if raw.startswith("[") else raw + ) + + out = [] + for level, items in buckets.items(): + for item in items: + out.append( + f'
' + f'{level}{html.escape(item[:400])}
' + ) + return "".join(out) + + +def _result_html(r: dict[str, Any]) -> str: + """The verdict, the numbers behind it, and anything that qualifies it. + + The outcome is the one thing every reader wants first, so the reward is set at display size and + the supporting counts are deliberately quieter. Getting that hierarchy wrong is how a failed + rollout reads as a successful one at a glance. + """ + reward = r.get("reward") + if not r.get("ok"): + tone, mark, label = "bad", "✕", "Failed" + value, caption = "—", str(r.get("exception_type") or "error") + elif reward is None: + # Not a zero. The verifier never ran, so this says nothing about the model. + tone, mark, label = "warn", "!", "Not graded" + value, caption = "—", "the verifier never ran" + elif reward > 0: + tone, mark, label = "good", "✓", "Solved" + value, caption = f"{reward:.2f}", "reward" + else: + tone, mark, label = "warn", "○", "Not solved" + value, caption = f"{reward:.2f}", "reward" + + turns = r.get("turns") or [] + generated = sum(len(t.get("completion_token_ids") or []) for t in turns) + dropped = sum( + len(t.get("completion_token_ids") or []) for t in turns if t.get("discarded") + ) + tools = sum(len(t.get("tool_calls") or []) for t in turns) + atif = r.get("atif", "none") + + # `key` marks the figures that decide whether this rollout is usable, as opposed to describing it. + # The initial prompt: task instruction plus the harness's system prompt and tool manifest. + # Constant across turns, so it is a property of the rollout rather than a per-row column. + context = len((turns[0].get("prompt_token_ids") or [])) if turns else 0 + + is_eval = r.get("rollout_type", "train") == "eval" + kv = [ + # "trainable tokens: 0" on an eval rollout reads as a capture failure. It is not one, so the + # slot says what kind of rollout this is instead of reporting a zero that means nothing here. + ("rollout", f"EVAL · {r.get('capture_level', '?')}", True) + if is_eval + else ("trainable tokens", f"{r.get('n_trainable_tokens', 0):,}", True), + ("context", f"{context:,}", False), + ("trace check", atif, atif != "match"), + ("model calls", r.get("n_turns", 0), False), + ("tool calls", tools, False), + ("conversations", r.get("n_roots", 0), False), + ( + "generated", + f"{generated:,}" + (f" · {dropped:,} discarded" if dropped else ""), + False, + ), + ("wall", f"{r.get('wall_s', 0):.0f}s", False), + ] + + out = [ + f'
', + '
', + f'
{mark}' + f"{html.escape(label)}
", + f'
{html.escape(value)}
' + f'
{html.escape(caption)}
', + "
", + '
' + + "".join( + f'{k}
{v}
' + for k, v, key in kv + ) + + "
", + ] + + if is_eval: + out.append( + '
This is an eval rollout. The endpoint returned ' + f"{'logprobs but no token ids' if r.get('capture_level') == 'logprobs' else 'no token ids and no logprobs'}, " + "so you get the reward and the full trace below, but nothing trainable — there is no " + "contract.json and no per-token logprobs. Point the server at vLLM " + "(--return-tokens-as-token-ids --logprobs-mode processed_logprobs) or " + "SGLang built from main for trainable rollouts.
" + ) + for fix in r.get("param_fixes") or []: + out.append( + f'
upstream compatibility: {html.escape(fix)} — ' + "the request differs from what the harness asked for.
" + ) + + rewards = r.get("rewards") or {} + if len(rewards) > 1: + chosen = r.get("reward_key", "") + parts = [ + f"{html.escape(k)} {v:.3f}" + (" ←" if k == chosen else "") + for k, v in sorted(rewards.items()) + ] + out.append(f'
{"   ".join(parts)}
') + + for step in r.get("step_results") or []: + vals = ", ".join(f"{k}={v:.2f}" for k, v in (step.get("rewards") or {}).items()) + out.append( + f'
step {html.escape(step.get("name", ""))} {vals}
' + ) + + if r.get("error"): + out.append(f'
{html.escape(str(r["error"])[:1200])}
') + if r.get("agent_log_tail"): + out.append( + '
agent log' + f"
{html.escape(str(r['agent_log_tail'])[:4000])}
" + ) + + out.append(_findings_html(r.get("findings") or [])) + out.append( + '
Capture quality and reward are ' + "independent: a perfectly captured rollout can still score 0 because the model was " + "wrong, and reward means the verifier never ran at all.
" + ) + return "".join(out) + + +def _conversation_html(r: dict[str, Any]) -> str: + """The whole conversation as it was actually sent: system prompt, tools, results, replies. + + Rebuilt from the result rather than the live session, so it survives the run. Several are + possible: each root is a separate conversation, and an auxiliary one (a next-speaker check, a + summariser) is labelled as such so it is not mistaken for the agent working on the task. + """ + conversations = r.get("conversations") or [] + if not conversations: + return "" + + agents = [c for c in conversations if c.get("role", "agent") == "agent"] + blocks = [] + seen_agents = 0 + for i, convo in enumerate(conversations): + role = convo.get("role", "agent") + if role == "agent": + seen_agents += 1 + # Numbered when there is more than one, so two blocks are never both "main". + badge = ( + "main conversation" + if len(agents) == 1 + else f"conversation {seen_agents} of {len(agents)}" + ) + else: + badge = { + "auxiliary": "auxiliary call", + "discarded": "discarded branch", + }.get(role, role) + rows = [ + row + for row in (_render_message(m) for m in convo.get("messages") or []) + if row + ] + if not rows: + continue + blocks.append( + f'
' + f"{html.escape(badge)} " + f'{convo.get("n_turns", 0)} model call(s), ' + f"{len(rows)} message(s){''.join(rows)}
" + ) + if not blocks: + return "" + return ( + f'
Conversation ' + f'everything the model saw and produced' + f"{''.join(blocks)}
" + ) + + +def _confidence(mean_logp: float) -> str: + """A bar for mean logprob. Closer to 0 is more confident; -1.0 is the practical floor here.""" + pct = max(0.0, min(1.0, 1.0 + mean_logp)) # -0 -> 1.0, -1 -> 0.0 + hue = 8 + int(112 * pct) # red through amber to green + return ( + f'' + f'' + ) + + +def _turns_html(r: dict[str, Any]) -> str: + """Turn by turn: what it did, how much it wrote, how sure it was. + + Replaces a table whose most prominent column was "tools", meaning the number of tools *offered* + to the model. That number is a property of the harness, identical on every row, and told nobody + anything. What varies per turn, and is worth reading, is the action taken, the tokens spent on + it, and the model's confidence while producing them. + """ + turns = r.get("turns") or [] + if not turns: + return '
No model calls were captured.
' + + used: dict[str, int] = {} + for t in turns: + for call in t.get("tool_calls") or []: + name = str(call.get("name", "?")) + used[name] = used.get(name, 0) + 1 + + rows = [] + for t in turns: + lp = t.get("per_token_logps") or [] + mean = sum(lp) / len(lp) if lp else 0.0 + gen = len(t.get("completion_token_ids") or []) + calls = t.get("tool_calls") or [] + if calls: + action = " ".join( + f"{html.escape(str(c.get('name', 'tool')))}" for c in calls + ) + elif t.get("finish_reason") == "stop": + action = 'final answer' + else: + action = 'text only' + note = ( + ' discarded' + if t.get("discarded") + else "" + ) + preview = _clip(t.get("text") or "", 160) + rows.append( + f'' + f'{t.get("turn")}' + f"{action}{note}" + + (f'
{preview}
' if preview else "") + + f'{gen:,}' + f"{_confidence(mean) if lp else ''}" + f'{html.escape(str(t.get("finish_reason") or ""))}' + ) + + histogram = "" + if used: + top = sorted(used.items(), key=lambda kv: -kv[1]) + histogram = ( + '
tools used: ' + + "   ".join(f"{html.escape(k)}×{v}" for k, v in top) + + "
" + ) + + return ( + '
Turn by turn' + '' + "" + + "".join(rows) + + "
#actiontokensconfidencestopped because
" + + histogram + + '
Confidence is the mean logprob of the ' + "sampled tokens: full bar means the model was near-certain, short means it was " + "guessing. Discarded turns were generated and billed but lead nowhere, so they are " + "excluded from training paths.
" + ) + + +def _write_contract(r: dict[str, Any]) -> str | None: + """Write `contract.json`: exactly what a trainer consumes, nothing else. + + Per turn, `(prompt_token_ids, completion_token_ids, per_token_logps)` plus the reward. The + logprobs are the load-bearing part and the reason this is a separate file: they are the + behaviour policy's, recorded at sampling time, and cannot be recovered afterwards by re-running + the prompt. Discarded turns are kept but flagged, because they were generated and billed and a + trainer must be able to see them in order to exclude them deliberately. + + Returns `None` for an eval rollout. Writing a file whose every `prompt_token_ids` is `[]` would + hand someone a download named `contract.json` containing no contract, and a file on disk is far + more convincing than an empty list in a JSON blob. + """ + import tempfile + from pathlib import Path as _Path + + turns = r.get("turns") or [] + if not turns or r.get("rollout_type", "train") == "eval": + return None + from .contract import export_training_contract + from .models import HarborRolloutResult + + contract = export_training_contract(HarborRolloutResult.model_validate(r)) + name = re.sub(r"[^A-Za-z0-9_.-]", "_", str(r.get("task_name") or "rollout")) + target = ( + _Path(tempfile.mkdtemp(prefix="harbor-contract-")) / f"{name}.contract.json" + ) + target.write_text(json.dumps(contract, indent=2)) + return str(target) + + +def _summary_json(r: dict[str, Any]) -> str: + """The result with the token arrays summarised, which is the part anyone actually reads. + + The full document stays available below; printing 8000 integers first buries the fields that + carry meaning. + """ + compact = {k: v for k, v in r.items() if k not in ("turns", "conversations")} + compact["turns"] = [ + { + "turn": t.get("turn"), + "action": [c.get("name") for c in (t.get("tool_calls") or [])] or "text", + "prompt_token_ids": f"<{len(t.get('prompt_token_ids') or [])} ids>", + "completion_token_ids": f"<{len(t.get('completion_token_ids') or [])} ids>", + "per_token_logps": f"<{len(t.get('per_token_logps') or [])} floats>", + "finish_reason": t.get("finish_reason"), + "discarded": t.get("discarded"), + "text": (t.get("text") or "")[:200], + } + for t in (r.get("turns") or [])[:200] + ] + compact["conversations"] = [ + { + "role": c.get("role"), + "n_turns": c.get("n_turns"), + "messages": f"<{len(c.get('messages') or [])} messages>", + } + for c in (r.get("conversations") or []) + ] + return json.dumps(compact, indent=2)[:200_000] + + +def _read(path: Any, limit: int = 20000) -> str: + try: + text = path.read_text(errors="replace") + except Exception: # noqa: BLE001 + return "" + return text if len(text) <= limit else text[:limit] + "\n…truncated…" + + +def harbor_gradio_builder( + *, + datasets: list[str] | None = None, + title: str | None = None, +) -> gr.Blocks: + """Build the Harbor UI. + + Args: + datasets (`list[str]`, *optional*): + Dataset specs served by this server; each becomes a selectable split. + + Returns: + `gr.Blocks`: The interface. + """ + from .tasks import HarborTaskProvider, resolve_task_dirs + + datasets = list(datasets or []) + + def on_validate( + url: str, + model: str, + api_key: str, + provider: str = "openai", + purpose: str = "eval", + include_experimental: bool = False, + ): + from openenv.core.harness.capture.validate_llm import list_models, validate_llm + + from .capabilities import capabilities + from .seams import agent_facing_model, get as get_seam + from .serving import HarborService + + url = (url or "").strip().rstrip("/") + api_key = (api_key or "").strip() or None + if not url: + return ( + _UNVALIDATED, + gr.update(), + gr.update(), + {}, + gr.update(interactive=False), + ) + + if not model: + served = list_models(url, api_key=api_key) + if len(served) != 1: + hint = ( + f"`{', '.join(served[:12])}`" + if served + else "nothing reachable — check the URL, and the API key if it needs one" + ) + return ( + f"**Pick a model** — this endpoint serves {hint}.", + gr.update(), + gr.update(), + {}, + gr.update(interactive=False), + ) + model = served[0] + + report = validate_llm(url, model, api_key=api_key, provider=provider) + if not report.reachable or (purpose == "train" and not report.trainable): + why = "; ".join(report.findings) or ( + "Exact engine tokens are required for training" + if report.reachable + else "unreachable" + ) + return ( + f"**Not usable** — {why}\n\n" + "Needs vLLM with `--return-tokens-as-token-ids --logprobs-mode " + "processed_logprobs`, SGLang built from git main, or any reachable OpenAI-spec " + "endpoint (with an API key) for eval rollouts.", + gr.update(), + gr.update(), + {}, + gr.update(interactive=False), + ) + + caps = capabilities( + datasets=datasets, + llm={ + "url": url, + "model": model, + "ok": report.ok, + "capture_level": report.capture_level, + "reachable": True, + "authenticated": bool(api_key), + }, + ) + sandboxes = caps.available_sandboxes + from .qualification import harness_maturity_rows + + try: + evidence = ( + json.loads(Path(report_path).read_text()) if report_path else None + ) + tiers = { + name: tier + for name, tier, _ in harness_maturity_rows( + [h.name for h in caps.harnesses], evidence + ) + } + except (OSError, ValueError, TypeError): + tiers = {h.name: "experimental" for h in caps.harnesses} + evidence = None + profile_provider = "vllm" if purpose == "train" else provider + harness_profiles = {} + unavailable_profiles = set() + for cell in (evidence or {}).get("cells", []): + if cell.get("provider") != profile_provider: + continue + config = cell.get("configuration") or {} + profile = config.get("acp_profile") or config.get("nemo_profile") + if profile: + name = cell["harness"] + harness_profiles[name] = profile + try: + get_seam(name, profile=profile) + except (ValueError, KeyError): + unavailable_profiles.add(name) + choices = [ + ( + f"{h.name} ({h.dialect}; {tiers[h.name]}" + + ( + f"; profile: {harness_profiles[h.name]}" + if h.name in harness_profiles + else "" + ) + + ")", + h.name, + ) + for h in sorted(caps.harnesses, key=lambda h: h.name) + if h.name not in unavailable_profiles + and ( + tiers[h.name] == "stable" + or (include_experimental and tiers[h.name] == "experimental") + ) + ] + values = [v for _, v in choices] + + leaf = agent_facing_model(model) + if purpose == "train": + lines = [ + f"**Endpoint ready — TRAINING CAPTURE** · `{model}` · token ids + logprobs ✓" + ] + else: + detail = ( + "token capture available; training export disabled for this eval" + if report.capture_level == "tokens" + else "logprobs, no token ids" + if report.capture_level == "logprobs" + else "no token ids, no logprobs" + ) + lines = [ + f"**Endpoint ready — EVAL** · `{model}` · {detail}", + "Rollouts carry the reward and the full trace, but nothing trainable.", + ] + if leaf != model: + lines.append(f"Sent to agents as `{leaf}`, rewritten back on the way out.") + if not values: + lines.append( + "No agents match the support filter. Load qualification evidence or explicitly include experimental adapters." + ) + for fix in report.param_fixes: + lines.append(f"upstream compat: {fix}") + # The one thing a user cannot discover by reading the endpoint's own docs: whether a model + # will actually sustain an agent loop here. Shown at Validate rather than after a rollout, + # because a rollout costs a sandbox and several minutes to learn the same thing. + for finding in report.findings: + if "behaviour_changed" in finding or "tool_call" in finding: + detail = finding.split(": ", 2)[-1] + lines.append(f"⚠️ {detail}") + + # Run uses the endpoint typed above. The engine is a per-rollout argument, so a browser can + # point this server at any reachable OpenAI-spec endpoint without restarting it — which is + # the whole point of validating a URL here. Say which one will be used, because a server may + # also have been booted with a default and the two can differ. + service = HarborService.current() + if ( + service is not None + and service.llm_url + and service.llm_url.rstrip("/") != url + ): + lines.append( + f"Rollouts will use **this** endpoint, not the server's default " + f"(`{service.llm_url}`)." + ) + lines.append( + f"Sandboxes: {', '.join(f'`{s}`' for s in sandboxes) or '**none usable**'}" + ) + blocked = [s.name for s in caps.sandboxes if not s.available] + if blocked: + lines.append( + f"unavailable: {', '.join(blocked)}" + ) + + return ( + " \n".join(lines), + gr.update( + choices=choices, + value="opencode" + if "opencode" in values + else (values[0] if values else None), + ), + gr.update(choices=sandboxes, value=sandboxes[0] if sandboxes else None), + # `ok` gates the Run button and now means "reachable", not "trainable": an eval endpoint + # is a perfectly good thing to press Run against. + { + "url": url, + "model": model, + "ok": True, + "capture_level": report.capture_level, + "trainable": report.trainable, + # Carried so Run can reach a token-gated endpoint. Without it, validating a hosted + # provider succeeded and pressing Run then failed to authenticate against the same + # URL. `gr.State` is held server-side and this is never rendered back into the page, + # which is the same rule the API key box itself follows. + "api_key": api_key or "", + "provider": provider, + "purpose": purpose, + "allowed_harnesses": values, + "harness_profiles": harness_profiles, + }, + gr.update( + interactive=bool(sandboxes and values), + value="Run training capture" + if purpose == "train" + else "Run eval rollout", + ), + ) + + def on_dataset(spec: str): + if not spec: + return gr.update(), "" + try: + n = len(resolve_task_dirs(spec)) + except Exception as exc: # noqa: BLE001 + return gr.update(value=0), f"Cannot load `{spec}` — {exc}" + return gr.update(value=0), f"**{n}** tasks · 0–{n - 1}" + + def on_task(spec: str, index: int): + if not spec: + return "", "", "", "", "" + try: + task_dir = HarborTaskProvider([spec]).task_dir(spec, int(index)) + except Exception as exc: # noqa: BLE001 + return f"_{exc}_", "", "", "", "" + env_dir, tests_dir = task_dir / "environment", task_dir / "tests" + return ( + f"`{task_dir.name}`", + _read(task_dir / "instruction.md"), + _read(env_dir / "Dockerfile"), + _read(task_dir / "task.toml"), + _read(tests_dir / "test.sh"), + ) + + def on_run(engine: dict, spec: str, index: int, harness: str, sandbox: str): + """Stream progress while the rollout runs, then the result and its graph.""" + import asyncio + import queue + import threading + import time + from pathlib import Path + + from .rollout import run_rollout as _run + from .serving import HarborService + + if not engine.get("ok"): + yield _UNVALIDATED, "", "", "{}", None, gr.update(interactive=True) + return + if harness not in engine.get("allowed_harnesses", []): + yield ( + "Selected harness is outside the validated support filter. Validate again.", + "", + "", + "{}", + None, + gr.update(interactive=False), + ) + return + service = HarborService.current() + if service is None: + yield ( + "Server not initialised — no capture proxy running.", + "", + "", + "{}", + None, + gr.update(interactive=True), + ) + return + try: + task_dir = HarborTaskProvider([spec]).task_dir(spec, int(index)) + except Exception as exc: # noqa: BLE001 + yield ( + f"Bad task — {html.escape(str(exc))}", + "", + "", + "{}", + None, + gr.update(interactive=True), + ) + return + + done: queue.Queue = queue.Queue(maxsize=1) + live_sessions: queue.Queue[str] = queue.Queue(maxsize=1) + + async def _run_with_engine(): + """Resolve the engine the user validated, then run against it. + + The engine is per rollout, so the URL in the box is the one used. Resolving it through the + capture server's pool means the tier comes from a real probe of that endpoint rather than + from whatever the server happened to boot with — and the probe is cached, so pressing Run + repeatedly costs nothing after the first time. + """ + from openenv.core.harness.capture.sessions import Upstream + + pool = service.capture.app.state.upstreams + typed_url = str((engine or {}).get("url") or "").strip() + if typed_url: + upstream = Upstream( + llm_url=typed_url, + model=str((engine or {}).get("model") or ""), + api_key=str((engine or {}).get("api_key") or "") or None, + provider=str((engine or {}).get("provider") or "openai"), + ) + client, level = await pool.resolve(upstream) + served = client.served_model or upstream.model + else: + # Nothing validated in the box: fall back to the server's default, which is what a + # server booted with --llm-url provides. With neither, the rollout reports the + # missing engine rather than silently producing an untrainable result. + upstream, (client, level) = None, pool.default + level = getattr(service, "capture_level", "text") + served = service.model + return await _run( + task_dir=task_dir, + harness=harness, + harness_profile=engine.get("harness_profiles", {}).get(harness), + sandbox=sandbox, + registry=service.capture.registry, + intercept_url=service.public_url, + model=served, + trials_dir=Path("/tmp/openenv-harbor-trials"), + dataset=spec, + capture_level=level, + purpose=str((engine or {}).get("purpose") or "eval"), + upstream=upstream, + inference=client, + on_session_created=live_sessions.put_nowait, + ) + + def worker() -> None: + try: + res = asyncio.run(_run_with_engine()) + done.put(("ok", res.model_dump())) + except Exception as exc: # noqa: BLE001 - show it, never take the server down + done.put(("err", f"{type(exc).__name__}: {exc}")) + + thread = threading.Thread(target=worker, daemon=True) + started = time.monotonic() + thread.start() + session_id = None + + while thread.is_alive(): + if session_id is None: + try: + session_id = live_sessions.get_nowait() + except queue.Empty: + pass + stats, phase, stage = None, "starting up", 0 + if session_id: + session = service.capture.registry.get(session_id) + if session is not None: + st = session.graph.stats() + # n_trainable_tokens only exists after export; mid-run we can count only what + # has been sampled, before masking and discards. + sampled = sum( + len(n.sampled_ids or []) for n in session.graph.nodes() + ) + turns = st.get("n_turns", 0) + stats = { + "calls": turns, + "roots": st.get("n_roots", 0), + "sampled tokens": sampled, + "discarded": st.get("n_discarded", 0), + } + if turns: + stage = -1 # past setup; the numbers mean something now + phase = "agent working" + # Only meaningful once a call has landed. Before that `idle_seconds` counts + # from session creation, which renders as a stall during a normal boot. + stats["since last call"] = f"{session.idle_seconds:.0f}s" + else: + stage = 0 + phase = "preparing the run or waiting for its first response" + # The transcript rides in the graph slot: it is empty until the run finishes anyway, + # and the two answer the same question at different times. + transcript = "" + if session_id: + live = service.capture.registry.get(session_id) + if live is not None: + transcript = _transcript_html(live) + yield ( + _live_html( + harness, sandbox, phase, time.monotonic() - started, stats, stage + ), + transcript, + "", + "{}", + None, + gr.update(interactive=False), + ) + time.sleep(2.0) + + kind, payload = done.get() + if kind == "err": + yield ( + f'
Run failed
' + f'
{html.escape(payload)}
', + "", + "", + "{}", + None, + gr.update(interactive=True), + ) + return + contract = None + contract_error = "" + try: + contract = _write_contract(payload) + except (ValueError, TypeError) as exc: + contract_error = ( + "

Training export rejected: " + html.escape(str(exc)) + "

" + ) + yield ( + _result_html(payload) + contract_error, + _conversation_html(payload), + _turns_html(payload), + _summary_json(payload), + contract, + gr.update(interactive=True), + ) + + with gr.Blocks(title=title or "Harbor") as app: + gr.HTML(f"") + state = gr.State({}) + + with gr.Column(elem_classes="hb-wrap"): + gr.Markdown( + "## Harbor task playground\nChoose a model, validate the connection, then run an agent " + "on a task. Follow its tool calls and results below. " + "Evaluation works with supported hosted providers; training capture requires " + "verified engine token IDs and log probabilities." + ) + + with gr.Row(equal_height=False): + # left — the model + with gr.Column(scale=1, elem_classes="hb-cell"): + with gr.Column(elem_classes="hb-panel"): + gr.Markdown("### 1 · Connect a model") + # Deliberately empty. Prefilling meant the box already held whatever URL the + # server was started with, so Validate confirmed a value nobody chose and a + # stale endpoint could be used without anyone noticing it was stale. + provider_in = gr.Dropdown( + label="Upstream provider", + choices=[ + ("OpenAI-compatible", "openai"), + ("Anthropic native", "anthropic"), + ("Hugging Face Inference Providers", "hf"), + ("vLLM", "vllm"), + ], + value="openai", + info="Select the upstream API. Exact training tokens are verified separately.", + ) + purpose_in = gr.Dropdown( + label="Use", + choices=[ + ("Evaluation", "eval"), + ("Training capture", "train"), + ], + value="eval", + ) + url_in = gr.Textbox( + label="LLM URL", + placeholder="https://your-endpoint/v1", + info="vLLM, SGLang, OpenAI, Anthropic, HF Inference Providers. " + "Accepts a bare root or one ending in /v1.", + ) + gr.HTML(_labelled("API key (optional)", _KEY_TIP)) + key_in = gr.Textbox( + label="", + type="password", + placeholder="only for a hosted provider", + show_label=False, + ) + model_in = gr.Textbox( + label="Model (optional)", + placeholder="read from the endpoint", + info="Required when the endpoint serves more than one model.", + ) + validate_btn = gr.Button( + "Validate connection", variant="secondary" + ) + gr.HTML(_labelled("Capture level", _LEVEL_TIP)) + engine_md = gr.Markdown(_UNVALIDATED) + + # right — task preview and agent. Implementation files stay folded away. + with gr.Column(scale=1, elem_classes="hb-cell"): + gr.Markdown("### 2 · Choose a task") + with gr.Row(): + ds_in = gr.Dropdown( + label="Dataset", + choices=datasets, + value=datasets[0] if datasets else None, + scale=3, + ) + idx_in = gr.Number( + label="Index", value=0, precision=0, minimum=0, scale=1 + ) + count_md = gr.Markdown() + task_md = gr.Markdown() + instruction_box = gr.Code( + label="Task instruction", language="markdown", lines=10 + ) + with gr.Accordion("Task files and grader", open=False): + with gr.Accordion("Dockerfile", open=False): + dockerfile_box = gr.Code( + label="", language="dockerfile", lines=12 + ) + with gr.Accordion("task.toml", open=False): + toml_box = gr.Code(label="", language="python", lines=12) + with gr.Accordion("Grader", open=False): + tests_box = gr.Code(label="", language="shell", lines=12) + + with gr.Column(elem_classes="hb-panel"): + gr.Markdown("### 3 · Choose an agent") + experimental_in = gr.Checkbox( + label="Include experimental adapters", + value=False, + info="Stable adapters are shown by default. Unstable adapters remain excluded.", + ) + harness_in = gr.Dropdown( + label="Agent", + choices=[], + info="The coding agent to run. Its dialect is shown in brackets; the " + "capture proxy connects it to your selected provider.", + ) + sandbox_in = gr.Dropdown( + label="Sandbox", + choices=[], + info="Where the agent executes. Harbor's backends, not OpenEnv's " + "container providers — only those with working credentials are listed.", + ) + + # Full width, under both columns: the action belongs to the pair, not to either one. + run_btn = gr.Button( + "Run rollout", variant="primary", interactive=False, scale=1 + ) + + with gr.Column(elem_classes="hb-panel"): + gr.Markdown("### Run status") + result_html = gr.HTML( + '

Validate your model and choose a task to begin.

' + ) + with gr.Column(elem_classes="hb-panel"): + gr.Markdown( + "### Live trace\nAgent messages, tool calls and tool results appear as " + "model calls complete. A request in progress may take a moment." + ) + convo_html = gr.HTML() + with gr.Accordion("Token details and training export", open=False): + analysis_html = gr.HTML() + contract_file = gr.File( + label="Training contract — captured tokens, log probabilities and reward", + interactive=False, + visible=True, + ) + with gr.Accordion("Harness/provider qualification evidence", open=False): + import os + from pathlib import Path + + from .qualification import ( + harness_maturity_rows, + qualification_details, + qualification_rows, + ) + from .seams import SEAMS + + report_path = os.environ.get("OPENENV_HARBOR_QUALIFICATION_REPORT", "") + + def read_qualification_evidence(): + try: + evidence = ( + json.loads(Path(report_path).read_text()) + if report_path + else None + ) + return ( + qualification_rows(list(SEAMS), evidence), + qualification_details(evidence), + harness_maturity_rows(list(SEAMS), evidence), + "Loaded recorded evidence." + if evidence + else "No qualification report configured.", + ) + except (OSError, ValueError, TypeError) as exc: + return ( + qualification_rows(list(SEAMS)), + [], + harness_maturity_rows(list(SEAMS)), + "Invalid qualification report: " + str(exc), + ) + + evidence_rows, evidence_details, maturity_rows, evidence_status = ( + read_qualification_evidence() + ) + gr.Markdown( + "Recorded results apply to the listed model, harness version, and captures. " + "They do not certify the endpoint currently selected above. " + "Capture/reader passes exclude optimizer validation; optimizer details state " + "whether the test used diagnostic replay and whether it covered weight sync." + ) + evidence_status_md = gr.Markdown(evidence_status) + gr.Markdown( + "Stable means all four recorded profiles passed, including optimizer replay. " + "It is limited to this test coverage, not a production-scale guarantee. " + "Experimental adapters have partial or pending support; unstable adapters " + "have no passing profile in the recorded matrix." + ) + maturity_table = gr.Dataframe( + headers=["Harness", "Maturity", "Qualification scope"], + value=maturity_rows, + interactive=False, + ) + evidence_table = gr.Dataframe( + headers=[ + "Harness", + "OpenAI eval", + "Anthropic eval", + "HF eval", + "vLLM training", + ], + value=evidence_rows, + interactive=False, + ) + evidence_detail_table = gr.Dataframe( + headers=[ + "Harness", + "Provider", + "Status", + "Model", + "Harness version", + "Tasks", + "Workflow profile", + "Optimizer scope", + "Optimizer model revision", + "Capture evidence", + "Reason", + ], + value=evidence_details, + interactive=False, + ) + refresh_evidence = gr.Button("Refresh recorded evidence") + refresh_evidence.click( + read_qualification_evidence, + [], + [ + evidence_table, + evidence_detail_table, + maturity_table, + evidence_status_md, + ], + ) + with gr.Accordion("Result JSON", open=False): + raw_json = gr.Code(language="json", lines=22) + + validate_btn.click( + on_validate, + [url_in, model_in, key_in, provider_in, purpose_in, experimental_in], + [engine_md, harness_in, sandbox_in, state, run_btn], + ) + for setting in ( + url_in, + model_in, + key_in, + provider_in, + purpose_in, + experimental_in, + ): + setting.change( + lambda: ({}, gr.update(interactive=False), _UNVALIDATED), + outputs=[state, run_btn, engine_md], + ) + ds_in.change(on_dataset, [ds_in], [idx_in, count_md]) + ds_in.change( + on_task, + [ds_in, idx_in], + [task_md, instruction_box, dockerfile_box, toml_box, tests_box], + ) + idx_in.change( + on_task, + [ds_in, idx_in], + [task_md, instruction_box, dockerfile_box, toml_box, tests_box], + ) + run_btn.click( + on_run, + [state, ds_in, idx_in, harness_in, sandbox_in], + [result_html, convo_html, analysis_html, raw_json, contract_file, run_btn], + ) + + if datasets: + app.load(on_dataset, [ds_in], [idx_in, count_md]) + app.load( + on_task, + [ds_in, idx_in], + [task_md, instruction_box, dockerfile_box, toml_box, tests_box], + ) + return app diff --git a/tests/envs/test_capture_inprocess_trace.py b/tests/envs/test_capture_inprocess_trace.py new file mode 100644 index 0000000000..6834a3aaba --- /dev/null +++ b/tests/envs/test_capture_inprocess_trace.py @@ -0,0 +1,177 @@ +# Copyright 2026 The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Reading a rollout back out of a live `CaptureServer`, in process. + +This is the path `CaptureServer` exists for, and why it runs as a thread rather than a subprocess: +the caller mints a session on the registry the proxy is writing into, then reads the graph straight +back out of it. Going through HTTP for that would add a serialisation round trip and a failure mode +for no benefit -- and it is also how a caller ends up never deleting the session, because over HTTP +there is no obvious place to. + +The property under test is the one the whole training contract rests on: turn k+1's prompt IS turn +k's prompt plus its completion, so turns link by exact token prefix. When that breaks, one +conversation silently fragments into several short ones and every fragment still trains. + +A stub engine stands in for vLLM so this needs no GPU. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from openenv.core.harness.capture import to_trace_entries +from openenv.core.harness.capture.export import export_session +from openenv.core.harness.capture.runner import CaptureServer +from openenv.core.harness.capture.sessions import Upstream + + +LLM_URL = "http://engine.invalid/v1" +MODEL = "test-model" + + +class _TokenEngine: + """A vLLM served with `--return-tokens-as-token-ids --logprobs-mode processed_logprobs`.""" + + served_model = MODEL + param_fixes: dict[str, Any] = {} + capture_level = "tokens" + + def __init__(self) -> None: + self.turn = 0 + # The prompt grows by the previous turn's completion. Faking that relationship is the only + # way the graph's prefix linking can be exercised at all. + self._prompt = [1, 2, 3] + + async def completion(self, request: dict[str, Any]) -> dict[str, Any]: + self.turn += 1 + prompt = list(self._prompt) + completion = [100 + self.turn, 200 + self.turn] + self._prompt = prompt + completion + return { + "id": f"c{self.turn}", + "object": "chat.completion", + "model": MODEL, + # `token_ids` on the choice, `prompt_token_ids` on the response: the shape vLLM returns + # under `--return-tokens-as-token-ids`. + "prompt_token_ids": prompt, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": f"turn {self.turn}"}, + "finish_reason": "stop", + "token_ids": completion, + "logprobs": {"content": [{"logprob": -0.5} for _ in completion]}, + } + ], + "usage": { + "prompt_tokens": len(prompt), + "completion_tokens": 2, + "total_tokens": 0, + }, + } + + +@pytest.fixture +def server(): + """A `CaptureServer` that is never `start()`ed -- the registry is what this exercises. + + Binding a port would make the test flaky on a busy machine and would test uvicorn rather than the + contract. + """ + srv = CaptureServer(llm_url=LLM_URL, model=MODEL) + engine = _TokenEngine() + # Seed the ENGINE POOL, not only the default: a session that names its own upstream resolves + # through the pool, which is what lets one server drive a train-tier engine and an eval-tier one + # at the same time. Without this the proxy would probe `engine.invalid` for real. + srv.app.state.upstreams._by_engine[ + Upstream(llm_url=LLM_URL, model=MODEL).cache_key + ] = ( + engine, + "tokens", + ) + srv.app.state.upstreams._default = (engine, "tokens") + return srv + + +def _mint(server, **kwargs): + return server.registry.create( + upstream=Upstream(llm_url=LLM_URL, model=MODEL), + capture_level="tokens", + **kwargs, + ) + + +def _chat(client: TestClient, session_id: str) -> None: + client.post( + "/v1/chat/completions", + headers={"Authorization": f"Bearer {session_id}"}, + json={"model": MODEL, "messages": [{"role": "user", "content": "hi"}]}, + ) + + +def _entries(server, session): + document = export_session( + session, include_messages=True, capture_level=session.capture_level + ) + return to_trace_entries(session.graph, document) + + +def test_entries_carry_the_engines_own_prompt_tokens(server): + session = _mint(server) + with TestClient(server.app) as client: + for _ in range(3): + _chat(client, session.session_id) + + entries = _entries(server, session) + assert len(entries) == 3 + for entry in entries: + assert entry["prompt_token_ids"], ( + "an entry came back with no engine tokenisation" + ) + assert entry["completion_token_ids"] + assert len(entry["per_token_logps"]) == len(entry["completion_token_ids"]) + # The mask spans prompt + completion, and only the completion is trained. + assert len(entry["loss_mask"]) == len(entry["prompt_token_ids"]) + len( + entry["completion_token_ids"] + ) + assert set(entry["loss_mask"][: len(entry["prompt_token_ids"])]) == {0} + + # THE CONTRACT: turn k+1's prompt is everything before it, token for token. + first, second = entries[0], entries[1] + assert ( + second["prompt_token_ids"] + == first["prompt_token_ids"] + first["completion_token_ids"] + ) + + +def test_deleting_a_session_releases_it(server): + session = _mint(server) + sid = session.session_id + assert server.registry.get(sid) is not None + assert server.registry.delete(sid) + # Sessions held past their rollout collide with the next run's claim and surface as a burst of + # CAPACITY_REACHED on a server that looks idle, so this is not bookkeeping. + assert server.registry.get(sid) is None + + +def test_a_budget_bounds_what_is_captured(server): + session = _mint(server, max_model_calls=2) + with TestClient(server.app) as client: + for _ in range(5): + _chat(client, session.session_id) + # Five requests, two captured: the proxy answered the rest itself, before ingest. + assert len(_entries(server, session)) == 2 diff --git a/tests/envs/test_capture_logprobs_mode_sentinel.py b/tests/envs/test_capture_logprobs_mode_sentinel.py new file mode 100644 index 0000000000..d30f7396dc --- /dev/null +++ b/tests/envs/test_capture_logprobs_mode_sentinel.py @@ -0,0 +1,86 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""`probe_logprobs_mode` must say "unknown", not "raw", when the distribution is saturated. + +The probe decides raw-vs-processed from how the top-two logprob GAP scales with temperature: `T1/T2` +when processed, `1.0` when raw. It measures at completion position 1 because that is the only +position independent of sampling. + +But a reasoning model's chat template FORCES its opening token -- `` for Qwen3, at p~1.0 with +every alternative at -inf. Engines report -inf as a sentinel (vLLM: -9999), and +-inf is unchanged by +division, so the gap is identical at both temperatures and the ratio is 1.0. The probe then reports +"raw", `validate_llm` demotes `capture_level` from `tokens` to `logprobs`, and every rollout comes +back 409 -- which reads as "this model cannot train" when the truth is "this probe cannot measure +here". + +Measured on a live Qwen3-8B served WITH `--logprobs-mode processed_logprobs`: + + position 1 (forced ``) gap 9999.0 @T=1.0 -> 9999.0 @T=2.0 ratio 1.000 + position 1, prefilled past `` gap 3.7500 @T=1.0 -> 1.8750 @T=2.0 ratio 0.500 + +So "raw" was wrong about a correctly-configured engine. "unknown" is what the function documents for +this case: not a failure, only an absence of evidence -- and unlike "raw" it does not demote the tier. +""" + +from __future__ import annotations + +import pytest + +validate_llm = pytest.importorskip("openenv.core.harness.capture.validate_llm") + + +def _payload(top: list[float]) -> dict: + """A chat completion whose first position carries `top` as its `top_logprobs`.""" + return { + "choices": [ + {"logprobs": {"content": [{"top_logprobs": [{"logprob": v} for v in top]}]}} + ] + } + + +def _probe_with(monkeypatch, per_temperature: list[list[float]]) -> str: + """Run the probe against canned responses, one per temperature it asks about.""" + answers = list(per_temperature) + + def fake_post(url, body, timeout, api_key=None, auth_header="Authorization"): + return _payload(answers.pop(0)) + + monkeypatch.setattr(validate_llm, "_post", fake_post) + return validate_llm.probe_logprobs_mode("http://engine", "some/model") + + +def test_saturated_gap_is_unknown_not_raw(monkeypatch): + # The real shape: top token at 0.0, runners-up at the -inf sentinel, unchanged by temperature. + mode = _probe_with(monkeypatch, [[0.0, -9999.0, -9999.0], [0.0, -9999.0, -9999.0]]) + assert mode == "unknown", ( + "a sentinel gap is an absence of evidence; calling it 'raw' demotes a correctly-configured " + "engine to the eval tier and every rollout then 409s" + ) + + +def test_genuinely_raw_is_still_detected(monkeypatch): + # The guard must not blind the check it lives in: a real, unchanging gap is still raw. + mode = _probe_with(monkeypatch, [[-1.0, -7.75], [-1.0, -7.75]]) + assert mode == "raw" + + +def test_processed_is_still_detected(monkeypatch): + # Gap halves as temperature doubles -> processed. Matches the measured 3.75 -> 1.875. + mode = _probe_with(monkeypatch, [[-1.0, -4.75], [-1.0, -2.875]]) + assert mode == "processed" + + +def test_flat_distribution_remains_unknown(monkeypatch): + # The pre-existing guard at the other extreme, kept honest alongside the new one. + mode = _probe_with(monkeypatch, [[-1.0, -1.2], [-1.0, -1.1]]) + assert mode == "unknown" + + +def test_sentinel_threshold_admits_real_tail_values(monkeypatch): + # A deep but REAL tail value must still be measured, or the guard would swallow valid data. + mode = _probe_with(monkeypatch, [[-1.0, -41.0], [-1.0, -21.0]]) + assert mode == "processed" diff --git a/tests/envs/test_capture_model_call_budget.py b/tests/envs/test_capture_model_call_budget.py new file mode 100644 index 0000000000..43ce1826f3 --- /dev/null +++ b/tests/envs/test_capture_model_call_budget.py @@ -0,0 +1,297 @@ +# Copyright 2026 The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The per-session model-call budget. + +Written because the thing it replaces was imaginary. `agent.build.steps` is a real key in opencode's +schema and is simply not honoured -- measured against a fake engine that always asks for one more +tool call, `steps=3`, `maxSteps=3` and no setting at all each produced 61 model calls. So the tests +that matter here are the two that distinguish a real cap from a decorative one: that the (n+1)th call +is never forwarded, and that it never enters the capture graph. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from openenv.core.harness.capture.server import create_app +from openenv.core.harness.capture.upstream import UpstreamHTTPError + + +class _CountingEngine: + """Stands in for vLLM. Records what it was asked to do, so 'not forwarded' is observable.""" + + def __init__(self) -> None: + self.calls = 0 + self.served_model = "test-model" + self.param_fixes: dict[str, Any] = {} + self.capture_level = "text" + + async def completion(self, request: dict[str, Any]) -> dict[str, Any]: + self.calls += 1 + return { + "id": f"c{self.calls}", + "object": "chat.completion", + "model": self.served_model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": f"turn {self.calls}"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + +@pytest.fixture +def app_and_engine(): + app = create_app( + llm_url="http://engine.invalid/v1", model="test-model", capture_level="text" + ) + engine = _CountingEngine() + app.state.inference = engine + # The pool captured the real client at create_app time, so replacing `app.state.inference` alone + # leaves every request going to `engine.invalid`. Sessions here name no upstream, so they take the + # pool's default and this is the hook that matters. + app.state.upstreams._default = (engine, "text") + return app, engine + + +def _chat(client: TestClient, session_id: str) -> Any: + return client.post( + "/v1/chat/completions", + headers={"Authorization": f"Bearer {session_id}"}, + json={"model": "test-model", "messages": [{"role": "user", "content": "hi"}]}, + ) + + +@pytest.mark.parametrize("stream", [False, True]) +def test_context_limit_stops_without_fabricating_a_captured_turn( + app_and_engine, stream +): + app, engine = app_and_engine + session = app.state.registry.create(max_model_calls=17) + with TestClient(app) as client: + _chat(client, session.session_id) + before = session.graph.stats()["n_turns"] + + async def too_long(request): + raise UpstreamHTTPError( + 400, + { + "error": { + "message": ( + "This model's maximum context length is 131072 tokens. " + "However, you requested 4096 output tokens and your prompt contains " + "at least 126977 input tokens." + ) + } + }, + ) + + engine.completion = too_long + response = client.post( + "/v1/chat/completions", + headers={"Authorization": f"Bearer {session.session_id}"}, + json={ + "model": "test-model", + "messages": [{"role": "user", "content": "long"}], + "stream": stream, + }, + ) + assert response.status_code == 200 + assert session.budget_stop_count == 1 + assert session.upstream_errors == 0 + assert session.graph.stats()["n_turns"] == before + assert any("context_budget_exhausted" in finding for finding in session.findings) + from openenv.core.harness.capture.export import export_session + + document = export_session(session, capture_level="text") + assert not any( + "degenerate_rollout" in finding for finding in document["validation"] + ) + if stream: + assert response.headers["content-type"].startswith("text/event-stream") + assert '"finish_reason":"stop"' in response.text.replace(" ", "") + else: + assert response.json()["choices"][0]["finish_reason"] == "stop" + + +@pytest.mark.parametrize( + "upstream_status, expected_status", [(400, 400), (413, 413), (422, 422), (500, 502)] +) +def test_other_upstream_errors_are_not_converted_to_budget_stops( + app_and_engine, upstream_status, expected_status +): + app, engine = app_and_engine + session = app.state.registry.create() + + async def invalid(request): + raise UpstreamHTTPError( + upstream_status, {"error": {"message": "invalid tools"}} + ) + + engine.completion = invalid + with TestClient(app) as client: + assert _chat(client, session.session_id).status_code == expected_status + assert session.budget_stop_count == 0 + assert session.upstream_errors == 1 + assert session.graph.stats()["n_turns"] == 0 + + +def test_single_turn_without_recorded_budget_stop_still_fails(app_and_engine): + from openenv.core.harness.capture.export import export_session + + app, _ = app_and_engine + session = app.state.registry.create() + with TestClient(app) as client: + _chat(client, session.session_id) + document = export_session(session, capture_level="text") + assert any( + "[FATAL] degenerate_rollout" in finding for finding in document["validation"] + ) + + +def test_budget_stop_does_not_make_an_empty_capture_valid(app_and_engine): + from openenv.core.harness.capture.export import export_session + + app, _ = app_and_engine + session = app.state.registry.create() + session.budget_stop_count = 1 + document = export_session(session, capture_level="text") + assert any("[FATAL] no_turns" in finding for finding in document["validation"]) + + +def test_budget_stops_forwarding_at_the_cap(app_and_engine): + app, engine = app_and_engine + session = app.state.registry.create(max_model_calls=3) + with TestClient(app) as client: + for _ in range(5): + assert _chat(client, session.session_id).status_code == 200 + # Five requests, three forwarded. Without the cap the engine would see all five. + assert engine.calls == 3 + assert session.model_calls == 3 + + +def test_the_capped_turn_is_terminal_and_never_captured(app_and_engine): + app, engine = app_and_engine + session = app.state.registry.create(max_model_calls=1) + with TestClient(app) as client: + _chat(client, session.session_id) + over = _chat(client, session.session_id).json() + + # Terminal: this is what actually ends the agent's loop. opencode exits 0 on it. + assert over["choices"][0]["finish_reason"] == "stop" + assert not over["choices"][0]["message"].get("tool_calls") + # Non-empty: an empty assistant message reads as a failed generation and is retried. See + # `test_the_stop_message_is_not_empty`. + assert over["choices"][0]["message"]["content"].strip() + # And it is not in the graph. A synthetic turn in the training data is the failure this guards. + assert session.graph.stats()["n_turns"] == 1 + + # The harness may put the terminal response in ATIF. The independent cross-check needs + # explicit evidence that this zero-token step came from the proxy, rather than the model. + from openenv.core.harness.capture.export import export_session + from openenv.harbor.atif import reconcile + + document = export_session(session, capture_level="text") + trace = { + "steps": [ + { + "source": "agent", + "message": "turn 1", + "metrics": {"completion_tokens": 1}, + }, + { + "source": "agent", + "message": over["choices"][0]["message"]["content"], + "metrics": over["usage"], + }, + ] + } + assert document["budget_stop_count"] == 1 + assert not any( + "degenerate_rollout" in finding for finding in document["validation"] + ) + report = reconcile(document, trace) + assert "proxy_budget_stops" in {f.code for f in report.findings} + assert "atif_calls_missing" not in {f.code for f in report.findings} + + +def test_the_stop_is_streamed_when_the_caller_streams(app_and_engine): + """A streaming caller must get SSE back, not a JSON body. + + This is the failure that made the cap useless in practice. opencode streams; answering it with a + plain JSON body did not end its loop, so it retried, the proxy answered the stop again, and the + rollout spun until its timeout -- "budget enforced" in the log, forever. + """ + app, engine = app_and_engine + session = app.state.registry.create(max_model_calls=1) + with TestClient(app) as client: + _chat(client, session.session_id) # spends the budget + over = client.post( + "/v1/chat/completions", + headers={"Authorization": f"Bearer {session.session_id}"}, + json={ + "model": "test-model", + "messages": [{"role": "user", "content": "hi"}], + "stream": True, + }, + ) + + assert over.status_code == 200 + assert over.headers["content-type"].startswith("text/event-stream") + body = over.text + assert "data: " in body and "[DONE]" in body + # The terminal signal has to be in the stream, or the loop never learns it should stop. + assert '"finish_reason": "stop"' in body or '"finish_reason":"stop"' in body + # And still nothing synthetic in the graph. + assert session.graph.stats()["n_turns"] == 1 + + +def test_the_stop_message_is_not_empty(app_and_engine): + """An empty assistant message reads as a failed generation and gets retried.""" + app, engine = app_and_engine + session = app.state.registry.create(max_model_calls=1) + with TestClient(app) as client: + _chat(client, session.session_id) + over = _chat(client, session.session_id).json() + assert over["choices"][0]["message"]["content"].strip() + + +def test_zero_means_unlimited(app_and_engine): + app, engine = app_and_engine + session = app.state.registry.create() + assert session.max_model_calls == 0 + with TestClient(app) as client: + for _ in range(6): + _chat(client, session.session_id) + assert engine.calls == 6 + assert not session.over_budget + + +def test_budget_is_per_session_not_per_server(app_and_engine): + """One deployment serves a capped training run and an uncapped eval run at the same time.""" + app, engine = app_and_engine + capped = app.state.registry.create(max_model_calls=2) + uncapped = app.state.registry.create() + with TestClient(app) as client: + for _ in range(4): + _chat(client, capped.session_id) + _chat(client, uncapped.session_id) + assert capped.model_calls == 2 + assert uncapped.model_calls == 4 diff --git a/tests/envs/test_capture_session_output_budget.py b/tests/envs/test_capture_session_output_budget.py new file mode 100644 index 0000000000..a12ada6e54 --- /dev/null +++ b/tests/envs/test_capture_session_output_budget.py @@ -0,0 +1,72 @@ +"""One shared proxy applies independent rollout caps without relaxing its own limit.""" + +from concurrent.futures import ThreadPoolExecutor + +import pytest +from fastapi.testclient import TestClient +from openenv.core.harness.capture.server import create_app + + +class Engine: + served_model = "test-model" + capture_level = "text" + param_fixes = {} + + async def completion(self, request): + return { + "id": "cap", + "model": self.served_model, + "choices": [ + { + "message": { + "role": "assistant", + "content": str(request["max_tokens"]), + }, + "finish_reason": "stop", + } + ], + } + + +def test_parallel_rollouts_keep_distinct_caps_and_cannot_raise_server_limit(): + app = create_app( + llm_url="http://unused.invalid", + model="test-model", + capture_level="text", + max_output_tokens=16384, + ) + app.state.upstreams._default = (Engine(), "text") + caps = [4096, 16384, 32768] + sessions = [app.state.registry.create(max_output_tokens=cap) for cap in caps] + with TestClient(app) as client: + + def call(index): + response = client.post( + "/v1/chat/completions", + headers={"Authorization": "Bearer " + sessions[index].session_id}, + json={ + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 32768, + }, + ) + assert response.status_code == 200 + return int(response.json()["choices"][0]["message"]["content"]) + + with ThreadPoolExecutor(max_workers=3) as pool: + assert list(pool.map(call, [0, 1, 2] * 3)) == [4096, 16384, 16384] * 3 + + +@pytest.mark.parametrize("cap", [0, -1, True, 1.5, "4096"]) +def test_invalid_session_budget_never_reaches_inference(cap): + app = create_app( + llm_url="http://unused.invalid", model="test-model", capture_level="text" + ) + session = app.state.registry.create(max_output_tokens=cap) + with TestClient(app) as client: + response = client.post( + "/v1/chat/completions", + headers={"Authorization": "Bearer " + session.session_id}, + json={"messages": [{"role": "user", "content": "hi"}]}, + ) + assert response.status_code == 400 + assert session.model_calls == 0 diff --git a/tests/envs/test_capture_stream_keepalive.py b/tests/envs/test_capture_stream_keepalive.py new file mode 100644 index 0000000000..6e6352ef8f --- /dev/null +++ b/tests/envs/test_capture_stream_keepalive.py @@ -0,0 +1,169 @@ +"""Delayed streaming must stay connected without manufacturing captured tokens.""" + +import asyncio +import json +import socket +import threading + +import httpx +import pytest +from openenv.core.harness.capture import sse +from openenv.core.harness.capture.detection import APIType +from openenv.core.harness.capture.export import export_session +from openenv.core.harness.capture.runner import CaptureServer +from starlette.responses import JSONResponse + + +class DelayedEngine: + served_model = "test-model" + capture_level = "tokens" + param_fixes = {} + api_key = None + + def __init__(self): + self.release = threading.Event() + self.cancelled = threading.Event() + self.calls = 0 + + async def completion(self, request): + self.calls += 1 + assert request["stream"] is False + try: + while not self.release.is_set(): + await asyncio.sleep(0.005) + except asyncio.CancelledError: + self.cancelled.set() + raise + return { + "id": "delayed", + "object": "chat.completion", + "model": self.served_model, + "prompt_token_ids": [1, 2], + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + "token_ids": [3, 4], + "logprobs": { + "content": [ + {"token": "3", "logprob": -0.25}, + {"token": "4", "logprob": -0.5}, + ] + }, + } + ], + "usage": {"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4}, + } + + +@pytest.fixture +def delayed_server(monkeypatch): + monkeypatch.setattr(sse, "KEEPALIVE_INTERVAL_S", 0.02) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + server = CaptureServer( + llm_url="http://127.0.0.1:9/v1", + model="test-model", + port=port, + capture_level="tokens", + ) + engine = DelayedEngine() + server.app.state.inference = engine + server.app.state.upstreams._default = (engine, "tokens") + session = server.app.state.registry.create(max_model_calls=2) + server.start() + try: + yield server, engine, session + finally: + engine.release.set() + server.stop() + + +def test_heartbeat_arrives_before_generation_then_exact_capture(delayed_server): + server, engine, session = delayed_server + with httpx.stream( + "POST", + f"http://127.0.0.1:{server.port}/v1/chat/completions", + headers={"Authorization": "Bearer " + session.session_id}, + json={ + "model": "test-model", + "messages": [{"role": "user", "content": "hi"}], + "stream": True, + }, + timeout=3, + ) as response: + assert response.status_code == 200 + lines = response.iter_lines() + assert next(lines) == ": openenv keepalive" + assert session.graph.stats()["n_turns"] == 0 + assert engine.calls == session.model_calls == 1 + engine.release.set() + text = "\n".join(lines) + assert "hello" in text and "data: [DONE]" in text + document = export_session(session, capture_level="tokens") + assert len(document["turns"]) == 1 + row = document["sequences"][0] + assert row["input_ids"] == [1, 2, 3, 4] + assert row["logprobs"] == [0, 0, -0.25, -0.5] + assert row["loss_mask"] == [0, 0, 1, 1] + + +def test_disconnect_cancels_pending_capture(delayed_server): + server, engine, session = delayed_server + with httpx.stream( + "POST", + f"http://127.0.0.1:{server.port}/v1/chat/completions", + headers={"Authorization": "Bearer " + session.session_id}, + json={ + "model": "test-model", + "messages": [{"role": "user", "content": "hi"}], + "stream": True, + }, + timeout=3, + ) as response: + assert next(response.iter_lines()) == ": openenv keepalive" + assert engine.cancelled.wait(2) + assert session.graph.stats()["n_turns"] == 0 + + +@pytest.mark.parametrize("dialect", list(APIType)) +def test_late_error_is_an_error_event_without_completion(monkeypatch, dialect): + monkeypatch.setattr(sse, "KEEPALIVE_INTERVAL_S", 0.001) + + async def check(): + async def failed(): + await asyncio.sleep(0.01) + return JSONResponse( + {"error": {"message": "engine failed"}}, status_code=502 + ) + + response = await sse.keepalive_response(failed(), dialect) + body = "".join([part async for part in response.body_iterator]) + assert ": openenv keepalive" in body + events = [ + json.loads(line[6:]) + for line in body.splitlines() + if line.startswith("data: ") + ] + assert len(events) == 1 + assert events[0].get("type") == "error" or "error" in events[0] + assert ( + "engine failed" in body and "[DONE]" not in body and "assistant" not in body + ) + + asyncio.run(check()) + + +def test_fast_error_keeps_http_status(): + async def check(): + async def failed(): + return JSONResponse( + {"error": {"message": "invalid request"}}, status_code=400 + ) + + response = await sse.keepalive_response(failed(), APIType.OPENAI_CHAT) + assert response.status_code == 400 + + asyncio.run(check()) diff --git a/tests/envs/test_harbor_acp_profile.py b/tests/envs/test_harbor_acp_profile.py new file mode 100644 index 0000000000..191d01bb9c --- /dev/null +++ b/tests/envs/test_harbor_acp_profile.py @@ -0,0 +1,59 @@ +"""The ACP profile uses Harbor's existing registry schema and isolates credentials.""" + +import json + +import pytest + +pytest.importorskip("harbor.agents.installed.acp") + +from harbor.agents.installed.acp import AcpRegistryEntry +from openenv.harbor.seams import acp_opencode_config, get + + +def test_acp_opencode_profile_is_valid_and_routes_primary_and_auxiliary_calls(): + first = acp_opencode_config("https://capture.example", "session-one", "Qwen3.5-4B") + entry = AcpRegistryEntry.model_validate(first["registry_entry"]) + assert entry.distribution.npx.package == "opencode-ai@1.18.30" + assert entry.distribution.npx.args == ["acp"] + config = json.loads(entry.distribution.npx.env["OPENCODE_CONFIG_CONTENT"]) + assert config["model"] == config["small_model"] == "intercepted/Qwen3.5-4B" + assert ( + config["provider"]["intercepted"]["options"]["baseURL"] + == "https://capture.example/v1" + ) + assert config["provider"]["intercepted"]["options"]["apiKey"] == "session-one" + second = acp_opencode_config("https://other.example", "session-two", "other-model") + assert "session-one" not in json.dumps(second) + assert "session-two" not in json.dumps(first) + assert get("acp").kwargs is None # Generic ACP must not silently select an agent. + + +def test_profile_selection_is_per_rollout_and_preserves_generic_adapter(tmp_path): + from openenv.harbor.rollout import build_trial_config + + generic = get("acp") + config = build_trial_config( + task_dir=tmp_path, + harness="acp", + sandbox="e2b", + intercept_url="https://capture.example", + session_id="session-profile", + model="Qwen3.5-4B", + trial_name="trial", + trials_dir=tmp_path, + harness_profile="opencode-1.18.30", + ) + assert config.agent.model_name == "intercepted/Qwen3.5-4B" + entry = AcpRegistryEntry.model_validate(config.agent.kwargs["registry_entry"]) + assert entry.distribution.npx.package == "opencode-ai@1.18.30" + assert get("acp") is generic + assert generic.kwargs is None + + +def test_unknown_profile_is_not_silently_ignored(): + import pytest + + with pytest.raises(ValueError, match="unsupported harness profile"): + get("acp", profile="unverified-agent") + with pytest.raises(ValueError, match="unsupported harness profile"): + get("codex", profile="opencode-1.18.30") diff --git a/tests/envs/test_harbor_async_contexts.py b/tests/envs/test_harbor_async_contexts.py new file mode 100644 index 0000000000..baf9e51c3a --- /dev/null +++ b/tests/envs/test_harbor_async_contexts.py @@ -0,0 +1,121 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Entry points that are reached from inside a running event loop. + +`asyncio.run` raises `RuntimeError: asyncio.run() cannot be called from a running event loop`, so +any code path that a server can reach has to use `run_async_safely`. This has now bitten three +separate places (the client's MCP calls, the `run_rollout` tool handler, and registry dataset +resolution), and each time it worked in a script and failed under the server, which is the worst +place to find out. +""" + +from __future__ import annotations + +import asyncio +import inspect + +import pytest + +pytest.importorskip("openenv.harbor.tasks") + + +def sources_reachable_from_a_server() -> dict[str, str]: + """Source of the functions a request can reach, where a loop is already running.""" + from openenv.harbor import environment, tasks + from openenv.harbor.client import HarborEnv + + return { + "environment._run_rollout": inspect.getsource( + environment.HarborEnvironment._run_rollout + ), + "tasks._registry_task_dirs": inspect.getsource(tasks._registry_task_dirs), + "client._call": inspect.getsource(HarborEnv._call), + } + + +@pytest.mark.parametrize("name", sorted(sources_reachable_from_a_server())) +def test_no_bare_asyncio_run_on_a_server_reachable_path(name): + """`asyncio.run` here is a runtime error under ASGI, not a style preference.""" + source = sources_reachable_from_a_server()[name] + assert "asyncio.run(" not in source, ( + f"{name} calls asyncio.run, which raises when a loop is already running. " + "Use openenv.core.utils.run_async_safely." + ) + + +def test_run_async_safely_works_with_a_loop_already_running(): + """The property the helper exists for, exercised the way a server would hit it.""" + from openenv.core.utils import run_async_safely + + async def inner() -> str: + await asyncio.sleep(0) + return "ok" + + async def outer() -> str: + # A loop is running right now, which is exactly when asyncio.run would raise. + return run_async_safely(inner()) + + assert asyncio.run(outer()) == "ok" + + +def test_bare_asyncio_run_would_have_failed_here(): + """Pins the failure mode, so the test above cannot be mistaken for a tautology.""" + + async def inner() -> str: + return "ok" + + async def outer() -> str: + return asyncio.run(inner()) + + with pytest.raises( + RuntimeError, match="cannot be called from a running event loop" + ): + asyncio.run(outer()) + + +# --- port forwarding -------------------------------------------------------- +def test_cloudflare_quick_forward_uses_the_tunnel_subcommand(): + """`cloudflared forward` is an alias for `cloudflared access`, a different feature entirely. + + Invoked that way the process prints `Incorrect Usage. flag provided but not defined: -url` and + exits without ever emitting a *.trycloudflare.com URL, so `--expose cloudflare` failed at + startup every time anyone selected it. The quick tunnel is `cloudflared tunnel --url`. + """ + forwarding = pytest.importorskip("openenv.core.harness.capture.forwarding") + + recorded: list[list[str]] = [] + + class _Proc: + stdout = None + + def poll(self): + return None + + def terminate(self): + pass + + forwarder = forwarding.CloudflareForwarder() + forwarder.preflight = lambda *_a, **_k: None + + def fake_popen(cmd, **_kwargs): + recorded.append(cmd) + raise RuntimeError("stop here; the command line is what matters") + + import subprocess + + original = subprocess.Popen + subprocess.Popen = fake_popen + try: + with pytest.raises(Exception): + forwarder.start(8100) + finally: + subprocess.Popen = original + + assert recorded, "start() never built a command" + cmd = recorded[0] + assert "forward" not in cmd, "`forward` is cloudflared access, not a tunnel" + assert cmd[1] == "tunnel" and "--url" in cmd diff --git a/tests/envs/test_harbor_aux_masking.py b/tests/envs/test_harbor_aux_masking.py new file mode 100644 index 0000000000..ec54064ac8 --- /dev/null +++ b/tests/envs/test_harbor_aux_masking.py @@ -0,0 +1,115 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Masking an auxiliary call out of a sequence that also contains real agent turns. + +Auxiliary detection is per node while demotion used to be per sequence, so a sequence mixing an aux +call with genuine agent turns stayed `agent` in full and shipped the aux call as a training turn +credited with the task's reward. Masking the aux node's sampled span is the fix — and getting the span +arithmetic wrong makes the function silently do nothing, which is what happened first: an offset was +advanced as if each turn were only prompt-plus-sampled, so from the second turn on it zeroed +already-masked context and left the real completion tokens at 1. + +The middle turn is the load-bearing case. Masking the FIRST turn works under either arithmetic. +""" + +from __future__ import annotations + +import pytest + +graph_mod = pytest.importorskip("openenv.core.harness.capture.graph") +export_mod = pytest.importorskip("openenv.core.harness.capture.export") +rollout_mod = pytest.importorskip("openenv.harbor.rollout") + + +def chain(lengths, *, context=1): + """A linear agent chain with `context` interstitial tokens between turns.""" + graph = graph_mod.RolloutGraph() + prompt = [1, 2, 3] + for index, n_sampled in enumerate(lengths): + sampled = list(range(100 + index * 50, 100 + index * 50 + n_sampled)) + graph.add_turn( + graph_mod.TurnNode( + node_id=f"n{index}", + prompt_ids=list(prompt), + sampled_ids=sampled, + sampled_logprobs=[-0.1] * n_sampled, + n_tools=1, + ) + ) + prompt = prompt + sampled + [900 + index] * context + return graph + + +def document(graph): + class Session: + session_id = "s" + metadata: dict = {} + findings: list = [] + + session = Session() + session.graph = graph + return export_mod.export_session(session) + + +def span(doc, node_id): + node = next(t for t in doc["turns"] if t["node_id"] == node_id) + return node["n_prompt"], node["n_prompt"] + node["n_sampled"] + + +@pytest.mark.parametrize("aux_index", [0, 1, 2]) +def test_the_aux_span_is_zeroed_wherever_it_sits(aux_index): + """Parametrised across positions because only the non-first cases catch the offset bug.""" + graph = chain([4, 5, 6]) + doc = document(graph) + sequence = doc["sequences"][0] + before = sum(sequence["loss_mask"]) + aux = f"n{aux_index}" + start, end = span(doc, aux) + + rollout_mod._mask_out_nodes(doc, sequence, {aux}) + + assert all(m == 0 for m in sequence["loss_mask"][start:end]), ( + f"the aux node's sampled span {start}:{end} must be fully masked" + ) + expected_removed = [4, 5, 6][aux_index] + assert sum(sequence["loss_mask"]) == before - expected_removed, ( + "exactly the aux node's tokens should stop being targets" + ) + assert sequence["n_trainable"] == sum(sequence["loss_mask"]) + + +def test_the_other_turns_keep_every_target(): + graph = chain([4, 5, 6]) + doc = document(graph) + sequence = doc["sequences"][0] + rollout_mod._mask_out_nodes(doc, sequence, {"n1"}) + for node_id, length in (("n0", 4), ("n2", 6)): + start, end = span(doc, node_id) + assert sum(sequence["loss_mask"][start:end]) == length, ( + f"{node_id} lost targets it should have kept" + ) + + +def test_masking_every_node_leaves_nothing_trainable(): + graph = chain([3, 3]) + doc = document(graph) + sequence = doc["sequences"][0] + rollout_mod._mask_out_nodes(doc, sequence, {"n0", "n1"}) + assert sum(sequence["loss_mask"]) == 0 + assert sequence["n_trainable"] == 0 + assert sequence["trainable"] is False + + +def test_wider_interstitial_context_does_not_shift_the_span(): + """The offset bug scaled with the amount of context between turns, so vary it.""" + graph = chain([4, 5], context=7) + doc = document(graph) + sequence = doc["sequences"][0] + start, end = span(doc, "n1") + rollout_mod._mask_out_nodes(doc, sequence, {"n1"}) + assert all(m == 0 for m in sequence["loss_mask"][start:end]) + assert sum(sequence["loss_mask"]) == 4 diff --git a/tests/envs/test_harbor_aux_token_count.py b/tests/envs/test_harbor_aux_token_count.py new file mode 100644 index 0000000000..3fa8a25e51 --- /dev/null +++ b/tests/envs/test_harbor_aux_token_count.py @@ -0,0 +1,86 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""The token-count estimator has to read every dialect the aux routes accept. + +`approximate_token_count` answers the harnesses' own token-counting endpoints (Anthropic's +`/v1/messages/count_tokens`, Google's `:countTokens`). It is a character estimate on purpose — the +alternative is re-rendering the chat template locally, which is the exact drift this layer exists to +avoid — but it has to at least *find* the text. It reads the body of whichever dialect called it, so a +dialect it does not know collapses to the `max(1, ...)` floor and answers 1 for a 50k-character +conversation. An agent uses that figure to decide when to compact, so a constant 1 means it never +compacts and blows its real context window mid-rollout. + +Google was that dialect: the estimator knew `messages` and `system`, and Gemini sends `contents` with +`parts`, plus `systemInstruction`. +""" + +from __future__ import annotations + +import pytest + +server = pytest.importorskip("openenv.core.harness.capture.server") +count = server.approximate_token_count + +TEXT = "x" * 400 # ~100 tokens at the estimator's 4-chars-per-token rate +EXPECTED = 100 + + +def test_an_empty_body_is_one_token_not_zero(): + """The floor exists so a caller never divides by zero; only an empty body should hit it.""" + assert count({}) == 1 + + +def test_openai_messages(): + assert count({"messages": [{"role": "user", "content": TEXT}]}) == EXPECTED + + +def test_openai_content_parts(): + body = {"messages": [{"role": "user", "content": [{"type": "text", "text": TEXT}]}]} + assert count(body) == EXPECTED + + +def test_anthropic_system_block(): + assert ( + count({"messages": [], "system": [{"type": "text", "text": TEXT}]}) == EXPECTED + ) + + +def test_google_contents_are_counted(): + """The regression: this used to be 1 regardless of how much text `contents` held.""" + body = {"contents": [{"role": "user", "parts": [{"text": TEXT}]}]} + assert count(body) == EXPECTED, ( + "Google's `contents` were invisible to the estimator" + ) + + +def test_google_system_instruction_is_counted(): + body = { + "contents": [{"role": "user", "parts": [{"text": TEXT}]}], + "systemInstruction": {"parts": [{"text": TEXT}]}, + } + assert count(body) == 2 * EXPECTED + + +def test_google_snake_case_system_instruction(): + """The REST API is camelCase, the Python SDK emits snake_case; the proxy sees both.""" + body = {"contents": [], "system_instruction": {"parts": [{"text": TEXT}]}} + assert count(body) == EXPECTED + + +def test_a_long_google_conversation_scales(): + """A multi-turn body should grow with its length — pinning that it is not a per-request constant.""" + turns = [{"role": "user", "parts": [{"text": TEXT}]} for _ in range(10)] + assert count({"contents": turns}) == 10 * EXPECTED + + +def test_malformed_parts_do_not_raise(): + """Bodies arrive from a sandboxed agent, so nothing here may throw on an unexpected shape.""" + body = { + "contents": [{"parts": ["bare string", {"text": None}, 7]}, "not a dict", None], + "systemInstruction": "a plain string", + } + assert count(body) >= 1 diff --git a/tests/envs/test_harbor_capture_graph.py b/tests/envs/test_harbor_capture_graph.py new file mode 100644 index 0000000000..4a4f8c38e6 --- /dev/null +++ b/tests/envs/test_harbor_capture_graph.py @@ -0,0 +1,358 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""The rollout graph: how captured calls become training sequences. + +This is the load-bearing piece of the capture layer. Turns are linked by exact token prefix and +nothing else, so every structural claim a trainer relies on (this is one conversation, this branch +was abandoned, these tokens are the model's own output) is a consequence of the linking rule. A bug +here does not crash: it silently produces training data that misattributes tokens. +""" + +from __future__ import annotations + +import pytest + +graph_mod = pytest.importorskip("openenv.core.harness.capture.graph") + +RolloutGraph = graph_mod.RolloutGraph +TurnNode = graph_mod.TurnNode +common_prefix_len = graph_mod.common_prefix_len + + +def node(node_id: str, prompt: list[int], sampled: list[int], **kwargs) -> TurnNode: + return TurnNode( + node_id=node_id, + prompt_ids=prompt, + sampled_ids=sampled, + sampled_logprobs=[-0.1] * len(sampled), + **kwargs, + ) + + +def chain( + graph: RolloutGraph, *lengths: int, base: int = 0, context: int = 1 +) -> list[TurnNode]: + """Add a linear conversation. + + `context` is how many tokens the harness inserts between turns: a tool result plus the chat + template's scaffolding. Real rollouts always have some, and turn boundaries are derived from + those mask-0 runs, so a chain built without them is not representative. + """ + added, prompt = [], [base] + for i, length in enumerate(lengths): + if i: + prompt = prompt + [base + 900 + i] * context + sampled = list(range(base + 100 + i * 10, base + 100 + i * 10 + length)) + current = graph.add_turn(node(f"n{base}_{i}", list(prompt), sampled)) + added.append(current) + prompt = current.end_ids + return added + + +# --- the linking rule ------------------------------------------------------- +def test_common_prefix_len(): + assert common_prefix_len([1, 2, 3], [1, 2, 9]) == 2 + assert common_prefix_len([1, 2], [1, 2, 3]) == 2 + assert common_prefix_len([], [1]) == 0 + assert common_prefix_len([1], [2]) == 0 + + +def test_a_turn_whose_prompt_extends_another_becomes_its_child(): + g = RolloutGraph() + first, second = chain(g, 3, 4) + assert second.parent_id == first.node_id + assert g.children(first.node_id) == [second] + + +def test_an_unrelated_prompt_starts_a_new_root(): + """A different system prompt breaks the prefix, which is what makes it a separate conversation.""" + g = RolloutGraph() + chain(g, 3) + chain(g, 3, base=5000) + assert len(g.roots()) == 2 + + +def test_linking_ignores_arrival_order(): + """Order of arrival must not decide structure; only the token prefix may.""" + g = RolloutGraph() + parent = g.add_turn(node("p", [1, 2], [3, 4])) + other = g.add_turn(node("other", [9, 9], [8])) + child = g.add_turn(node("c", [1, 2, 3, 4], [5])) + assert child.parent_id == parent.node_id + assert other.parent_id is None + + +# --- forks and discards ----------------------------------------------------- +def test_two_children_of_one_node_are_a_fork(): + g = RolloutGraph() + parent = g.add_turn(node("p", [1], [2, 3])) + g.add_turn(node("a", [1, 2, 3], [4])) + g.add_turn(node("b", [1, 2, 3], [5])) + forks = g.forks() + assert len(forks) == 1 + assert forks[0][0] == parent.node_id + assert len(forks[0][1]) == 2 + + +def test_an_abandoned_branch_is_discarded_and_the_continued_one_is_not(): + """A retry the agent walked away from must not be trained with the task's reward.""" + g = RolloutGraph() + g.add_turn(node("p", [1], [2, 3])) + g.add_turn(node("dead", [1, 2, 3], [4])) # never extended + g.add_turn(node("live", [1, 2, 3], [5])) + g.add_turn(node("live2", [1, 2, 3, 5], [6])) # extends `live` + + discarded = {n.node_id for n in g.discarded_nodes()} + assert "dead" in discarded + assert "live" not in discarded and "live2" not in discarded + + +# --- sequences, the actual training rows ------------------------------------- +def test_sequence_masks_prompt_and_marks_only_sampled_tokens(): + g = RolloutGraph() + first, second = chain(g, 2, 3) # noqa: F841 + seq = g.sequence_for(second.node_id) + + assert len(seq.input_ids) == len(seq.loss_mask) == len(seq.logprobs) + # Exactly the sampled tokens are trainable: 2 from the first turn, 3 from the second. + assert sum(seq.loss_mask) == 5 + assert seq.n_trainable == 5 + trainable = [i for i, m in zip(seq.input_ids, seq.loss_mask) if m] + assert trainable == first.sampled_ids + second.sampled_ids + + +def test_turn_lengths_match_what_each_turn_sampled(): + g = RolloutGraph() + turns = chain(g, 2, 3, 4) + seq = g.sequence_for(turns[-1].node_id) + assert seq.turn_lengths() == [2, 3, 4] + + +def test_turn_lengths_merge_when_a_turn_adds_no_context(): + """A property of `turn_lengths`, which no longer decides turn boundaries anywhere. + + Boundaries are runs of mask-1 tokens, so two turns with nothing between them read as one, and a + turn with unusable logprobs contributes no run at all. `turns_from_document` used to zip + `node_ids` against this, which dropped turns and misattributed the survivors; it now uses each + node's own recorded prompt and sampled counts, so this limitation is confined to the helper. + + Kept because `turn_lengths` remains the join key when reconciling against an external trace, + where a merged run would show up as a per-call count mismatch. + """ + g = RolloutGraph() + turns = chain(g, 2, 3, context=0) + seq = g.sequence_for(turns[-1].node_id) + assert seq.turn_lengths() == [5] + assert len(seq.node_ids) == 2 + + +def test_context_tokens_are_conditioned_on_but_never_trained(): + """Tool results are real tokens the model saw and did not produce: mask 0, not absent.""" + g = RolloutGraph() + parent = g.add_turn(node("p", [1, 2], [3])) + # The harness inserted a tool result (99) between the turns. + child = g.add_turn(node("c", [1, 2, 3, 99], [4])) + + assert child.context_ids(parent) == [99] + seq = g.sequence_for(child.node_id) + assert 99 in seq.input_ids + assert seq.loss_mask[seq.input_ids.index(99)] == 0 + + +def test_one_sequence_per_leaf(): + g = RolloutGraph() + g.add_turn(node("p", [1], [2])) + g.add_turn(node("a", [1, 2], [3])) + g.add_turn(node("b", [1, 2], [4])) + assert len(g.sequences()) == len(g.leaves()) == 2 + + +def test_stats_report_the_shape(): + g = RolloutGraph() + chain(g, 2, 2) + chain(g, 2, base=5000) + stats = g.stats() + assert stats["n_turns"] == 3 + assert stats["n_roots"] == 2 + assert stats["n_leaves"] == 2 + + +def test_empty_graph_is_not_an_error(): + g = RolloutGraph() + assert g.nodes() == [] and g.roots() == [] and g.sequences() == [] + assert g.stats()["n_turns"] == 0 + + +# --- linking without token ids (an eval endpoint) --------------------------- +# +# A hosted provider returns no token ids at all, so `end_ids` is empty for every node and the token +# rule can never find a parent: `len(end) <= best_len` holds for all candidates. The graph would +# report a 20-turn conversation as 20 separate roots — not wrong exactly, but it reads as if the +# agent restarted every turn, and every root-count heuristic downstream misfires. +def eval_node(node_id: str, messages: list[dict], reply: str) -> TurnNode: + return TurnNode( + node_id=node_id, + prompt_ids=[], + sampled_ids=[], + sampled_logprobs=None, + request_messages=messages, + response_message={"role": "assistant", "content": reply}, + ) + + +def test_message_prefix_links_an_eval_conversation_into_one_root(): + graph = RolloutGraph() + first = [{"role": "system", "content": "sys"}, {"role": "user", "content": "go"}] + graph.add_turn(eval_node("a", first, "step 1")) + second = [ + *first, + {"role": "assistant", "content": "step 1"}, + {"role": "user", "content": "tool result"}, + ] + graph.add_turn(eval_node("b", second, "step 2")) + third = [ + *second, + {"role": "assistant", "content": "step 2"}, + {"role": "user", "content": "tool result 2"}, + ] + graph.add_turn(eval_node("c", third, "done")) + + assert graph.stats()["n_roots"] == 1 + assert graph.stats()["n_turns"] == 3 + assert graph.get("b").parent_id == "a" + assert graph.get("c").parent_id == "b" + + +def test_an_unrelated_eval_conversation_is_still_its_own_root(): + """A subagent starts from a fresh system prompt and must not be grafted onto the main chain.""" + graph = RolloutGraph() + graph.add_turn(eval_node("a", [{"role": "system", "content": "main"}], "working")) + graph.add_turn( + eval_node("b", [{"role": "system", "content": "subagent"}], "also working") + ) + assert graph.stats()["n_roots"] == 2 + + +def test_provider_noise_on_the_assistant_message_does_not_break_linking(): + """The message a provider returns and the one a harness echoes back are equal in meaning and + unequal as dicts: `refusal`, `annotations` and `audio: null` get added, `content` moves between + `null` and `""`. Comparing raw dicts would find no parent for any turn.""" + graph = RolloutGraph() + first = [{"role": "user", "content": "go"}] + parent = TurnNode( + node_id="a", + prompt_ids=[], + sampled_ids=[], + request_messages=first, + response_message={ + "role": "assistant", + "content": "step 1", + "refusal": None, + "annotations": [], + "audio": None, + }, + ) + graph.add_turn(parent) + graph.add_turn( + eval_node( + "b", + [ + *first, + {"role": "assistant", "content": "step 1"}, + {"role": "user", "content": "next"}, + ], + "step 2", + ) + ) + assert graph.get("b").parent_id == "a" + + +def test_token_linking_still_wins_when_ids_are_present(): + """Messages are a weaker key — what the harness said it sent, not what the engine tokenised — so + they must never be consulted while ids are available.""" + graph = RolloutGraph() + turns = chain(graph, 3, 4) + assert graph.get(turns[1].node_id).parent_id == turns[0].node_id + assert graph.stats()["n_roots"] == 1 + + +def test_tool_call_arguments_are_compared_as_json_not_as_bytes(): + """The bug that made message linking inert on real data. + + An eight-turn opencode rollout against the HF router came back as eight separate roots. The + arguments were identical; the *strings* were not, differing only in the space after the colon, + because the harness re-serialises what the provider sent: + + {"command": "ls"} provider + {"command":"ls"} echoed back + """ + graph = RolloutGraph() + first = [{"role": "user", "content": "go"}] + call = {"id": "call_1", "type": "function", "function": {"name": "bash"}} + graph.add_turn( + TurnNode( + node_id="a", + prompt_ids=[], + sampled_ids=[], + request_messages=first, + response_message={ + "role": "assistant", + "content": None, + "tool_calls": [ + { + **call, + "function": { + **call["function"], + "arguments": '{"command": "ls"}', + }, + } + ], + }, + ) + ) + graph.add_turn( + eval_node( + "b", + [ + *first, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + **call, + "function": { + **call["function"], + "arguments": '{"command":"ls"}', + }, + } + ], + }, + {"role": "tool", "content": "a.txt"}, + ], + "done", + ) + ) + assert graph.get("b").parent_id == "a" + assert graph.stats()["n_roots"] == 1 + + +def test_reordered_argument_keys_are_still_the_same_call(): + """Any harness that round-trips arguments through a dict can reorder the keys.""" + from openenv.core.harness.capture.graph import _canonical_arguments + + assert _canonical_arguments('{"b": 2, "a": 1}') == _canonical_arguments( + '{"a":1,"b":2}' + ) + + +def test_malformed_arguments_are_not_forced_to_match(): + """Two different malformed strings are two different calls, not one.""" + from openenv.core.harness.capture.graph import _canonical_arguments + + assert _canonical_arguments("{not json") != _canonical_arguments("{also not json") + assert _canonical_arguments("{not json") == _canonical_arguments(" {not json ") diff --git a/tests/envs/test_harbor_capture_level.py b/tests/envs/test_harbor_capture_level.py new file mode 100644 index 0000000000..c7192f3f98 --- /dev/null +++ b/tests/envs/test_harbor_capture_level.py @@ -0,0 +1,831 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Which capture level an endpoint gets, and what follows from it. + +Two rollout types, and the endpoint decides which one you get: `train` when it returns token ids and +aligned logprobs, `eval` otherwise. The tests below cover the decision (a probe that negotiates its +way down through a provider's 400s) and the consequence that matters most — that an eval rollout +cannot be mistaken for, or converted into, a training one. +""" + +from __future__ import annotations + +import urllib.error + +import pytest + +validate_llm_mod = pytest.importorskip("openenv.core.harness.capture.validate_llm") +export_mod = pytest.importorskip("openenv.core.harness.capture.export") +contract_mod = pytest.importorskip("openenv.core.harness.capture.contract") +graph_mod = pytest.importorskip("openenv.core.harness.capture.graph") + +validate_llm = validate_llm_mod.validate_llm + + +def http_400(payload: dict) -> urllib.error.HTTPError: + import io + import json + + return urllib.error.HTTPError( + "http://engine/v1/chat/completions", + 400, + "Bad Request", + {}, + io.BytesIO(json.dumps(payload).encode()), + ) + + +def unsupported(param: str, code: str = "unsupported_parameter", message="") -> dict: + return { + "error": { + "message": message + or f"Unsupported parameter: '{param}' is not supported with this model.", + "param": param, + "code": code, + } + } + + +def reply(*, prompt_ids=None, token_ids=None, logprobs=None) -> dict: + choice: dict = {"message": {"content": "ok"}, "finish_reason": "stop"} + if token_ids is not None: + choice["token_ids"] = token_ids + if logprobs is not None: + choice["logprobs"] = {"content": [{"logprob": lp} for lp in logprobs]} + payload: dict = {"choices": [choice]} + if prompt_ids is not None: + payload["prompt_token_ids"] = prompt_ids + return payload + + +def probe(monkeypatch, script): + """Run `validate_llm` against a scripted endpoint. Returns the report and the bodies sent.""" + sent: list[dict] = [] + remaining = list(script) + + def fake_post(url, body, timeout, api_key=None, auth_header="Authorization"): + sent.append(dict(body)) + outcome = remaining.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + monkeypatch.setattr(validate_llm_mod, "_post", fake_post) + monkeypatch.setattr(validate_llm_mod, "list_models", lambda *a, **k: ["m"]) + # The logprobs-mode probe issues its own two calls and is exercised on its own below; leaving it + # on here would make every scripted case account for requests it is not about. + return ( + validate_llm( + "http://engine", "m", check_logprobs_mode=False, check_tools=False + ), + sent, + ) + + +# --- the probe decides the level -------------------------------------------- +def test_a_full_capture_engine_is_trainable(monkeypatch): + report, sent = probe( + monkeypatch, + [reply(prompt_ids=[1, 2], token_ids=[7, 8], logprobs=[-0.1, -0.2])], + ) + assert (report.capture_level, report.rollout_type) == ("tokens", "train") + assert report.trainable and report.ok and report.reachable + assert len(sent) == 1, "grading a working engine must cost exactly one completion" + assert report.param_fixes == [] + + +def test_token_ids_returned_as_null_land_on_the_logprobs_level(monkeypatch): + """The HF router's shape: it accepts `return_token_ids` and answers `token_ids: null`. The level + must come from what came back, not from the request having been accepted.""" + report, _ = probe(monkeypatch, [reply(token_ids=None, logprobs=[-0.1])]) + assert (report.capture_level, report.rollout_type) == ("logprobs", "eval") + assert not report.trainable and report.reachable + + +def test_a_provider_rejecting_return_token_ids_is_retried_without_it(monkeypatch): + report, sent = probe( + monkeypatch, + [ + http_400( + { + "error": { + "message": "Unrecognized request argument supplied: " + "return_token_ids" + } + } + ), + reply(logprobs=[-0.1]), + ], + ) + assert report.capture_level == "logprobs" + assert "return_token_ids" in sent[0] and "return_token_ids" not in sent[1] + assert report.param_fixes == ["dropped return_token_ids"] + + +def test_a_provider_rejecting_logprobs_too_lands_on_text(monkeypatch): + """Every current OpenAI model: `return_token_ids` unknown, `logprobs` unsupported.""" + report, sent = probe( + monkeypatch, + [ + http_400( + unsupported( + "return_token_ids", + "unknown_parameter", + "Unknown parameter: 'return_token_ids'.", + ) + ), + http_400(unsupported("logprobs")), + reply(), + ], + ) + assert (report.capture_level, report.rollout_type) == ("text", "eval") + assert "logprobs" not in sent[-1] + assert "top_logprobs" not in sent[-1] + assert report.reachable + + +def test_max_tokens_is_renamed_during_the_probe(monkeypatch): + report, sent = probe( + monkeypatch, + [ + http_400( + unsupported( + "max_tokens", + message="Unsupported parameter: 'max_tokens' is not supported with " + "this model. Use 'max_completion_tokens' instead.", + ) + ), + reply(prompt_ids=[1], token_ids=[2], logprobs=[-0.1]), + ], + ) + assert sent[1]["max_completion_tokens"] == sent[0]["max_tokens"] + assert report.param_fixes == ["renamed max_tokens -> max_completion_tokens"] + + +def test_an_unreachable_endpoint_is_neither_trainable_nor_eval(monkeypatch): + report, _ = probe(monkeypatch, [OSError("connection refused")]) + assert not report.reachable + assert report.capture_level == "" + assert "connection refused" in report.summary() + + +def test_a_model_the_endpoint_does_not_serve_fails_before_any_completion(monkeypatch): + monkeypatch.setattr( + validate_llm_mod, "list_models", lambda *a, **k: ["other-model"] + ) + monkeypatch.setattr( + validate_llm_mod, + "_post", + lambda *a, **k: pytest.fail("must not spend a completion on a bad model name"), + ) + report = validate_llm("http://engine", "m") + assert not report.ok and not report.reachable + + +def test_an_endpoint_that_publishes_no_model_list_is_still_probed(monkeypatch): + """Some hosted gateways gate `/v1/models` differently from inference, or omit it. Refusing there + would reject an endpoint that serves completions perfectly well.""" + monkeypatch.setattr(validate_llm_mod, "list_models", lambda *a, **k: []) + monkeypatch.setattr( + validate_llm_mod, + "_post", + lambda *a, **k: reply(prompt_ids=[1], token_ids=[2], logprobs=[-0.1]), + ) + report = validate_llm("http://engine", "m") + assert report.trainable + + +def test_require_llm_accepts_an_eval_endpoint_but_can_be_told_not_to(monkeypatch): + monkeypatch.setattr(validate_llm_mod, "list_models", lambda *a, **k: ["m"]) + monkeypatch.setattr(validate_llm_mod, "_post", lambda *a, **k: reply()) + + report = validate_llm_mod.require_llm("http://engine", "m") + assert report.capture_level == "text" + + with pytest.raises(RuntimeError, match="needs trainable rollouts"): + validate_llm_mod.require_llm("http://engine", "m", require_tokens=True) + + +# --- what an eval level means downstream ------------------------------------ +class FakeSession: + def __init__(self, graph): + self.session_id = "s1" + self.metadata: dict = {} + self.findings: list[str] = [] + self.graph = graph + + +def eval_graph(): + graph = graph_mod.RolloutGraph() + messages = [{"role": "user", "content": "go"}] + graph.add_turn( + graph_mod.TurnNode( + node_id="a", + prompt_ids=[], + sampled_ids=[], + request_messages=messages, + response_message={"role": "assistant", "content": "step 1"}, + n_tools=1, + ) + ) + graph.add_turn( + graph_mod.TurnNode( + node_id="b", + prompt_ids=[], + sampled_ids=[], + request_messages=[ + *messages, + {"role": "assistant", "content": "step 1"}, + {"role": "user", "content": "result"}, + ], + response_message={"role": "assistant", "content": "done"}, + n_tools=1, + ) + ) + return graph + + +def test_an_eval_export_keeps_the_whole_trace_and_claims_nothing_trainable(): + graph = eval_graph() + document = export_mod.export_session( + FakeSession(graph), include_messages=True, capture_level="logprobs" + ) + assert document["rollout_type"] == "eval" + assert document["capture_level"] == "logprobs" + assert document["trainable"] is False + + # The trace is the payload of an eval rollout, and all of it has to survive. + assert len(document["turns"]) == 2 + assert document["turns"][0]["request_messages"] + + # Structure survives too. `conversations_from_document` and `turns_from_document` both walk + # `sequences`, so emptying it on an eval rollout would delete the very trace this exists for — + # which is exactly the bug a live rollout caught: reward 1.0, six turns, zero conversations. + assert len(document["sequences"]) == 1 + row = document["sequences"][0] + assert row["node_ids"] == ["a", "b"] + assert row["n_turns"] == 2 + # What is withheld is the training claim, not the structure. + assert row["trainable"] is False + assert row["input_ids"] == [] + assert document["stats"]["n_trainable_tokens"] == 0 + + +def test_an_eval_rollout_still_rebuilds_its_conversations(): + """The regression a live run found: an eval result with a reward, six turns and no transcript.""" + models = pytest.importorskip("openenv.harbor.models") + document = export_mod.export_session( + FakeSession(eval_graph()), include_messages=True, capture_level="logprobs" + ) + conversations = models.conversations_from_document(document) + assert len(conversations) == 1 + # The deepest node replays the whole thread, plus its own reply. + assert [m["role"] for m in conversations[0].messages] == [ + "user", + "assistant", + "user", + "assistant", + ] + turns = models.turns_from_document(document) + assert len(turns) == 2 + assert [t.text for t in turns] == ["step 1", "done"] + assert all(t.prompt_token_ids == [] for t in turns) + + +def test_a_training_contract_cannot_be_built_from_an_eval_rollout(): + graph = eval_graph() + document = export_mod.export_session(FakeSession(graph), capture_level="text") + for build in (contract_mod.to_turn_records, contract_mod.to_trace_entries): + with pytest.raises(ValueError, match="EVAL rollout"): + build(graph, document) + + +def test_a_train_export_is_unchanged(): + """The regression that matters: nothing on the trainable path may move.""" + graph = graph_mod.RolloutGraph() + graph.add_turn( + graph_mod.TurnNode( + node_id="a", + prompt_ids=[1, 2, 3], + sampled_ids=[4, 5], + sampled_logprobs=[-0.1, -0.2], + n_tools=1, + ) + ) + document = export_mod.export_session(FakeSession(graph)) + assert document["rollout_type"] == "train" + assert document["capture_level"] == "tokens" + assert len(document["sequences"]) == 1 + assert document["sequences"][0]["input_ids"] == [1, 2, 3, 4, 5] + assert contract_mod.to_turn_records(graph, document) == [ + ([1, 2, 3], [4, 5], [-0.1, -0.2]) + ] + + +# --- the proxy reports its level, and never its credential ------------------ +def app_client(**kwargs): + from fastapi.testclient import TestClient + + server = pytest.importorskip("openenv.core.harness.capture.server") + return TestClient( + server.create_app(llm_url="http://127.0.0.1:9/v1", model="m", **kwargs) + ) + + +def test_health_states_the_rollout_type(): + with app_client(capture_level="logprobs") as client: + body = client.get("/health").json() + assert body["capture_level"] == "logprobs" + assert body["rollout_type"] == "eval" + + +def test_health_confirms_auth_without_revealing_the_key(): + """On a Space this endpoint is public, and the key it holds buys paid inference.""" + with app_client(api_key="sk-secret-value") as client: + body = client.get("/health").json() + assert body["upstream_auth"] is True + assert "sk-secret-value" not in str(body) + + +def test_health_says_train_by_default(): + with app_client() as client: + body = client.get("/health").json() + assert (body["capture_level"], body["rollout_type"]) == ("tokens", "train") + assert body["upstream_auth"] is False + + +def test_an_eval_rollout_is_recorded_without_fatal_findings(monkeypatch): + """`check_turn`'s FATALs (`no_prompt_ids`, `no_logprobs`) are the expected condition here. + Letting them fire would mark every eval turn unusable and teach everyone to ignore findings. + + Two calls, not one: `degenerate_rollout` is FATAL for a single-call agentic rollout and stays + that way on this path, because an agent that made one call and stopped did not attempt the task + whether or not its tokens were captured. + """ + replies = [reply(), reply()] + + async def fake_completion(_self, _request): + return replies.pop(0) + + server = pytest.importorskip("openenv.core.harness.capture.server") + upstream = pytest.importorskip("openenv.core.harness.capture.upstream") + monkeypatch.setattr(upstream.InferenceClient, "completion", fake_completion) + + app = server.create_app( + llm_url="http://127.0.0.1:9/v1", model="m", capture_level="text" + ) + from fastapi.testclient import TestClient + + first = [{"role": "user", "content": "go"}] + second = [ + *first, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "tool result"}, + ] + with TestClient(app) as client: + session = client.post("/sessions").json()["session_id"] + for messages in (first, second): + response = client.post( + "/v1/chat/completions", + json={"model": "m", "messages": messages}, + headers={"Authorization": f"Bearer {session}"}, + ) + assert response.status_code == 200 + status = client.get(f"/sessions/{session}").json() + rollout = client.get(f"/sessions/{session}/rollout").json() + + assert status["n_turns"] == 2 + # One root, via the message-prefix fallback: with no token ids the token rule would make each + # call its own root and the rollout would read as two unrelated conversations. Note that + # `reply()` omits `role` on the assistant message while the harness names it on the way back — + # linking has to survive that asymmetry, since it is invisible until the graph collapses. + assert status["n_roots"] == 1 + assert rollout["rollout_type"] == "eval" + assert not [f for f in rollout["validation"] if f.startswith("[FATAL")], ( + "an eval rollout must not be reported as a capture failure" + ) + + +# --- the train path must not move ------------------------------------------- +# +# The acceptance rows for a real vLLM and a real SGLang need a GPU. This is the gate that runs +# anywhere, including CI: a stub upstream returning exactly the shape vLLM returns with +# `--return-tokens-as-token-ids --logprobs-mode processed_logprobs`, driven through the whole proxy, +# with every field a trainer reads pinned. A change to the eval path that leaks into the token path +# fails here rather than in a loss curve days later. +def test_the_trainable_path_end_to_end_is_unchanged(monkeypatch): + server = pytest.importorskip("openenv.core.harness.capture.server") + upstream = pytest.importorskip("openenv.core.harness.capture.upstream") + from fastapi.testclient import TestClient + + # Turn k's prompt is turn k-1's prompt + completion + one interstitial context token, which is + # what a real engine returns once the harness has appended a tool result. + scripted = [ + {"prompt": [1, 2, 3], "sampled": [10, 11], "logprobs": [-0.5, -0.25]}, + { + "prompt": [1, 2, 3, 10, 11, 90], + "sampled": [12, 13, 14], + "logprobs": [-0.1, -0.2, -0.3], + }, + { + "prompt": [1, 2, 3, 10, 11, 90, 12, 13, 14, 91], + "sampled": [15], + "logprobs": [-0.05], + }, + ] + remaining = list(scripted) + + async def fake_completion(_self, _request): + step = remaining.pop(0) + return upstream.normalize_response( + { + "prompt_token_ids": step["prompt"], + "choices": [ + { + "message": {"role": "assistant", "content": "step"}, + "finish_reason": "stop", + "token_ids": step["sampled"], + "logprobs": { + "content": [{"logprob": lp} for lp in step["logprobs"]] + }, + } + ], + } + ) + + monkeypatch.setattr(upstream.InferenceClient, "completion", fake_completion) + app = server.create_app(llm_url="http://127.0.0.1:9/v1", model="m") + + with TestClient(app) as client: + session = client.post("/sessions").json()["session_id"] + for _ in scripted: + assert ( + client.post( + "/v1/chat/completions", + json={ + "model": "m", + "messages": [{"role": "user", "content": "go"}], + "tools": [{"type": "function", "function": {"name": "bash"}}], + }, + headers={"Authorization": f"Bearer {session}"}, + ).status_code + == 200 + ) + document = client.get(f"/sessions/{session}/rollout").json() + graph = app.state.registry.get(session).graph + + assert document["rollout_type"] == "train" + assert document["capture_level"] == "tokens" + assert document["trainable"] is True + assert document["stats"]["n_turns"] == 3 + assert document["stats"]["n_roots"] == 1 + + (row,) = document["sequences"] + assert row["role"] == "agent" + assert row["trainable"] is True + # The flattened sequence: every prompt token the model conditioned on, in order, with each + # sampled span marked 1 and the interstitial context tokens marked 0. + assert row["input_ids"] == [1, 2, 3, 10, 11, 90, 12, 13, 14, 91, 15] + assert row["loss_mask"] == [0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1] + assert row["logprobs"] == [ + 0.0, + 0.0, + 0.0, + -0.5, + -0.25, + 0.0, + -0.1, + -0.2, + -0.3, + 0.0, + -0.05, + ] + assert row["prompt_len"] == 3 + assert row["turn_lengths"] == [2, 3, 1] + assert row["n_trainable"] == 6 + assert document["stats"]["n_trainable_tokens"] == 6 + + # And the contract a trainer actually consumes, per turn. + assert contract_mod.to_turn_records(graph, document) == [ + ([1, 2, 3], [10, 11], [-0.5, -0.25]), + ([1, 2, 3, 10, 11, 90], [12, 13, 14], [-0.1, -0.2, -0.3]), + ([1, 2, 3, 10, 11, 90, 12, 13, 14, 91], [15], [-0.05]), + ] + + +def test_the_probe_does_not_restate_the_tier_as_fatal_findings(monkeypatch): + """An eval endpoint's findings must not read like a broken one. + + `no_prompt_token_ids` is reported FATAL by `check_upstream_response`, and it is the *definition* + of an eval endpoint. Printing three fatal-looking lines under a heading that already says + EVAL ONLY is how a findings list stops being read at all. + """ + report, _ = probe(monkeypatch, [reply()]) + assert report.capture_level == "text" + assert report.findings == [] + + +def test_a_genuinely_broken_response_still_reports(monkeypatch): + """`no_choices` is not "merely eval-only" — the endpoint answered with nothing at all.""" + report, _ = probe(monkeypatch, [{"id": "x"}]) + assert any("no_choices" in f for f in report.findings) + + +# --- raw vs processed logprobs ---------------------------------------------- +# +# The one hole no other check here can see. `token_ids` arrives from the REQUEST parameter, not from a +# serving flag, so an engine started with neither flag returns aligned, negative, correctly-counted +# logprobs that are pre-temperature — and grades as fully trainable. vLLM's `logprobs_mode` defaults +# to `raw_logprobs`. The test follows from the definition: raw values cannot move with temperature. +def temperature_scripted(monkeypatch, by_temperature, *, fail=False): + """Serve first-position `top_logprobs` per temperature. Returns the calls made.""" + calls: list[float] = [] + + def fake_post(url, body, timeout, api_key=None, auth_header="Authorization"): + if fail: + raise OSError("endpoint refused") + calls.append(body["temperature"]) + tops = by_temperature[body["temperature"]] + return { + "choices": [ + { + "message": {"content": "x"}, + "finish_reason": "stop", + "logprobs": { + "content": [ + { + "token": "a", + "logprob": -0.1, + "top_logprobs": [ + {"token": t, "logprob": lp} + for t, lp in tops.items() + ], + } + ] + }, + } + ] + } + + monkeypatch.setattr(validate_llm_mod, "_post", fake_post) + return calls + + +def test_an_unchanged_gap_is_raw(monkeypatch): + """Live measurement: gap 6.7500 at both temperatures on a default-flags vLLM.""" + calls = temperature_scripted( + monkeypatch, + {1.0: {"a": -0.0037, "b": -6.7537}, 2.0: {"a": -0.0037, "b": -6.7537}}, + ) + assert validate_llm_mod.probe_logprobs_mode("http://engine", "m") == "raw" + assert calls == [1.0, 2.0] + + +def test_a_halved_gap_is_processed(monkeypatch): + """Live measurement: gap 6.7500 -> 3.3750, i.e. exactly T1/T2, on a processed_logprobs vLLM.""" + temperature_scripted( + monkeypatch, + {1.0: {"a": -0.0037, "b": -6.7537}, 2.0: {"a": -1.4380, "b": -4.8130}}, + ) + assert validate_llm_mod.probe_logprobs_mode("http://engine", "m") == "processed" + + +def test_a_constant_offset_between_calls_does_not_change_the_verdict(monkeypatch): + """Why the gap, not the value: a data-parallel engine answers consecutive calls from different + replicas, and comparing values directly misread one such engine (DP=4) as processed. A constant + shift cancels in a difference, so the same gap survives it.""" + temperature_scripted( + monkeypatch, + {1.0: {"a": -0.0037, "b": -6.7537}, 2.0: {"a": -0.9037, "b": -7.6537}}, + ) + assert validate_llm_mod.probe_logprobs_mode("http://engine", "m") == "raw" + + +def test_a_distribution_too_flat_to_divide_is_unknown(monkeypatch): + """Guessing from noise is how a check becomes something people override on principle.""" + temperature_scripted( + monkeypatch, {1.0: {"a": -0.5, "b": -0.6}, 2.0: {"a": -0.5, "b": -0.9}} + ) + assert validate_llm_mod.probe_logprobs_mode("http://engine", "m") == "unknown" + + +def test_a_single_top_logprob_is_unknown(monkeypatch): + """A gap needs two values.""" + temperature_scripted(monkeypatch, {1.0: {"a": -0.5}, 2.0: {"a": -0.5}}) + assert validate_llm_mod.probe_logprobs_mode("http://engine", "m") == "unknown" + + +def test_an_unreachable_endpoint_is_unknown(monkeypatch): + """Absence of evidence, not evidence of a problem.""" + temperature_scripted(monkeypatch, {}, fail=True) + assert validate_llm_mod.probe_logprobs_mode("http://engine", "m") == "unknown" + + +def test_a_provider_that_refused_temperature_cannot_be_asked(monkeypatch): + """gpt-5.6 drops `temperature`, so a question about temperature has no meaning — and must cost + no calls at all.""" + compat = pytest.importorskip("openenv.core.harness.capture.compat") + calls = temperature_scripted(monkeypatch, {}) + assert ( + validate_llm_mod.probe_logprobs_mode( + "http://engine", "m", fixes=[compat.ParamFix(param="temperature")] + ) + == "unknown" + ) + assert calls == [] + + +def test_raw_logprobs_demote_a_trainable_endpoint_to_eval(monkeypatch): + """The endpoint answers fine and is a good eval backend; what it cannot do is train.""" + monkeypatch.delenv("OPENENV_ALLOW_RAW_LOGPROBS", raising=False) + monkeypatch.setattr(validate_llm_mod, "list_models", lambda *a, **k: ["m"]) + monkeypatch.setattr( + validate_llm_mod, + "probe_logprobs_mode", + lambda *a, **k: "raw", + ) + monkeypatch.setattr( + validate_llm_mod, + "_post", + lambda *a, **k: reply(prompt_ids=[1], token_ids=[2], logprobs=[-0.1]), + ) + report = validate_llm("http://engine", "m") + assert report.logprobs_mode == "raw" + assert report.capture_level == "logprobs", "must not stay trainable" + assert report.trainable is False + assert report.reachable is True, "still perfectly usable for eval" + assert any("raw_logprobs" in f and "[FATAL]" in f for f in report.findings) + # The measurement supersedes the inference that pointed at it; printing both says one thing twice. + assert not any("token_strings" in f for f in report.findings) + + +def test_the_override_keeps_it_trainable_but_says_so(monkeypatch): + """A refusal that cannot be overridden becomes a reason to stop trusting the tool.""" + monkeypatch.setenv("OPENENV_ALLOW_RAW_LOGPROBS", "1") + monkeypatch.setattr(validate_llm_mod, "list_models", lambda *a, **k: ["m"]) + monkeypatch.setattr(validate_llm_mod, "probe_logprobs_mode", lambda *a, **k: "raw") + monkeypatch.setattr( + validate_llm_mod, + "_post", + lambda *a, **k: reply(prompt_ids=[1], token_ids=[2], logprobs=[-0.1]), + ) + report = validate_llm("http://engine", "m") + assert report.capture_level == "tokens" + assert report.trainable is True + assert any("raw_logprobs_forced" in f for f in report.findings) + + +def test_the_mode_is_not_probed_below_the_tokens_tier(monkeypatch): + """Nothing below `tokens` is trainable, so the answer would inform no decision.""" + monkeypatch.setattr(validate_llm_mod, "list_models", lambda *a, **k: ["m"]) + monkeypatch.setattr( + validate_llm_mod, + "probe_logprobs_mode", + lambda *a, **k: pytest.fail("must not spend two calls on an eval endpoint"), + ) + monkeypatch.setattr(validate_llm_mod, "_post", lambda *a, **k: reply()) + assert validate_llm("http://engine", "m").logprobs_mode == "" + + +# --- can a coding agent work here at all? ----------------------------------- +# +# The capture probe sends no tools; every validated harness sends one on every call. So this is the +# only signal about agent viability, and it caught a real failure that `harbor info` previously +# reported as a perfectly healthy endpoint. +def tool_reply(*, tool_call=True, finish="stop"): + message = {"role": "assistant", "content": None if tool_call else "I'd run ls."} + if tool_call: + message["tool_calls"] = [ + { + "id": "c1", + "type": "function", + "function": {"name": "bash", "arguments": "{}"}, + } + ] + return {"choices": [{"message": message, "finish_reason": finish}]} + + +def test_a_tool_call_means_agents_can_work(monkeypatch): + monkeypatch.setattr(validate_llm_mod, "_post", lambda *a, **k: tool_reply()) + assert validate_llm_mod.probe_tool_support("http://engine", "m")[0] == "ok" + + +def test_prose_instead_of_a_tool_call_is_reported(monkeypatch): + monkeypatch.setattr( + validate_llm_mod, "_post", lambda *a, **k: tool_reply(tool_call=False) + ) + assert ( + validate_llm_mod.probe_tool_support("http://engine", "m")[0] == "no-tool-call" + ) + + +def test_truncation_is_inconclusive_not_a_failure(monkeypatch): + """The false positive this avoids: Qwen3.6-35B-A3B spent 224 tokens reasoning and hit the cap, so + a 64-token probe called it tool-incapable while it in fact worked with all 16 harnesses.""" + monkeypatch.setattr( + validate_llm_mod, + "_post", + lambda *a, **k: tool_reply(tool_call=False, finish="length"), + ) + assert validate_llm_mod.probe_tool_support("http://engine", "m")[0] == "unknown" + + +def test_an_endpoint_that_refuses_tools_outright_is_flagged(monkeypatch): + """`tools` is protected from being dropped, so this cannot be papered over.""" + + def refuse(*a, **k): + raise http_400( + {"error": {"message": "tools are not supported", "param": "tools"}} + ) + + monkeypatch.setattr(validate_llm_mod, "_post", refuse) + assert validate_llm_mod.probe_tool_support("http://engine", "m")[0] == "rejected" + + +def test_reasoning_forced_off_warns_at_validate_time(monkeypatch): + """The gpt-5.6 case: tools are accepted only with reasoning disabled, after which agentic loops + make one model call and stop. Discovered only when the probe carries a tool manifest.""" + monkeypatch.setattr(validate_llm_mod, "list_models", lambda *a, **k: ["m"]) + monkeypatch.setattr( + validate_llm_mod, "probe_logprobs_mode", lambda *a, **k: "processed" + ) + compat = pytest.importorskip("openenv.core.harness.capture.compat") + monkeypatch.setattr( + validate_llm_mod, + "probe_tool_support", + lambda *a, **k: ( + "ok", + [compat.ParamFix(param="reasoning_effort", value="none")], + ), + ) + monkeypatch.setattr( + validate_llm_mod, + "_post", + lambda *a, **k: reply(prompt_ids=[1], token_ids=[2], logprobs=[-0.1]), + ) + report = validate_llm("http://engine", "m") + assert any( + "reasoning_effort" in f and "behaviour_changed" in f for f in report.findings + ) + assert any("single model call" in f for f in report.findings) + + +# --- roles must not depend on token counts that eval endpoints never have ---- +def test_a_toolless_harness_is_still_the_agent_on_an_eval_endpoint(): + """terminus-2 parses tool calls out of raw text, so it sends no manifest. Role assignment used + `n_trainable` as the tiebreak when nothing had tools, and on an eval endpoint that is 0 for every + sequence — so every path was labelled auxiliary, `result.turns` came back empty and the + conversations were mistagged, on a rollout that had captured perfectly well.""" + models = pytest.importorskip("openenv.harbor.models") + graph = graph_mod.RolloutGraph() + first = [{"role": "user", "content": "go"}] + graph.add_turn( + graph_mod.TurnNode( + node_id="a", + prompt_ids=[], + sampled_ids=[], + n_tools=0, + request_messages=first, + response_message={"role": "assistant", "content": "step 1"}, + ) + ) + graph.add_turn( + graph_mod.TurnNode( + node_id="b", + prompt_ids=[], + sampled_ids=[], + n_tools=0, + request_messages=[ + *first, + {"role": "assistant", "content": "step 1"}, + {"role": "user", "content": "result"}, + ], + response_message={"role": "assistant", "content": "done"}, + ) + ) + document = export_mod.export_session( + FakeSession(graph), include_messages=True, capture_level="logprobs" + ) + assert [r["role"] for r in document["sequences"]] == ["agent"] + assert len(models.turns_from_document(document)) == 2 + assert len(models.conversations_from_document(document)) == 1 + + +def test_the_train_path_still_uses_trainable_tokens_as_the_tiebreak(): + """Where token counts DO mean something, a toolless sequence with nothing trainable is auxiliary.""" + graph = graph_mod.RolloutGraph() + graph.add_turn( + graph_mod.TurnNode( + node_id="a", + prompt_ids=[1, 2], + sampled_ids=[3], + sampled_logprobs=None, # rejected on ingest -> masked out -> nothing trainable + n_tools=0, + ) + ) + document = export_mod.export_session(FakeSession(graph), capture_level="tokens") + assert [r["role"] for r in document["sequences"]] == ["auxiliary"] diff --git a/tests/envs/test_harbor_capture_normalise.py b/tests/envs/test_harbor_capture_normalise.py new file mode 100644 index 0000000000..6dd2995dc2 --- /dev/null +++ b/tests/envs/test_harbor_capture_normalise.py @@ -0,0 +1,159 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Request shapes that vLLM rejects outright, normalised before they reach it. + +Each case here is a real harness sending something an OpenAI-spec engine 400s on. A 400 does not +degrade a rollout, it truncates it: the agent loses the call, and the captured trajectory ends early +while still looking structurally valid. These are cheap to assert and expensive to rediscover. +""" + +from __future__ import annotations + +import pytest + +server = pytest.importorskip("openenv.core.harness.capture.server") + +normalise_for_capture = server.normalise_for_capture + + +def test_stream_is_forced_off(): + """Capture needs one whole response; reassembling ids from SSE deltas corrupts silently.""" + request = {"messages": [], "stream": True} + normalise_for_capture(request) + assert request["stream"] is False + + +def test_stream_options_is_dropped(): + """vLLM 400s on `stream_options` once `stream` is False. opencode sends it on every call.""" + request = { + "messages": [], + "stream": True, + "stream_options": {"include_usage": True}, + } + normalise_for_capture(request) + assert "stream_options" not in request + + +def test_empty_tools_is_dropped(): + """kimi-cli sends `tools: []`, which vLLM rejects: 'must not be an empty array'.""" + request = {"messages": [], "tools": []} + normalise_for_capture(request) + assert "tools" not in request + + +def test_empty_functions_is_dropped(): + """The legacy spelling fails the same way.""" + request = {"messages": [], "functions": []} + normalise_for_capture(request) + assert "functions" not in request + + +def test_tool_choice_is_dropped_with_the_tools_it_referenced(): + """`tool_choice` without `tools` is invalid, and means nothing once the list is gone.""" + request = {"messages": [], "tools": [], "tool_choice": "auto"} + normalise_for_capture(request) + assert "tools" not in request + assert "tool_choice" not in request + + +def test_populated_tools_are_left_alone(): + """The guard must be narrow: dropping real tools would change what the model can do.""" + tools = [{"type": "function", "function": {"name": "bash"}}] + request = {"messages": [], "tools": tools, "tool_choice": "auto"} + normalise_for_capture(request) + assert request["tools"] == tools + assert request["tool_choice"] == "auto" + + +def test_absent_tool_keys_are_not_invented(): + """A request with no tool keys must stay that way rather than gain empty ones.""" + request = {"messages": []} + normalise_for_capture(request) + assert "tools" not in request + assert "functions" not in request + assert "tool_choice" not in request + + +# --- engine shape differences ----------------------------------------------- +upstream = pytest.importorskip("openenv.core.harness.capture.upstream") +normalize_response = upstream.normalize_response + + +def test_sglang_per_choice_prompt_ids_are_hoisted(): + """SGLang returns the prompt ids on the choice; vLLM returns them at the top level. + + Every reader downstream (`check_upstream_response`, the capture server's `_ingest`, the UI) looks + only at the top level, so an un-hoisted SGLang response reads as "no prompt ids" and fails + validation even though the rollout path handles it perfectly well. + """ + response = { + "choices": [ + { + "prompt_token_ids": [1, 2, 3], + "token_ids": [4, 5], + "message": {"content": "hi"}, + } + ] + } + + out = normalize_response(response) + + assert out["prompt_token_ids"] == [1, 2, 3] + + +def test_an_engines_own_top_level_prompt_ids_win(): + """vLLM's value must not be overwritten by a choice that also carries one.""" + response = { + "prompt_token_ids": [9, 9, 9], + "choices": [{"prompt_token_ids": [1, 2, 3], "message": {"content": "hi"}}], + } + + assert normalize_response(response)["prompt_token_ids"] == [9, 9, 9] + + +def test_nothing_is_invented_when_no_choice_carries_prompt_ids(): + response = {"choices": [{"message": {"content": "hi"}}]} + assert "prompt_token_ids" not in normalize_response(response) + + +def test_hoisting_survives_a_response_with_no_choices(): + assert normalize_response({}) == {} + + +def test_parallel_tool_calls_goes_with_an_empty_tools_array(): + """Found by the compatibility matrix: codex against OpenAI failed EVERY call with + + Invalid value for 'parallel_tool_calls': 'parallel_tool_calls' is only allowed when + 'tools' are specified. + + It sends `tools: []` plus `parallel_tool_calls`; stripping only the empty list left the orphan. + vLLM ignores the orphan, which is why this survived until a hosted provider was tried. + """ + server = pytest.importorskip("openenv.core.harness.capture.server") + body = { + "model": "m", + "tools": [], + "parallel_tool_calls": True, + "tool_choice": "auto", + } + server.normalise_for_capture(body) + assert "tools" not in body + assert "parallel_tool_calls" not in body + assert "tool_choice" not in body + + +def test_parallel_tool_calls_survives_when_tools_are_real(): + """It is only invalid without tools; a genuine manifest must keep its companions.""" + server = pytest.importorskip("openenv.core.harness.capture.server") + body = { + "model": "m", + "tools": [{"type": "function", "function": {"name": "bash"}}], + "parallel_tool_calls": True, + } + server.normalise_for_capture(body) + assert body["parallel_tool_calls"] is True + assert len(body["tools"]) == 1 diff --git a/tests/envs/test_harbor_capture_request_validation.py b/tests/envs/test_harbor_capture_request_validation.py new file mode 100644 index 0000000000..e6e5455599 --- /dev/null +++ b/tests/envs/test_harbor_capture_request_validation.py @@ -0,0 +1,128 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Request-validation behavior at the Capture Proxy boundary.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +server = pytest.importorskip("openenv.core.harness.capture.server") + + +@pytest.fixture +def client() -> TestClient: + with TestClient(server.create_app(), raise_server_exceptions=False) as client: + yield client + + +def _headers(client: TestClient) -> dict[str, str]: + session = client.post("/sessions", json={}).json() + return {"Authorization": f"Bearer {session['session_id']}"} + + +@pytest.mark.parametrize( + ("path", "body", "message"), + [ + ( + "/v1/messages", + {"messages": "not-an-array"}, + "Anthropic messages must be an array", + ), + ( + "/v1/messages", + {"messages": ["not-an-object"]}, + "Anthropic messages must be objects", + ), + ("/v1/messages", {"tools": "not-an-array"}, "Anthropic tools must be an array"), + ( + "/v1/messages", + {"tools": ["not-an-object"]}, + "Anthropic tools must be objects", + ), + ( + "/v1/responses", + {"input": {"not": "supported"}}, + "Responses input must be a string or an array of objects", + ), + ( + "/v1/responses", + {"input": ["not-an-object"]}, + "Responses input items must be objects", + ), + ( + "/v1/responses", + {"tools": "not-an-array"}, + "Responses tools must be an array", + ), + ( + "/v1/responses", + {"tools": ["not-an-object"]}, + "Responses tools must be objects", + ), + ], +) +def test_invalid_dialect_payload_returns_a_client_error( + client: TestClient, path: str, body: dict[str, object], message: str +) -> None: + response = client.post(path, json=body, headers=_headers(client)) + + assert response.status_code == 400 + assert response.json() == { + "error": {"message": message, "type": "invalid_request_error"} + } + + +def test_non_object_request_body_returns_a_client_error(client: TestClient) -> None: + response = client.post("/v1/chat/completions", json=[], headers=_headers(client)) + + assert response.status_code == 400 + assert response.json() == { + "error": { + "message": "body must be a JSON object", + "type": "invalid_request_error", + } + } + + +@pytest.mark.parametrize("metadata", [[], ["unexpected"], "unexpected"]) +def test_session_registration_rejects_non_object_metadata( + client: TestClient, metadata: object +) -> None: + response = client.post("/sessions", json={"metadata": metadata}) + + assert response.status_code == 400 + assert response.json() == { + "error": { + "message": "metadata must be a JSON object", + "type": "invalid_request_error", + } + } + + +@pytest.mark.parametrize("key", ["session_id", "upstream", "capture_level"]) +def test_session_registration_rejects_reserved_metadata_keys( + client: TestClient, key: str +) -> None: + response = client.post("/sessions", json={"metadata": {key: "conflict"}}) + + assert response.status_code == 400 + assert response.json() == { + "error": { + "message": f"metadata cannot include reserved key: {key}", + "type": "invalid_request_error", + } + } + + +def test_session_registration_accepts_non_reserved_metadata(client: TestClient) -> None: + response = client.post("/sessions", json={"metadata": {"task_id": "task-123"}}) + + assert response.status_code == 200 + assert response.json()["session_id"] diff --git a/tests/envs/test_harbor_capture_server.py b/tests/envs/test_harbor_capture_server.py new file mode 100644 index 0000000000..1697fef198 --- /dev/null +++ b/tests/envs/test_harbor_capture_server.py @@ -0,0 +1,125 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Port-ownership guarantees for the capture proxy. + +A capture server that reports healthy while a *different* process owns its port is the worst +failure this layer has: sessions are minted in one registry and validated against another, so the +agent is rejected with 401, every rollout reports zero model calls, and the UI shows a live view +that can never advance. Nothing in that chain names the port, so these tests pin the invariant that +`start()` refuses rather than proceeds. + +No credentials and no engine are needed: `start()` binds a socket and never contacts `llm_url`. +""" + +from __future__ import annotations + +import socket + +import pytest + +harbor_runner = pytest.importorskip("openenv.harbor.runner") + +CaptureServer = harbor_runner.CaptureServer + +# Never contacted. Discard port, so a stray request would fail loudly rather than reach a real host. +UNUSED_ENGINE = "http://127.0.0.1:9/v1" + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +@pytest.fixture +def capture(): + """Yield a factory that tears every server it built back down.""" + built = [] + + def make(port: int) -> CaptureServer: + server = CaptureServer(llm_url=UNUSED_ENGINE, model="test-model", port=port) + built.append(server) + return server + + yield make + for server in reversed(built): + server.stop() + + +def test_health_reports_this_instance(capture): + """`/health` must identify which app answered, so a probe can check identity not reachability.""" + import httpx + + server = capture(_free_port()) + server.start() + + payload = httpx.get(f"http://127.0.0.1:{server.port}/health", timeout=5.0).json() + assert payload["instance"] == server.app.state.instance_id + assert payload["status"] == "ok" + + +def test_instance_ids_are_distinct(capture): + """Two apps must never share an id, or the identity probe cannot tell them apart.""" + first, second = capture(_free_port()), capture(_free_port()) + assert first.app.state.instance_id != second.app.state.instance_id + + +def test_start_refuses_a_port_another_server_holds(capture): + """The regression: a second server on a held port used to report healthy. + + Its own uvicorn fails to bind on a background thread where nothing observes the error, while the + liveness probe connects successfully to the *incumbent*. Reachability is not ownership. + """ + port = _free_port() + incumbent = capture(port) + incumbent.start() + + intruder = capture(port) + with pytest.raises(RuntimeError, match=f"{port}"): + intruder.start() + + # The incumbent must be untouched: a failed start may not disturb a working server. + import httpx + + payload = httpx.get(f"http://127.0.0.1:{port}/health", timeout=5.0).json() + assert payload["instance"] == incumbent.app.state.instance_id + + +def test_start_refuses_a_port_held_by_a_non_capture_listener(capture): + """Any listener counts, not just another capture server.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as squatter: + squatter.bind(("127.0.0.1", 0)) + squatter.listen(1) + port = int(squatter.getsockname()[1]) + + with pytest.raises(RuntimeError, match="already in use"): + capture(port).start() + + +def test_start_succeeds_on_a_free_port_after_a_refusal(capture): + """A refusal must leave no state behind that breaks the next attempt.""" + port = _free_port() + capture(port).start() + + with pytest.raises(RuntimeError): + capture(port).start() + + recovered = capture(_free_port()) + recovered.start() + assert recovered._thread is not None and recovered._thread.is_alive() + + +def test_stop_releases_the_port(capture): + """Otherwise a restart in the same process hits the new guard and looks like a collision.""" + port = _free_port() + server = capture(port) + server.start() + server.stop() + + successor = capture(port) + successor.start() + assert successor.app.state.instance_id != server.app.state.instance_id diff --git a/tests/envs/test_harbor_capture_trace_entries.py b/tests/envs/test_harbor_capture_trace_entries.py new file mode 100644 index 0000000000..d91579fd57 --- /dev/null +++ b/tests/envs/test_harbor_capture_trace_entries.py @@ -0,0 +1,121 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""The loop-owning training endpoint, and the contract types it serves. + +`GET /sessions/{id}/trace_entries` exists because a loop-owning consumer -- an external agent that +drives its own tool loop, opencode or codex or claude-code -- wants per-model-call records, not the +stitched sequence document `/rollout` returns. `to_trace_entries` already produced that shape but +could not be asked for it over HTTP, because it needs the session's graph and that is server-side +state. + +These tests pin the two things a consumer depends on and cannot check for itself: + + * the endpoint answers with `{"session_id", "entries"}` and 404s an unknown id rather than 500ing, + so a caller can distinguish "no such rollout" from "the server broke"; + * `TraceEntry` carries exactly the five keys the record is defined to have. A consumer builds + training rows off those key names, so a rename is a silent breakage -- the trainer would read + empty token fields and report a rollout that learned nothing. + +No engine is contacted: `llm_url` is the discard port, so a stray request would fail loudly. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("fastapi") +capture_server = pytest.importorskip("openenv.core.harness.capture.server") +from fastapi.testclient import TestClient # noqa: E402 +from openenv.core.harness import LoopOwningSession, TraceEntry # noqa: E402 + + +# Never contacted. Discard port, so a request that escaped would fail rather than reach a host. +UNUSED_ENGINE = "http://127.0.0.1:9/v1" + + +@pytest.fixture +def client() -> TestClient: + return TestClient(capture_server.create_app(llm_url=UNUSED_ENGINE, model="unused")) + + +def test_unknown_session_is_404_not_500(client: TestClient) -> None: + # A 500 here would be read as "the capture server is broken" and send someone to the wrong + # place; the distinction between an unknown rollout and a broken server has to survive. + response = client.get("/sessions/no-such-session/trace_entries") + assert response.status_code == 404 + assert response.json() == {"error": "unknown session"} + + +def test_fresh_session_returns_an_empty_entry_list(client: TestClient) -> None: + session_id = client.post("/sessions", json={}).json()["session_id"] + + response = client.get(f"/sessions/{session_id}/trace_entries") + + assert response.status_code == 200 + body = response.json() + assert body["session_id"] == session_id + # Empty, not absent: a rollout that captured nothing yet is a valid answer, and a consumer + # must be able to tell it apart from a malformed reply. + assert body["entries"] == [] + + +def test_trace_entry_carries_exactly_the_documented_keys() -> None: + # Consumers index these by name to build training rows, so a rename breaks them silently -- + # the token fields simply read empty and the rollout looks like it learned nothing. + # + # `prompt_token_ids`, `loss_mask`, `reward` and `metadata` were added 2026-09. The first is the + # load-bearing one: without it a consumer must re-render the prompt with apply_chat_template, + # which matched the engine on 0 of 28 measured turns on Qwen3.5-4B and collapsed a run at its + # first weight update. + assert set(TraceEntry.__annotations__) == { + "request", + "response", + "prompt_token_ids", + "completion_token_ids", + "completion_tokens", + "per_token_logps", + "loss_mask", + "reward", + "metadata", + } + + +def test_trace_entry_is_total_false_so_partial_records_are_legal() -> None: + # An eval-tier rollout has no token fields by design. If the record required them, every + # eval capture would be a type error rather than a legitimately partial record. + assert TraceEntry.__total__ is False + + +def test_loop_owning_session_protocol_is_structural() -> None: + """A session satisfies the protocol by shape, never by inheritance. + + That freedom is the whole point: opencode reads a file out of its sandbox, the capture proxy + answers over HTTP, and the consumer distinguishes neither. + """ + + class ReadsAFile: + def wait_for_completion(self, timeout_s: float | None = None) -> int: + return 0 + + def fetch_proxy_trace(self) -> list[TraceEntry]: + return [] + + class CallsAServer: + def wait_for_completion(self, timeout_s: float | None = None) -> int: + return 0 + + def fetch_proxy_trace(self) -> list[TraceEntry]: + return [{"request": {}, "response": {}, "completion_token_ids": [1]}] + + for candidate in (ReadsAFile(), CallsAServer()): + assert isinstance(candidate, LoopOwningSession) + + class MissingTheTrace: + def wait_for_completion(self, timeout_s: float | None = None) -> int: + return 0 + + assert not isinstance(MissingTheTrace(), LoopOwningSession) diff --git a/tests/envs/test_harbor_capture_validate.py b/tests/envs/test_harbor_capture_validate.py new file mode 100644 index 0000000000..82cc4fe72f --- /dev/null +++ b/tests/envs/test_harbor_capture_validate.py @@ -0,0 +1,161 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Validation on ingest, and the capability checks that run before a rollout starts. + +Both exist to convert a silent wrong answer into a loud one. A turn whose logprobs are misaligned +has to be caught while we still know which turn it was; a sandbox that cannot be constructed has to +be caught before it is offered rather than 90 seconds into a run. +""" + +from __future__ import annotations + +import sys +import types + +import pytest + +validate = pytest.importorskip("openenv.core.harness.capture.validate") +capabilities = pytest.importorskip("openenv.harbor.capabilities") + +check_turn = validate.check_turn + + +def codes(report) -> set[str]: + return {f.code for f in report.findings} + + +# --- per-turn validation ---------------------------------------------------- +def test_a_well_formed_turn_passes(): + report = check_turn([1, 2, 3], [4, 5], [-0.1, -0.2], finish_reason="stop") + assert report.ok + + +def test_missing_prompt_ids_is_fatal(): + """The endpoint was started without token-id capture: every rebuilt row would be empty.""" + report = check_turn([], [4], [-0.1]) + assert not report.ok + assert "no_prompt_ids" in codes(report) + + +def test_logprob_count_must_match_sampled_count(): + """Off-by-one here silently trains on the wrong token's probability.""" + report = check_turn([1], [4, 5, 6], [-0.1, -0.2]) + assert not report.ok + + +def test_a_turn_that_sampled_nothing_is_reported_but_not_fatal(): + """Reported, not rejected: a model can legitimately stop without emitting a token. + + `ok` means usable, so an empty completion warns rather than invalidating the rollout. It still + has to be visible, because a run of these means the agent is looping without producing anything. + """ + report = check_turn([1, 2], [], []) + assert report.ok + assert "no_sampled_ids" in codes(report) + + +def test_findings_name_the_turn(): + """A misalignment is only actionable if you know which call produced it.""" + report = check_turn([], [1], [-0.1], index=7) + assert any("turn 7" in str(f) for f in report.findings) + + +# --- sandbox capability ----------------------------------------------------- +def _module_with(**flags): + module = types.ModuleType("fake_backend_module") + for key, value in flags.items(): + setattr(module, key, value) + sys.modules[module.__name__] = module + return module + + +def test_missing_sdk_is_detected_from_the_backends_own_flag(): + """Harbor guards each SDK with a module-level `_HAS_X` and raises from `__init__`. + + So the module imports, the class loads, and the check passes, with the failure arriving only + once a rollout tries to build a sandbox, where it reads as a broken rollout rather than a + missing dependency. This is the case that shipped a Space offering `e2b` it could never run. + """ + module = _module_with(_HAS_E2B=False) + cls = type("E2BEnvironment", (), {"__module__": module.__name__}) + detail = capabilities._missing_sdk(cls) + assert detail and "e2b" in detail + assert "harbor[cloud]" in detail or "openenv[harbor]" in detail + + +def test_present_sdk_reports_nothing(): + module = _module_with(_HAS_E2B=True) + cls = type("E2BEnvironment", (), {"__module__": module.__name__}) + assert capabilities._missing_sdk(cls) == "" + + +def test_a_backend_without_flags_is_not_assumed_broken(): + module = _module_with(SOMETHING_ELSE=1) + cls = type("Plain", (), {"__module__": module.__name__}) + assert capabilities._missing_sdk(cls) == "" + + +def test_several_missing_extras_are_all_named(): + module = _module_with(_HAS_MODAL=False, _HAS_DOCKERFILE_PARSE=False) + cls = type("ModalEnvironment", (), {"__module__": module.__name__}) + detail = capabilities._missing_sdk(cls) + assert "modal" in detail and "dockerfile_parse" in detail + + +def test_an_unimportable_module_is_not_a_crash(): + cls = type("Ghost", (), {"__module__": "module.that.does.not.exist"}) + assert capabilities._missing_sdk(cls) == "" + + +def test_unknown_sandbox_names_are_rejected_by_name(): + status = capabilities.check_sandbox("not-a-real-backend") + assert status.available is False + assert "unknown" in status.detail.lower() or "harbor" in status.detail.lower() + + +# --- capability reporting --------------------------------------------------- +def test_render_says_why_a_sandbox_is_unavailable(): + """The commonest cause of a rollout dying 90s in is a missing key, so it belongs at startup.""" + caps = capabilities.Capabilities( + sandboxes=[ + capabilities.SandboxStatus("e2b", True), + capabilities.SandboxStatus("daytona", False, "DAYTONA_API_KEY is not set"), + ] + ) + out = caps.render() + assert "DAYTONA_API_KEY is not set" in out + assert caps.available_sandboxes == ["e2b"] + + +def test_render_warns_when_nothing_is_usable(): + caps = capabilities.Capabilities( + sandboxes=[capabilities.SandboxStatus("e2b", False, "no key")] + ) + assert "WARNING" in caps.render() + + +def test_capabilities_serialise_for_the_wire(): + caps = capabilities.Capabilities( + sandboxes=[capabilities.SandboxStatus("e2b", True)], + llm={"model": "m", "ok": True}, + ) + payload = caps.to_dict() + assert set(payload) == {"harnesses", "sandboxes", "datasets", "llm"} + assert payload["llm"]["ok"] is True + + +def test_the_install_hint_does_not_point_at_the_unsatisfiable_extra(): + """`harbor[cloud]` cannot be installed: langsmith and tensorlake demand incompatible websockets. + + Both pyprojects avoid it for that reason, so an error message telling someone to install it + would send them straight to a resolver failure. + """ + module = _module_with(_HAS_DAYTONA=False) + cls = type("DaytonaEnvironment", (), {"__module__": module.__name__}) + detail = capabilities._missing_sdk(cls) + assert "harbor[cloud]" not in detail + assert "openenv[harbor]" in detail diff --git a/tests/envs/test_harbor_contract_export.py b/tests/envs/test_harbor_contract_export.py new file mode 100644 index 0000000000..763f94fb13 --- /dev/null +++ b/tests/envs/test_harbor_contract_export.py @@ -0,0 +1,123 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""What the trainer-facing contract includes, and what it must exclude. + +Both directions are silent when wrong. Dropping agent turns trains on part of a rollout while +reporting the whole reward; including auxiliary turns credits a next-speaker classification with +solving the task. +""" + +from __future__ import annotations + +import pytest + +contract = pytest.importorskip("openenv.core.harness.capture.contract") +graph_mod = pytest.importorskip("openenv.core.harness.capture.graph") + +RolloutGraph = graph_mod.RolloutGraph +TurnNode = graph_mod.TurnNode + + +def node(node_id: str, prompt: list[int], sampled: list[int]) -> TurnNode: + return TurnNode( + node_id=node_id, + prompt_ids=prompt, + sampled_ids=sampled, + sampled_logprobs=[-0.1] * len(sampled), + request_messages=[{"role": "user", "content": "hi"}], + response_message={"content": "ok"}, + ) + + +@pytest.fixture +def two_agent_roots(): + """A harness that rewrote its system prompt mid-run, so the rollout has two agent roots.""" + g = RolloutGraph() + g.add_turn(node("a1", [1], [2, 3])) + g.add_turn(node("a2", [1, 2, 3, 9], [4])) + g.add_turn(node("b1", [500], [6])) + document = { + "sequences": [ + {"role": "agent", "root_id": "a1", "node_ids": ["a1", "a2"]}, + {"role": "agent", "root_id": "b1", "node_ids": ["b1"]}, + ] + } + return g, document + + +def test_every_agent_root_is_exported(two_agent_roots): + """The regression: only the first agent sequence was kept, so later roots vanished.""" + g, document = two_agent_roots + assert [n.node_id for n in contract._agent_nodes(g, document)] == ["a1", "a2", "b1"] + + +def test_turn_records_cover_every_agent_turn(two_agent_roots): + g, document = two_agent_roots + records = contract.to_turn_records(g, document) + assert len(records) == 3 + for prompt_ids, output_ids, logps in records: + assert output_ids, "a turn with no sampled tokens is not trainable" + assert len(output_ids) == len(logps) + + +def test_trace_entries_cover_every_agent_turn(two_agent_roots): + g, document = two_agent_roots + assert len(contract.to_trace_entries(g, document)) == 3 + + +def test_auxiliary_sequences_are_excluded(): + """An aux call must never be credited with the reward the agent earned.""" + g = RolloutGraph() + g.add_turn(node("agent", [1], [2])) + g.add_turn(node("aux", [900], [3])) + document = { + "sequences": [ + {"role": "agent", "root_id": "agent", "node_ids": ["agent"]}, + {"role": "auxiliary", "root_id": "aux", "node_ids": ["aux"]}, + ] + } + assert [n.node_id for n in contract._agent_nodes(g, document)] == ["agent"] + + +def test_discarded_sequences_are_excluded(): + g = RolloutGraph() + g.add_turn(node("kept", [1], [2])) + g.add_turn(node("dead", [1], [3])) + document = { + "sequences": [ + {"role": "agent", "root_id": "kept", "node_ids": ["kept"]}, + {"role": "discarded", "root_id": "kept", "node_ids": ["dead"]}, + ] + } + assert [n.node_id for n in contract._agent_nodes(g, document)] == ["kept"] + + +def test_a_node_shared_by_two_paths_appears_once(): + """Forked paths share their prefix; the shared turn must not be exported twice.""" + g = RolloutGraph() + g.add_turn(node("shared", [1], [2])) + g.add_turn(node("left", [1, 2], [3])) + g.add_turn(node("right", [1, 2], [4])) + document = { + "sequences": [ + {"role": "agent", "root_id": "shared", "node_ids": ["shared", "left"]}, + {"role": "agent", "root_id": "shared", "node_ids": ["shared", "right"]}, + ] + } + ids = [n.node_id for n in contract._agent_nodes(g, document)] + assert ids.count("shared") == 1 + assert set(ids) == {"shared", "left", "right"} + + +def test_no_agent_sequences_is_empty_not_an_error(): + g = RolloutGraph() + g.add_turn(node("aux", [1], [2])) + document = { + "sequences": [{"role": "auxiliary", "root_id": "aux", "node_ids": ["aux"]}] + } + assert contract._agent_nodes(g, document) == [] + assert contract.to_turn_records(g, document) == [] diff --git a/tests/envs/test_harbor_e2b_stream.py b/tests/envs/test_harbor_e2b_stream.py new file mode 100644 index 0000000000..073949f101 --- /dev/null +++ b/tests/envs/test_harbor_e2b_stream.py @@ -0,0 +1,96 @@ +import asyncio +from types import SimpleNamespace + +import pytest + +pytest.importorskip("harbor.environments.e2b") + +from openenv.harbor.e2b_stream import E2BStreamingEnvironment +from tenacity import wait_none + + +def environment(files): + env = object.__new__(E2BStreamingEnvironment) + env._sandbox = SimpleNamespace(files=files) + return env + + +def test_directory_upload_preserves_bytes_paths_and_closes_streams(tmp_path): + (tmp_path / "nested").mkdir() + (tmp_path / "nested/binary").write_bytes(bytes(range(256)) * 100) + (tmp_path / "text").write_text("hello\n") + observed, handles = {}, [] + + async def write_files(entries, **kwargs): + assert kwargs == {"gzip": True, "use_octet_stream": True, "request_timeout": 30} + for entry in entries: + handles.append(entry["data"]) + observed[entry["path"]] = entry["data"].read() + + env = environment(SimpleNamespace(write_files=write_files)) + asyncio.run(env.upload_dir(tmp_path, "/logs/agent")) + assert observed == { + "/logs/agent/nested/binary": bytes(range(256)) * 100, + "/logs/agent/text": b"hello\n", + } + assert all(handle.closed for handle in handles) + + +def test_retry_reopens_source_and_replays_identical_file_bytes(tmp_path, monkeypatch): + source = tmp_path / "log" + source.write_bytes(b"exact evidence") + seen, handles = [], [] + + async def write(path, stream, **kwargs): + handles.append(stream) + seen.append((path, stream.read())) + if len(seen) == 1: + raise TimeoutError("transport stalled") + + monkeypatch.setattr(E2BStreamingEnvironment.upload_file.retry, "wait", wait_none()) + asyncio.run( + environment(SimpleNamespace(write=write)).upload_file(source, "/logs/log") + ) + assert seen == [("/logs/log", b"exact evidence")] * 2 + assert all(handle.closed for handle in handles) + + +def test_hung_upload_has_total_deadline_and_bounded_retries(tmp_path, monkeypatch): + (tmp_path / "log").write_text("data") + deadlines, calls = [], [] + real_wait_for = asyncio.wait_for + + async def bounded(coro, timeout): + deadlines.append(timeout) + return await real_wait_for(coro, timeout=0.005) + + async def write_files(entries, **kwargs): + calls.extend(entry["data"] for entry in entries) + await asyncio.Event().wait() + + monkeypatch.setattr(asyncio, "wait_for", bounded) + monkeypatch.setattr(E2BStreamingEnvironment.upload_dir.retry, "wait", wait_none()) + with pytest.raises(TimeoutError): + asyncio.run( + environment(SimpleNamespace(write_files=write_files)).upload_dir( + tmp_path, "/logs" + ) + ) + assert deadlines == [120, 120] + assert len(calls) == 2 and all(handle.closed for handle in calls) + + +def test_cancellation_is_propagated_without_retry(tmp_path): + source = tmp_path / "log" + source.write_bytes(b"data") + calls = [] + + async def write(path, stream, **kwargs): + calls.append(stream) + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + asyncio.run( + environment(SimpleNamespace(write=write)).upload_file(source, "/logs/log") + ) + assert len(calls) == 1 and calls[0].closed diff --git a/tests/envs/test_harbor_forwarding_lifecycle.py b/tests/envs/test_harbor_forwarding_lifecycle.py new file mode 100644 index 0000000000..0a44a300c8 --- /dev/null +++ b/tests/envs/test_harbor_forwarding_lifecycle.py @@ -0,0 +1,67 @@ +"""A live forwarder must not block when its child fills stdout or stderr.""" + +import subprocess +import sys +import time +from types import SimpleNamespace + +from openenv.core.harness.capture.forwarding import GradioForwarder + + +def test_gradio_drains_both_pipes_and_stops_only_its_child(monkeypatch, tmp_path): + finished = tmp_path / "both-pipes-written" + children = [] + unrelated = SimpleNamespace(share_token="other", proc=None) + tunnels = [unrelated] + + def setup_tunnel(**kwargs): + process = subprocess.Popen( + [ + sys.executable, + "-c", + ( + "import os, pathlib, sys, time; " + "os.write(1, b'x' * 1048576 + b'\\n'); " + "os.write(2, b'y' * 1048576 + b'\\n'); " + "pathlib.Path(sys.argv[1]).write_text('done'); time.sleep(60)" + ), + str(finished), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + children.append(process) + tunnel = SimpleNamespace(share_token=kwargs["share_token"], proc=process) + + def kill(): + process.terminate() + tunnel.proc = None + + tunnel.kill = kill + tunnels.append(tunnel) + return "https://test.invalid" + + monkeypatch.setitem( + sys.modules, "gradio.networking", SimpleNamespace(setup_tunnel=setup_tunnel) + ) + monkeypatch.setitem( + sys.modules, "gradio.tunneling", SimpleNamespace(CURRENT_TUNNELS=tunnels) + ) + forwarder = GradioForwarder() + try: + assert forwarder.start(8123) == "https://test.invalid" + deadline = time.monotonic() + 5 + while not finished.exists() and time.monotonic() < deadline: + time.sleep(0.02) + assert finished.exists(), "forwarder blocked on a full child-process pipe" + forwarder.stop() + assert children[0].poll() is not None + assert unrelated in tunnels + forwarder.stop() + finally: + for process in children: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + process.stdout.close() + process.stderr.close() diff --git a/tests/envs/test_harbor_google_signature.py b/tests/envs/test_harbor_google_signature.py new file mode 100644 index 0000000000..6eb1650892 --- /dev/null +++ b/tests/envs/test_harbor_google_signature.py @@ -0,0 +1,44 @@ +"""Google's bytes fields must survive strict SDK JSON decoding.""" + +import base64 + +from openenv.core.harness.capture.dialects.google import ( + _GoogleStreamState, + GoogleTransformer, +) +from openenv.core.harness.capture.dialects.reasoning import make_signature + + +def _assert_signature(response): + part = response["candidates"][0]["content"]["parts"][0] + assert part["thought"] is True + assert base64.b64decode( + part["thoughtSignature"], validate=True + ).decode() == make_signature(part["text"]) + + +def test_google_buffered_thought_signature_is_json_bytes(): + response = GoogleTransformer().transform_response( + { + "choices": [ + { + "message": { + "reasoning_content": "Inspect the CSV first.", + "content": "Working.", + }, + "finish_reason": "stop", + } + ] + }, + {}, + ) + _assert_signature(response) + + +def test_google_streamed_thought_signature_is_json_bytes(): + responses = _GoogleStreamState(GoogleTransformer()).process_chunk( + {"choices": [{"delta": {"reasoning_content": "Inspect the CSV first."}}]} + ) + assert responses + for response in responses: + _assert_signature(response) diff --git a/tests/envs/test_harbor_hosted_serving.py b/tests/envs/test_harbor_hosted_serving.py new file mode 100644 index 0000000000..5bf9355a45 --- /dev/null +++ b/tests/envs/test_harbor_hosted_serving.py @@ -0,0 +1,180 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Serving on a hosted platform, where there is one port and one URL. + +Locally the capture proxy runs on its own port and is published to the sandbox. A Space exposes +exactly one port and already has a public URL, so the proxy is mounted onto the env server's app +instead and nothing is forwarded. These tests pin that split, because getting it wrong is not a +crash: it is a deployment that quietly opens a second listener it cannot publish. +""" + +from __future__ import annotations + +import pytest + +serving = pytest.importorskip("openenv.harbor.serving") + +CAPTURE_MOUNT = serving.CAPTURE_MOUNT +HarborService = serving.HarborService +space_public_url = serving.space_public_url + +# Never contacted: nothing here reaches an engine. +UNUSED_LLM = "http://127.0.0.1:9/v1" + + +@pytest.fixture(autouse=True) +def _clear_space_env(monkeypatch): + """Tests must not inherit a Space identity from the developer's shell.""" + monkeypatch.delenv("SPACE_HOST", raising=False) + monkeypatch.delenv("SPACE_ID", raising=False) + + +def test_no_space_means_no_public_url(): + assert space_public_url() == "" + + +def test_space_host_is_used_verbatim(monkeypatch): + monkeypatch.setenv("SPACE_HOST", "owner-env.hf.space") + assert space_public_url() == "https://owner-env.hf.space" + + +def test_space_host_tolerates_a_scheme_already_present(monkeypatch): + monkeypatch.setenv("SPACE_HOST", "https://owner-env.hf.space/") + assert space_public_url() == "https://owner-env.hf.space" + + +def test_space_id_is_slugged_when_host_is_absent(monkeypatch): + """`SPACE_ID` is always set; the hostname lowercases and dash-separates it.""" + monkeypatch.setenv("SPACE_ID", "AdithyaSK/harbor_data.agent-env") + assert space_public_url() == "https://adithyask-harbor-data-agent-env.hf.space" + + +def test_hosted_start_mounts_and_never_forwards(monkeypatch): + """The regression that got a Space flagged: a hosted deployment must not forward.""" + monkeypatch.setenv("SPACE_ID", "owner/env") + + def explode(*_args, **_kwargs): + raise AssertionError("a hosted deployment must not create a forwarder") + + monkeypatch.setattr( + "openenv.core.harness.capture.forwarding.make_forwarder", explode, raising=False + ) + + service = HarborService(llm_url=UNUSED_LLM, model="m", datasets=[]) + url = service.start() + + assert service.mounted is True + assert url == f"https://owner-env.hf.space{CAPTURE_MOUNT}" + # No port was bound, so stop() must be safe even though start() never launched a server. + service.stop() + + +def test_mounted_capture_answers_under_the_prefix(): + """Mounting strips the prefix, so every dialect route keeps working unchanged.""" + fastapi = pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + from openenv.core.harness.capture.server import create_app + + host = fastapi.FastAPI() + host.mount(CAPTURE_MOUNT, create_app(llm_url=UNUSED_LLM, model="m")) + client = TestClient(host) + + assert client.get(f"{CAPTURE_MOUNT}/health").json()["status"] == "ok" + + # The proxy's catch-all must match /v1/chat/completions, not /capture/v1/chat/completions. + response = client.post( + f"{CAPTURE_MOUNT}/v1/chat/completions", + json={"model": "m", "messages": [{"role": "user", "content": "hi"}]}, + headers={"Authorization": "Bearer not-a-session"}, + ) + # 401 rather than 404 proves it routed to the proxy and was rejected on identity, which is also + # what stops a publicly mounted proxy being an open relay. + assert response.status_code == 401 + assert "unknown API key" in response.json()["error"]["message"] + + +def test_a_failed_forwarder_does_not_leave_the_capture_server_running(monkeypatch): + """A half-started service poisons every later attempt. + + The capture server binds its port and starts a thread before the forwarder is built. If the + forwarder then fails, leaving that server up means the next `start()` fails on a port conflict + that says nothing about the real error. + """ + stopped: list[bool] = [] + + class FakeCapture: + port = 8123 + + def start(self): + pass + + def stop(self): + stopped.append(True) + + def explode(*_args, **_kwargs): + raise RuntimeError("cloudflared is not installed") + + monkeypatch.setattr( + "openenv.core.harness.capture.forwarding.make_forwarder", explode, raising=False + ) + + service = HarborService(llm_url=UNUSED_LLM, model="m", datasets=[]) + service.capture = FakeCapture() + + with pytest.raises(RuntimeError, match="cloudflared"): + service.start() + + assert stopped == [True], "the capture server was left holding its port" + assert service.public_url in (None, "") + + +def test_a_space_that_cannot_probe_does_not_claim_to_be_trainable( + monkeypatch, tmp_path +): + """The Space entry point defaulted `_CAPTURE_LEVEL` to "tokens" and only corrected it when a model + resolved AND the probe succeeded. An ambiguous model list, an unset model or a raising probe left + it at token level, so the proxy was built for capture and every rollout was stamped trainable — + the exact mislabelling the capture level exists to prevent. Unknown must mean the weaker tier. + """ + import runpy + + monkeypatch.setenv("OPENENV_LLM_URL", "http://127.0.0.1:9/v1") + monkeypatch.delenv("OPENENV_MODEL", raising=False) + monkeypatch.delenv("SPACE_HOST", raising=False) + monkeypatch.delenv("SPACE_ID", raising=False) + # Serves two models and names neither, so nothing resolves and the probe never runs. + # + # Patched through the module OBJECT, not the dotted string: the package re-exports a function + # called `validate_llm` which shadows the same-named submodule, so the string form resolves to the + # function and monkeypatch fails with "'function' object has no attribute 'list_models'". + import importlib + + validate_llm_mod = importlib.import_module( + "openenv.core.harness.capture.validate_llm" + ) + monkeypatch.setattr(validate_llm_mod, "list_models", lambda *a, **k: ["one", "two"]) + # The service must not be started for real; capture the level it would have been built with. + built: dict = {} + + class FakeService: + def __init__(self, **kwargs): + built.update(kwargs) + + def start(self): + return "" + + @classmethod + def set_current(cls, _service): + pass + + monkeypatch.setattr(serving, "HarborService", FakeService) + monkeypatch.setattr(serving, "build_app", lambda **kwargs: kwargs) + + runpy.run_module("harbor_env.server.app", run_name="not_main") + assert built.get("capture_level") == "text", ( + "an unprobed endpoint must default to the weakest tier, never to tokens" + ) diff --git a/tests/envs/test_harbor_install_fixes.py b/tests/envs/test_harbor_install_fixes.py new file mode 100644 index 0000000000..355ed5cca6 --- /dev/null +++ b/tests/envs/test_harbor_install_fixes.py @@ -0,0 +1,320 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""The upstream Harbor defect that cost `openclaw` its ATIF trajectory. + +The sibling `hermes` fix went away with the seam itself: hermes-agent fails to install +(`exit 127`, 5/5 attempts), so there is nothing left to intercept. + +Both failed the same way: no error, no exception, no missing file -- just an agent that quietly +produced no trace, so every rollout reported `atif=none` and the cross-check silently did not exist. +Neither is detectable from a passing rollout, which is why they are pinned here. +""" + +from __future__ import annotations + +import json + +import pytest + +install_fixes = pytest.importorskip("openenv.harbor.install_fixes") +openclaw_mod = pytest.importorskip("harbor.agents.installed.openclaw") + +TRIM = install_fixes._OPENCLAW_TRIM_TRAILING_LOG +OpenClaw = openclaw_mod.OpenClaw + +_CONTAINER_PATH = "/logs/agent/openclaw.txt" +_SESSION_FILE = "/root/.openclaw/agents/main/sessions/790c93f1.jsonl" + + +def test_sqlite_export_preserves_per_call_usage_for_harbor(tmp_path, monkeypatch): + import subprocess + from types import SimpleNamespace + + meta = {"sessionId": "session-1", "sessionFile": "agent:main:main"} + (tmp_path / "openclaw.txt").write_text(json.dumps({"meta": {"agentMeta": meta}})) + entries = [ + { + "type": "message", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "answer"}], + "usage": {"input": 100 + count, "output": count}, + }, + } + for count in [137, 124, 96, 5] + ] + bundle = tmp_path / "bundle" + bundle.mkdir() + (bundle / "session-branch.json").write_text(json.dumps({"entries": entries})) + + def run(command, **kwargs): + assert command[:5] == [ + "openclaw", + "sessions", + "export-trajectory", + "--session-key", + "agent:main:main", + ] + assert kwargs["check"] and kwargs["timeout"] == 60 + return SimpleNamespace( + stdout=json.dumps({"sessionId": "session-1", "outputDir": str(bundle)}) + ) + + monkeypatch.setattr(subprocess, "run", run) + install_fixes._export_openclaw_sqlite_transcript(str(tmp_path)) + target = tmp_path / "openclaw.session.jsonl" + assert [json.loads(line) for line in target.read_text().splitlines()] == entries + steps = openclaw_mod.openclaw_session_jsonl_to_atif_steps( + target, instruction="task", model_name="test" + ) + assert [ + step.metrics.completion_tokens for step in steps if step.source == "agent" + ] == [137, 124, 96, 5] + + +def test_sqlite_export_rejects_other_session(tmp_path, monkeypatch): + import subprocess + from types import SimpleNamespace + + (tmp_path / "openclaw.txt").write_text( + json.dumps( + { + "meta": { + "agentMeta": { + "sessionId": "expected", + "sessionFile": "agent:main:main", + } + } + } + ) + ) + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + stdout=json.dumps({"sessionId": "other"}) + ), + ) + with pytest.raises(ValueError, match="different session"): + install_fixes._export_openclaw_sqlite_transcript(str(tmp_path)) + assert not (tmp_path / "openclaw.session.jsonl").exists() + + +def test_sqlite_export_preserves_existing_native_jsonl(tmp_path, monkeypatch): + import subprocess + + target = tmp_path / "openclaw.session.jsonl" + target.write_text("existing native transcript\n") + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: pytest.fail("must preserve legacy transcript"), + ) + install_fixes._export_openclaw_sqlite_transcript(str(tmp_path)) + assert target.read_text() == "existing native transcript\n" + + +# The shape openclaw actually produces: a pretty-printed envelope whose closing brace sits at +# column 0, and whose LAST nested object is the `completion` block. Both details matter below, and +# the key ORDER is taken from a real capture file (`payloads` first, `meta` last) because the +# backwards-scan trap depends on which nested object happens to be last. +_ENVELOPE = { + "payloads": [], + "meta": { + "agentMeta": {"sessionId": "790c93f1", "sessionFile": _SESSION_FILE}, + "completion": {"stopReason": "stop", "finishReason": "stop"}, + }, +} +# Harbor merges the agent's stderr into the same file with `2>&1`, so this lands after the JSON. +_TRAILING_LOG = ( + "[agents/agent-command] [agent] run 9e921697-bfe9-4266-ad60-6e9f65d0de5e " + "ended with stopReason=stop" +) + + +def _capture_file(with_trailing_log: bool = True) -> str: + body = json.dumps(_ENVELOPE, indent=2) + return f"{body}\n{_TRAILING_LOG}\n" if with_trailing_log else f"{body}\n" + + +def _run_trim(tmp_path) -> str: + """Execute the real production trim script against a temp file, not a copy of its logic.""" + target = tmp_path / "openclaw.txt" + script = TRIM.replace(_CONTAINER_PATH, str(target)) + # If the constant is ever reworded, the substitution stops matching and this test would + # silently exercise nothing. Fail instead. + assert script != TRIM, f"{_CONTAINER_PATH!r} no longer appears in the trim script" + target.write_text(_capture_file(), encoding="utf-8") + exec(compile(script, "", "exec"), {}) + return target.read_text(encoding="utf-8") + + +# --- openclaw --------------------------------------------------------------- +def test_harbor_cannot_parse_its_own_capture_file_when_openclaw_logs_after_the_json(): + """The bug itself: one stderr line after the envelope and Harbor's parser gives up. + + `_load_json_object` requires the JSON object to consume the entire remaining suffix, but Harbor's + own `2>&1` is what put a non-JSON line there. Returning None means `populate_context_post_run` + returns at `if not envelope` and no `trajectory.json` is ever written. + """ + assert OpenClaw._load_json_object(_capture_file()) is None + + +def test_trimming_the_trailing_log_line_makes_harbors_own_parser_succeed(tmp_path): + """The fix, stated as the only thing it is allowed to be: Harbor's parser does the parsing. + + The subclass removes the trailing lines and nothing else, so the envelope that comes back is + Harbor's own -- including `agentMeta.sessionFile`, which is what the session copy needs. + """ + parsed = OpenClaw._load_json_object(_run_trim(tmp_path)) + + assert parsed is not None + assert parsed["meta"]["agentMeta"]["sessionFile"] == _SESSION_FILE + + +def test_trim_leaves_an_already_clean_capture_file_untouched(tmp_path): + """A run whose stopReason is `end_turn` logs nothing, so the file is already parseable.""" + target = tmp_path / "openclaw.txt" + script = TRIM.replace(_CONTAINER_PATH, str(target)) + clean = _capture_file(with_trailing_log=False) + target.write_text(clean, encoding="utf-8") + + exec(compile(script, "", "exec"), {}) + + assert target.read_text(encoding="utf-8") == clean + + +def test_trim_survives_a_capture_file_with_no_envelope_at_all(tmp_path): + """An agent that died before emitting JSON must not turn into a crash in our override.""" + target = tmp_path / "openclaw.txt" + script = TRIM.replace(_CONTAINER_PATH, str(target)) + garbage = "openclaw: command not found\n" + target.write_text(garbage, encoding="utf-8") + + exec(compile(script, "", "exec"), {}) + + assert target.read_text(encoding="utf-8") == garbage + + +def test_a_backwards_scan_without_the_suffix_rule_latches_onto_the_wrong_object(): + """Why the fix trims text instead of loosening the parser -- the obvious loosening is wrong. + + Dropping Harbor's "must consume the suffix" rule looks like the one-line fix. It is not: the scan + walks backwards, so the first thing that decodes is the LAST nested object, and `completion` + decodes perfectly. The caller then gets a dict with no `meta` at all and builds a degenerate + 2-step trajectory from it -- which still reports `atif=match`, because `reconcile` downgrades a + trace carrying no token counts instead of failing it. A silently wrong trace is worse than none. + """ + text = _capture_file().strip() + decoder = json.JSONDecoder() + found = None + for start in range(len(text) - 1, -1, -1): + if text[start] != "{": + continue + try: + obj, _ = decoder.raw_decode(text[start:]) + except ValueError: + continue + if isinstance(obj, dict): + found = obj + break + + assert found == {"stopReason": "stop", "finishReason": "stop"} + assert "meta" not in found + + +@pytest.mark.asyncio +async def test_openhands_clean_install_still_prepares_local_runtime(monkeypatch): + """A dependency-successful install must not leave LocalRuntime invoking real Poetry.""" + from unittest.mock import AsyncMock + + monkeypatch.setattr(install_fixes.OpenHands, "install", AsyncMock()) + agent = object.__new__(install_fixes.InterceptOpenHands) + root_exec = AsyncMock() + monkeypatch.setattr(agent, "exec_as_root", root_exec) + environment = object() + await agent.install(environment) + commands = [call.kwargs["command"] for call in root_exec.call_args_list] + assert len(commands) == 3 + assert all('/opt/openhands-venv/bin/python "$@"' in command for command in commands) + assert any("/usr/local/bin/poetry" in command for command in commands) + assert any("/opt/openhands-venv/bin/poetry" in command for command in commands) + + +def test_kimi_terminal_signal_does_not_kill_its_exec_transport(): + import subprocess + + wrapped = install_fixes._isolated_process_group("echo finished; kill 0") + completed = subprocess.run( + ["bash", "-c", wrapped], + start_new_session=True, + capture_output=True, + text=True, + timeout=5, + ) + assert completed.returncode == 143 + assert completed.stdout.strip() == "finished" + + +@pytest.mark.asyncio +async def test_openclaw_install_and_runtime_select_supported_node(monkeypatch): + from unittest.mock import AsyncMock + + execution = AsyncMock() + monkeypatch.setattr(install_fixes.OpenClaw, "exec_as_agent", execution) + agent = object.__new__(install_fixes.InterceptOpenClaw) + await agent.exec_as_agent( + object(), command="nvm install 22 && nvm use 22 && openclaw --version" + ) + assert ( + execution.call_args.kwargs["command"] + == "nvm install 24.16.0 && nvm use 24.16.0 && openclaw --version" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "adapter,parent", + [ + (install_fixes.InterceptOpenClaw, install_fixes.OpenClaw), + (install_fixes.InterceptKimi, install_fixes.KimiCli), + ], +) +async def test_command_wrappers_preserve_harbor_positional_call_contract( + monkeypatch, adapter, parent +): + from unittest.mock import AsyncMock + + execution = AsyncMock(return_value="executed") + monkeypatch.setattr(parent, "exec_as_agent", execution) + agent = object.__new__(adapter) + environment = object() + env = {"TEST_SETTING": "test"} + result = await agent.exec_as_agent(environment, "echo ready", env, "/tmp", 30) + assert result == "executed" + execution.assert_awaited_once_with( + environment, command="echo ready", env=env, cwd="/tmp", timeout_sec=30 + ) + + +def test_openclaw_catalog_model_id_is_local_to_its_provider(tmp_path): + from openenv.harbor.seams import get + + selected, kwargs, env, _ = get("openclaw").resolve( + base_url="https://capture.example", + session="session-test", + model="Qwen/Qwen3.5-4B", + ) + agent = install_fixes.InterceptOpenClaw( + logs_dir=tmp_path, model_name=selected, extra_env=env, **kwargs + ) + config = agent._build_full_openclaw_config() + provider, model_id = selected.split("/", 1) + catalog = config["models"]["providers"][provider] + assert any(model["id"] == model_id for model in catalog["models"]) + assert catalog["baseUrl"] == "https://capture.example/v1" + assert catalog["apiKey"] == "session-test" diff --git a/tests/envs/test_harbor_native_provider.py b/tests/envs/test_harbor_native_provider.py new file mode 100644 index 0000000000..8174fcee96 --- /dev/null +++ b/tests/envs/test_harbor_native_provider.py @@ -0,0 +1,337 @@ +"""Native provider semantics and honest capability classification.""" + +import copy +import json + +import httpx +import pytest +from openenv.core.harness.capture.providers import ( + anthropic_request, + anthropic_response, + replay_anthropic, +) +from openenv.core.harness.capture.upstream import InferenceClient, UpstreamRequestError +from openenv.core.harness.capture.validate_llm import validate_llm + + +def native_message(): + return { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-test", + "content": [ + { + "type": "thinking", + "thinking": "Check first", + "signature": "signed-original", + }, + { + "type": "tool_use", + "id": "toolu_1", + "name": "run", + "input": {"cmd": "pwd"}, + }, + ], + "stop_reason": "tool_use", + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 5, "cache_read_input_tokens": 7}, + } + + +def test_native_history_and_signatures_are_preserved_without_mutation(): + original = { + "model": "alias", + "max_tokens": 200, + "stream": True, + "messages": [{"role": "assistant", "content": native_message()["content"]}], + } + before = copy.deepcopy(original) + body = anthropic_request( + {"_openenv_native_request": original, "max_tokens": 100}, "pinned" + ) + assert original == before + assert body["messages"] == before["messages"] + assert ( + body["model"] == "pinned" + and body["max_tokens"] == 100 + and body["stream"] is False + ) + + +def test_chat_tool_roundtrip_keeps_ids_and_json(): + response = anthropic_response(native_message()) + assistant = response["choices"][0]["message"] + assistant.pop("reasoning_content") + body = anthropic_request( + { + "messages": [ + assistant, + {"role": "tool", "tool_call_id": "toolu_1", "content": "/tmp"}, + ] + }, + "pinned", + ) + call = body["messages"][0]["content"][0] + result = body["messages"][1]["content"][0] + assert call["id"] == result["tool_use_id"] == "toolu_1" + assert call["input"] == {"cmd": "pwd"} + assert response["usage"] == { + "prompt_tokens": 17, + "completion_tokens": 5, + "total_tokens": 22, + } + assert "prompt_token_ids" not in response + assert response["choices"][0]["logprobs"] is None + + +def test_unsigned_reasoning_is_rejected(): + with pytest.raises(UpstreamRequestError, match="signed"): + anthropic_request( + {"messages": [{"role": "assistant", "reasoning_content": "thought"}]}, "m" + ) + + +@pytest.mark.asyncio +async def test_native_wire_uses_messages_and_native_headers(): + def respond(request): + assert request.url.path == "/v1/messages" + assert request.headers["x-api-key"] == "test-secret" + assert request.headers["anthropic-version"] == "2023-06-01" + assert request.headers["anthropic-beta"] == "context-management-2025-06-27" + body = json.loads(request.content) + assert body["model"] == "pinned" + assert "return_token_ids" not in body and "logprobs" not in body + return httpx.Response(200, json=native_message()) + + client = InferenceClient( + "https://test/v1", + served_model="pinned", + api_key="test-secret", + provider="anthropic", + ) + http_client = await client._get_client() + await http_client.aclose() + client._client = httpx.AsyncClient( + base_url="https://test", + transport=httpx.MockTransport(respond), + headers={"x-api-key": "test-secret", "anthropic-version": "2023-06-01"}, + ) + try: + response = await client.completion( + { + "messages": [{"role": "user", "content": "go"}], + "_openenv_native_headers": { + "anthropic-beta": "context-management-2025-06-27", + "x-api-key": "must-not-replace-real-key", + }, + } + ) + assert client.capture_level == "text" + assert response["_openenv_native_response"] == native_message() + finally: + await client.aclose() + + +def test_sse_preserves_real_signature(): + events = [ + json.loads(frame.split("data: ", 1)[1]) + for frame in replay_anthropic(native_message()) + ] + signatures = [ + event["delta"]["signature"] + for event in events + if event.get("delta", {}).get("type") == "signature_delta" + ] + assert signatures == ["signed-original"] + assert events[0]["type"] == "message_start" and events[-1]["type"] == "message_stop" + + +def test_probe_native_tools_never_certifies_training(monkeypatch): + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def read(self): + payload = native_message() + payload["content"] = [ + { + "type": "tool_use", + "id": "toolu_probe", + "name": "report_ok", + "input": {"value": "ok"}, + } + ] + return json.dumps(payload).encode() + + def urlopen(request, timeout): + assert request.full_url == "https://test/v1/messages" + assert request.get_header("X-api-key") == "key" + return Response() + + monkeypatch.setattr("urllib.request.urlopen", urlopen) + report = validate_llm( + "https://test/v1", "claude-test", provider="anthropic", api_key="key" + ) + assert report.reachable and report.tool_support == "ok" + assert report.capture_level == "text" and not report.trainable and not report.ok + + +def test_logprob_permission_fix_removes_dependent_top_logprobs(): + from openenv.core.harness.capture.compat import diagnose + + body = { + "logprobs": True, + "top_logprobs": 0, + "messages": [{"role": "user", "content": "hi"}], + } + fix = diagnose( + { + "error": { + "message": "You are not allowed to request logprobs from this model" + } + } + ) + assert fix.apply(body) + assert body == {"messages": [{"role": "user", "content": "hi"}]} + assert not fix.apply(body) + + +@pytest.mark.parametrize( + "key,value", + [ + ("frequency_penalty", 1), + ("presence_penalty", 1), + ("repetition_penalty", 1.1), + ("min_p", 0.1), + ], +) +def test_native_conversion_rejects_sampling_semantics_it_cannot_preserve(key, value): + with pytest.raises(UpstreamRequestError, match=key): + anthropic_request({"messages": [], key: value}, "model") + + +def test_explicit_eval_keeps_capability_but_disables_supervision(): + from openenv.core.harness.capture.export import export_session + from openenv.core.harness.capture.sessions import rollout_type_for, SessionRegistry + + registry = SessionRegistry() + session = registry.create(capture_level="tokens", purpose="eval") + result = export_session(session, capture_level="tokens") + assert result["capture_level"] == "tokens" and result["rollout_type"] == "eval" + assert not result["trainable"] + assert rollout_type_for("auto", "tokens") == "train" + with pytest.raises(ValueError, match="exact engine"): + registry.create(capture_level="text", purpose="train") + with pytest.raises(ValueError, match="sampling"): + registry.create( + capture_level="tokens", purpose="eval", sampling={"temperature": 0.8} + ) + + +@pytest.mark.asyncio +async def test_native_stream_is_consumable_by_anthropic_sdk(): + anthropic = pytest.importorskip("anthropic") + message = native_message() + + def respond(request): + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content="".join(replay_anthropic(message)).encode(), + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + client = anthropic.AsyncAnthropic(api_key="test", http_client=http_client) + async with client.messages.stream( + model="claude-test", + max_tokens=64, + messages=[{"role": "user", "content": "go"}], + ) as stream: + reconstructed = await stream.get_final_message() + assert reconstructed.content[0].signature == "signed-original" + assert reconstructed.content[1].id == "toolu_1" + assert reconstructed.content[1].input == {"cmd": "pwd"} + assert reconstructed.stop_reason == "tool_use" + + +def test_eval_sampling_requires_explicit_eval_and_preserves_requested_policy(): + from openenv.core.harness.capture.sessions import SessionRegistry + + registry = SessionRegistry() + policy = {"temperature": 0.8, "top_p": 1, "top_k": -1} + session = registry.create( + purpose="eval", capture_level="text", eval_sampling=policy + ) + assert session.eval_sampling == policy and not session.sampling + with pytest.raises(ValueError, match="explicit eval"): + registry.create(purpose="auto", eval_sampling=policy) + native = anthropic_request( + {"_openenv_native_request": {"messages": [], **policy}}, "model" + ) + assert native["temperature"] == 0.8 + assert "top_p" not in native and "top_k" not in native + + +def test_strict_tools_preserve_constraint_on_native_anthropic(): + request = { + "messages": [], + "tools": [ + { + "type": "function", + "function": { + "name": "run", + "strict": True, + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + }, + } + ], + } + assert anthropic_request(request, "model")["tools"][0]["strict"] is True + + +@pytest.mark.parametrize( + "block", + [ + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "server_tool_use", "id": "srv", "name": "web_search", "input": {}}, + { + "type": "text", + "text": "cited answer", + "citations": [{"type": "web_search_result_location"}], + }, + ], +) +def test_response_semantics_are_not_silently_dropped_across_protocols(block): + from openenv.core.harness.capture.providers import ProviderConversionError + + native = native_message() + native["content"].append(block) + with pytest.raises(ProviderConversionError, match="cannot be preserved"): + anthropic_response(native) + response = anthropic_response(native, native_passthrough=True) + assert response["_openenv_native_response"] == native + response["_openenv_native_response"]["content"].clear() + assert native["content"] + + +def test_native_pause_is_not_translated_to_successful_stop(): + from openenv.core.harness.capture.providers import ProviderConversionError + + native = native_message() + native["stop_reason"] = "pause_turn" + with pytest.raises(ProviderConversionError, match="stop reason"): + anthropic_response(native) + assert ( + anthropic_response(native, native_passthrough=True)["_openenv_native_response"][ + "stop_reason" + ] + == "pause_turn" + ) diff --git a/tests/envs/test_harbor_nemo_profile.py b/tests/envs/test_harbor_nemo_profile.py new file mode 100644 index 0000000000..107b727cf7 --- /dev/null +++ b/tests/envs/test_harbor_nemo_profile.py @@ -0,0 +1,80 @@ +"""The opt-in NeMo profile keeps endpoint routing and exercises real sandbox commands.""" + +import importlib.util +import json +from pathlib import Path + +import pytest +import yaml + +pytest.importorskip("harbor.agents.installed.nemo_agent") + +from openenv.harbor.nemo_profile import NemoShellProfile + + +def test_nemo_react_profile_reuses_harbor_provider_configuration(tmp_path): + agent = NemoShellProfile( + logs_dir=tmp_path, + model_name="openai/Qwen3.5-4B", + llm_type="openai", + version="1.9.0", + extra_env={"OPENAI_BASE_URL": "https://capture.example/v1"}, + ) + config = yaml.safe_load(agent._generate_config_yaml("Qwen3.5-4B", "session-test")) + llm = config["llms"][config["workflow"]["llm_name"]] + assert llm["base_url"] == "https://capture.example/v1" + assert llm["api_key"] == "session-test" + assert llm["model_name"] == "Qwen3.5-4B" + assert config["workflow"]["use_native_tool_calling"] is True + assert config["workflow"]["tool_names"] == ["shell"] + + +def shell_module(): + path = ( + Path(__file__).parents[2] + / "examples/harbor/nemo_shell_profile/src/openenv_nat_shell/shell.py" + ) + spec = importlib.util.spec_from_file_location("qualification_shell", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.asyncio +async def test_shell_executes_and_reports_failure_without_faking_success(tmp_path): + shell = shell_module() + result = json.loads( + await shell.execute( + "printf data > answer.txt; cat answer.txt; exit 7", cwd=str(tmp_path) + ) + ) + assert result["exit_code"] == 7 + assert result["stdout"] == "data" + assert (tmp_path / "answer.txt").read_text() == "data" + + +@pytest.mark.asyncio +async def test_shell_timeout_terminates_command_group(tmp_path): + shell = shell_module() + with pytest.raises(TimeoutError): + await shell.execute( + "sleep 5; touch late-output", timeout=0.1, cwd=str(tmp_path) + ) + assert not (tmp_path / "late-output").exists() + + +def test_explicit_profile_uses_packaged_workflow_without_mutating_generic_seam(): + from pathlib import Path + + from openenv.harbor.seams import get + + generic = get("nemo-agent") + selected = get("nemo-agent", profile="shell-1.9.0") + _, kwargs, _, _ = selected.resolve( + base_url="https://proxy.example", session="session", model="model" + ) + assert selected.import_path == "openenv.harbor.nemo_profile:NemoShellProfile" + assert kwargs["version"] == "1.9.0" + assert (Path(kwargs["workflow_package"]) / "pyproject.toml").is_file() + assert get("nemo-agent") is generic + assert generic.import_path != selected.import_path diff --git a/tests/envs/test_harbor_per_session_engine.py b/tests/envs/test_harbor_per_session_engine.py new file mode 100644 index 0000000000..9b1155e7cb --- /dev/null +++ b/tests/envs/test_harbor_per_session_engine.py @@ -0,0 +1,194 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""The engine is a per-rollout property, not a per-server one. + +A dataset server is the expensive thing to keep alive: thousands of task files, prebuilt sandbox +templates. An inference engine is the cheap, changing part — it restarts every training run, and a +train-tier engine and an eval-tier one are usually both wanted against the same task suite. Pinning +the engine at boot made the durable thing hostage to the ephemeral one: no URL meant no capture proxy +at all, and every rollout answered "server not initialised". + +So a caller names its engine when it mints a session, that engine is probed THEN (so the caller learns +its tier at submit time rather than when the token fields come back empty), and the measurement is +cached per engine so a whole GRPO group naming one vLLM pays for it once. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +server = pytest.importorskip("openenv.core.harness.capture.server") +sessions = pytest.importorskip("openenv.core.harness.capture.sessions") + + +def app_with(monkeypatch, *, llm_url="", probe=None): + """A capture app whose probe is stubbed, so no engine is contacted.""" + calls: list[str] = [] + + def fake_probe(self, upstream): + calls.append(upstream.llm_url) + return ( + upstream.model or "stub-model", + (probe or {}).get(upstream.llm_url, "tokens"), + ) + + monkeypatch.setattr(server.UpstreamPool, "_probe", fake_probe, raising=True) + app = server.create_app(llm_url=llm_url, model="boot-model" if llm_url else None) + app.state.admin_key = None # these tests are about engines, not auth + return app, calls + + +def test_a_server_boots_with_no_engine_at_all(monkeypatch): + """The regression: this used to be unusable rather than merely engineless.""" + app, _ = app_with(monkeypatch) + with TestClient(app) as client: + assert client.get("/health").status_code == 200 + # Nothing to list, and an empty list is the honest answer rather than a 500. + assert client.get("/v1/models").json() == {"object": "list", "data": []} + + +def test_naming_an_engine_probes_it_and_returns_the_tier(monkeypatch): + app, calls = app_with(monkeypatch, probe={"http://train:8000": "tokens"}) + with TestClient(app) as client: + body = client.post("/sessions", json={"llm_url": "http://train:8000"}).json() + assert body["capture_level"] == "tokens" + assert body["rollout_type"] == "train", "token-capable engine must be trainable" + assert calls == ["http://train:8000"], "the engine should be probed exactly once" + + +def test_a_weaker_engine_comes_back_as_eval(monkeypatch): + """Same server, same session route, different engine — the tier follows the engine.""" + app, _ = app_with(monkeypatch, probe={"http://evalonly:8000": "text"}) + with TestClient(app) as client: + body = client.post("/sessions", json={"llm_url": "http://evalonly:8000"}).json() + assert body["capture_level"] == "text" + assert body["rollout_type"] == "eval" + + +def test_two_engines_coexist_on_one_server(monkeypatch): + """The whole point: a trainer and an eval run share a server and get different tiers.""" + app, _ = app_with( + monkeypatch, probe={"http://train:8000": "tokens", "http://eval:8000": "text"} + ) + with TestClient(app) as client: + train = client.post("/sessions", json={"llm_url": "http://train:8000"}).json() + evaluate = client.post("/sessions", json={"llm_url": "http://eval:8000"}).json() + assert (train["rollout_type"], evaluate["rollout_type"]) == ("train", "eval") + assert train["session_id"] != evaluate["session_id"] + + +def test_the_probe_is_cached_per_engine(monkeypatch): + """A GRPO group is N sessions on ONE engine; probing N times would add round trips per rollout.""" + app, calls = app_with(monkeypatch, probe={"http://train:8000": "tokens"}) + with TestClient(app) as client: + for _ in range(5): + client.post("/sessions", json={"llm_url": "http://train:8000"}) + assert calls == ["http://train:8000"], f"probed {len(calls)} times, expected 1" + + +def test_a_session_with_no_engine_and_no_default_is_told_so(monkeypatch): + """Better than forwarding to an empty base URL, which reads as a connection fault.""" + app, _ = app_with(monkeypatch) + with TestClient(app) as client: + sid = client.post("/sessions", json={}).json()["session_id"] + response = client.post( + "/v1/chat/completions", + json={"model": "m", "messages": [{"role": "user", "content": "hi"}]}, + headers={"Authorization": f"Bearer {sid}"}, + ) + assert response.status_code == 503 + assert "no inference engine" in response.json()["error"]["message"] + + +def test_booting_with_an_engine_still_works(monkeypatch): + """Backwards compatibility: the boot engine is the default for sessions that name none.""" + app, calls = app_with(monkeypatch, llm_url="http://default:8000") + with TestClient(app) as client: + body = client.post("/sessions", json={}).json() + assert body["llm_url"] == "http://default:8000" + assert body["capture_level"] == "tokens" # create_app's default level + assert calls == [], "naming no engine must not trigger a probe" + + +def test_health_lists_the_engines_it_has_measured(monkeypatch): + app, _ = app_with(monkeypatch, probe={"http://train:8000": "tokens"}) + with TestClient(app) as client: + client.post("/sessions", json={"llm_url": "http://train:8000"}) + upstreams = client.get("/health").json()["upstreams"] + assert upstreams == [ + { + "llm_url": "http://train:8000", + "model": "stub-model", + "capture_level": "tokens", + } + ] + + +def test_an_unprobeable_engine_is_the_weakest_tier_not_a_crash(): + """`text` is the floor because claiming `tokens` without evidence is how an eval rollout gets + stamped trainable — the one failure the capture level exists to prevent.""" + pool = server.UpstreamPool(default_client=None, default_level="tokens") + model, level = pool._probe( + sessions.Upstream(llm_url="http://nope.invalid:9/v1", model="m") + ) + assert level == "text" + assert model == "m" + + +def test_credentials_isolate_clients_without_exposing_secrets(): + """Credentials may select different tenants, quotas, and capabilities at the same URL.""" + a = sessions.Upstream(llm_url="http://x/v1", model="m", api_key="secret-a") + b = sessions.Upstream(llm_url="http://x/v1", model="m", api_key="secret-b") + assert a.cache_key != b.cache_key + assert "secret-a" not in str(a.cache_key) + assert "secret-a" not in repr(a) + + +def test_the_outgoing_model_comes_from_the_session_engine(monkeypatch): + """The engine 404s on a mangled model name, and an engineless server used to send one. + + Harnesses rewrite the model: opencode is configured with `intercepted/` and its provider + layer forwards only the last path segment, so `Qwen/Qwen3.5-2B` arrives as `Qwen3.5-2B` and the + engine answers `404 The model does not exist`. The proxy rewriting `model` is what makes the call + work. Reading the SERVER's model to do it meant an engineless server skipped the rewrite entirely + and every agent call 404'd — captured live before this test existed. + """ + app, _ = app_with(monkeypatch) + sent: dict = {} + + class FakeClient: + served_model = "Qwen/Qwen3.5-2B" + + async def completion(self, request): + sent.update(request) + raise server.UpstreamError("stop here; the request is what matters") + + with TestClient(app) as client: + body = client.post( + "/sessions", + json={"llm_url": "http://train:8000", "model": "Qwen/Qwen3.5-2B"}, + ).json() + # Swap in a client that records what it was asked to send. + key = sessions.Upstream( + llm_url="http://train:8000", model="Qwen/Qwen3.5-2B" + ).cache_key + app.state.upstreams._by_engine[key] = (FakeClient(), "tokens") + client.post( + "/v1/chat/completions", + json={ + "model": "Qwen3.5-2B", + "messages": [{"role": "user", "content": "hi"}], + }, + headers={"Authorization": f"Bearer {body['session_id']}"}, + ) + + assert sent.get("model") == "Qwen/Qwen3.5-2B", ( + f"the mangled name reached the engine: {sent.get('model')!r}" + ) diff --git a/tests/envs/test_harbor_proc_env_context.py b/tests/envs/test_harbor_proc_env_context.py new file mode 100644 index 0000000000..cad0ae17dc --- /dev/null +++ b/tests/envs/test_harbor_proc_env_context.py @@ -0,0 +1,113 @@ +"""Concurrent rollouts of a credential-by-env harness must see DIFFERENT keys from `os.environ`. + +claude-code, gemini-cli and goose read `os.environ` inside `run()` to build the env dict they pass to +the sandbox, and the API key IS the rollout's session id — so N concurrent rollouts need N different +values of one variable at one instant. That is what forced `_PROC_ENV_LOCK` and made those three +harnesses serialise. + +These tests assert the property that replaces the lock: an overlay is visible to the task that set it +and invisible to every other, including while they interleave. +""" + +from __future__ import annotations + +import asyncio +import os + +import pytest +from openenv.harbor import proc_env_context as ctx + + +@pytest.fixture(autouse=True) +def _restore(): + """Uninstall the proxy between tests: it replaces a process-global.""" + real = os.environ + ctx._installed = False + yield + os.environ = real + ctx._installed = False + + +def test_the_overlay_is_visible_to_reads(): + ctx.install() + with ctx.overlay({"OPENAI_API_KEY": "session-abc"}): + assert os.environ.get("OPENAI_API_KEY") == "session-abc" + assert os.environ["OPENAI_API_KEY"] == "session-abc" + assert "OPENAI_API_KEY" in os.environ + + +def test_it_does_not_leak_after_the_block(): + ctx.install() + with ctx.overlay({"OPENENV_TEST_ONLY": "x"}): + pass + assert os.environ.get("OPENENV_TEST_ONLY") is None + + +def test_concurrent_tasks_see_their_own_key(): + """The property the lock used to provide, now without serialising.""" + ctx.install() + observed: dict[str, str | None] = {} + + async def rollout(name: str, key: str): + with ctx.overlay({"OPENAI_API_KEY": key}): + # Yield control repeatedly so the tasks genuinely interleave inside their overlays; + # a process-global would be clobbered by whichever task ran last. + for _ in range(5): + await asyncio.sleep(0) + assert os.environ.get("OPENAI_API_KEY") == key + observed[name] = os.environ.get("OPENAI_API_KEY") + + async def main(): + await asyncio.gather(*(rollout(f"r{i}", f"session-{i}") for i in range(8))) + + asyncio.run(main()) + assert observed == {f"r{i}": f"session-{i}" for i in range(8)} + + +def test_copy_is_merged_because_subprocess_uses_it(): + """`subprocess` builds a child's env from `os.environ`; hiding the overlay would launch it + without credentials, and that failure would look like a bad key rather than a bad proxy.""" + ctx.install() + with ctx.overlay({"OPENENV_OVERLAY_ONLY": "yes"}): + assert os.environ.copy().get("OPENENV_OVERLAY_ONLY") == "yes" + assert "OPENENV_OVERLAY_ONLY" in dict(os.environ) + assert "OPENENV_OVERLAY_ONLY" in list(os.environ) + + +def test_the_real_environment_still_shows_through(): + ctx.install() + os.environ["OPENENV_REAL"] = "base" + try: + with ctx.overlay({"OTHER": "1"}): + assert os.environ.get("OPENENV_REAL") == "base" + finally: + del os.environ["OPENENV_REAL"] + + +def test_writes_reach_the_real_environment(): + """Only reads are context-local; a write that vanished would break unrelated libraries.""" + ctx.install() + with ctx.overlay({"A": "1"}): + os.environ["OPENENV_WRITTEN"] = "persisted" + assert os.environ.get("OPENENV_WRITTEN") == "persisted" + del os.environ["OPENENV_WRITTEN"] + + +def test_it_can_be_switched_off(): + """This swaps a global the whole process reads, so it must be disableable without a rollback.""" + os.environ["OPENENV_CONCURRENT_PROC_ENV"] = "0" + try: + assert ctx.enabled() is False + assert ctx.install() is False + finally: + del os.environ["OPENENV_CONCURRENT_PROC_ENV"] + + +def test_an_overlay_nests(): + """A rollout inside a rollout's context must not lose the outer values.""" + ctx.install() + with ctx.overlay({"OUTER": "1"}): + with ctx.overlay({"INNER": "2"}): + assert os.environ.get("OUTER") == "1" + assert os.environ.get("INNER") == "2" + assert os.environ.get("INNER") is None diff --git a/tests/envs/test_harbor_qualification.py b/tests/envs/test_harbor_qualification.py new file mode 100644 index 0000000000..8403de8a4b --- /dev/null +++ b/tests/envs/test_harbor_qualification.py @@ -0,0 +1,117 @@ +import pytest +from openenv.harbor.models import HarborRolloutResult +from openenv.harbor.qualification import evaluate_eval_capture, qualification_rows +from openenv.harbor.seams import SEAMS + + +def test_all_current_adapters_start_unqualified_for_every_provider(): + rows = qualification_rows(list(SEAMS)) + assert len(rows) == 29 + assert all(row[1:] == ["not_run"] * 4 for row in rows) + + +def test_eval_export_rejection_is_expected_and_zero_reward_is_valid(): + result = HarborRolloutResult( + rollout_type="eval", capture_level="text", n_turns=2, reward=0.0 + ) + assert all(evaluate_eval_capture(result).values()) + result.reward = None + assert not evaluate_eval_capture(result)["verifier_graded"] + + +def test_no_capture_cannot_be_certified_as_success(): + result = HarborRolloutResult(rollout_type="eval", capture_level="text", reward=0.0) + assert not evaluate_eval_capture(result)["model_calls_captured"] + + +def test_pass_requires_evidence_and_is_not_promoted_to_optimizer(): + cell = { + "harness": "opencode", + "provider": "vllm", + "status": "capture_and_reader_pass", + } + with pytest.raises(ValueError, match="evidence"): + qualification_rows(["opencode"], {"cells": [cell]}) + cell["evidence"] = ["two-task-run.json"] + assert ( + qualification_rows(["opencode"], {"cells": [cell]})[0][-1] + == "capture_and_reader_pass" + ) + with pytest.raises(ValueError, match="duplicate"): + qualification_rows(["opencode"], {"cells": [cell, cell]}) + + +def test_optimizer_status_requires_proof_for_current_captures(): + from openenv.harbor.qualification import qualification_details + + cell = { + "harness": "opencode", + "provider": "vllm", + "status": "optimizer_pass", + "evidence": ["capture.json"], + "optimizer_validated": True, + } + with pytest.raises(ValueError, match="matching, scoped"): + qualification_rows(["opencode"], {"cells": [cell]}) + proof = { + "matches_current_captures": True, + "result": "result.json", + "inputs": "inputs.json", + "scope": "diagnostic replay; no weight sync", + "model": "test-model", + "revision": "pinned", + "rows": 2, + } + cell["optimizer_evidence"] = proof + assert ( + qualification_rows(["opencode"], {"cells": [cell]})[0][-1] == "optimizer_pass" + ) + assert ( + "diagnostic replay; no weight sync" + in qualification_details({"cells": [cell]})[0][7] + ) + proof["matches_current_captures"] = False + with pytest.raises(ValueError, match="matching, scoped"): + qualification_rows(["opencode"], {"cells": [cell]}) + cell["status"] = "capture_and_reader_pass" + cell["optimizer_validated"] = False + assert qualification_details({"cells": [cell]})[0][7].startswith( + "previous captures only:" + ) + + +def test_unrecognized_provider_is_not_silently_hidden(): + with pytest.raises(ValueError, match="provider"): + qualification_rows([], {"cells": [{"harness": "opencode", "provider": "typo"}]}) + + +def test_maturity_requires_all_providers_and_current_optimizer(): + from openenv.harbor.qualification import harness_maturity_rows, PROVIDERS + + cells = [ + {"harness": "example", "provider": provider, "status": "failed"} + for provider in PROVIDERS + ] + report = {"cells": cells} + assert harness_maturity_rows(["example"], report)[0][1] == "unstable" + assert harness_maturity_rows(["unmeasured"], report)[0][1] == "experimental" + for cell in cells: + cell.update(status="eval_pass", evidence=["capture.json"]) + cells[-1]["status"] = "capture_and_reader_pass" + assert harness_maturity_rows(["example"], report)[0][1] == "experimental" + cells[-1].update( + status="optimizer_pass", + optimizer_validated=True, + optimizer_evidence={ + "matches_current_captures": True, + "result": "result.json", + "inputs": "inputs.json", + "scope": "diagnostic", + "model": "model", + "revision": "pinned", + "rows": 2, + }, + ) + assert harness_maturity_rows(["example"], report)[0][1] == "stable" + cells[1]["status"] = "failed" + assert harness_maturity_rows(["example"], report)[0][1] == "experimental" diff --git a/tests/envs/test_harbor_reconcile.py b/tests/envs/test_harbor_reconcile.py new file mode 100644 index 0000000000..03b9cec294 --- /dev/null +++ b/tests/envs/test_harbor_reconcile.py @@ -0,0 +1,301 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""`reconcile`: the only check on this path that is not self-referential. + +It compares the capture against ATIF, the trace the harness writes independently, and its verdict is +what gates trainability. Until now only its helpers were tested — `load_trace` and +`atif_turn_lengths` — so every decision that actually decides whether a rollout may be trained on was +uncovered: the turn-count FATAL, the coverage floor, the auxiliary-subsequence inference, and the +subagent refusal. +""" + +from __future__ import annotations + +import pytest + +atif = pytest.importorskip("openenv.harbor.atif") + +reconcile = atif.reconcile + + +graph_mod = pytest.importorskip("openenv.core.harness.capture.graph") +export_mod = pytest.importorskip("openenv.core.harness.capture.export") + + +def document(lengths, *, role="agent", rollout_type="train"): + """A real capture document with one sampled span per entry in `lengths`. + + Built by `export_session` over a real graph rather than hand-rolled, so the fixture cannot drift + from the document contract `reconcile` reads — three separate KeyErrors while writing these tests + were a hand-written dict missing fields the producer always sets. + """ + graph = graph_mod.RolloutGraph() + prompt = [1] + for i, n_sampled in enumerate(lengths): + sampled = list(range(1000 + i * 100, 1000 + i * 100 + n_sampled)) + graph.add_turn( + graph_mod.TurnNode( + node_id=f"n{i}", + prompt_ids=list(prompt), + sampled_ids=sampled, + sampled_logprobs=[-0.1] * n_sampled, + n_tools=1, + finish_reason="stop", + ) + ) + prompt = prompt + sampled + [9000 + i] + + class Session: + session_id = "s" + metadata: dict = {} + findings: list = [] + + session = Session() + session.graph = graph + doc = export_mod.export_session( + session, capture_level="tokens" if rollout_type == "train" else "text" + ) + if role != "agent": + # Same mutation rollout.py's auxiliary demotion performs. + for row in doc["sequences"]: + row["role"] = role + return doc + + +def trace(lengths, **extra): + return { + "schema_version": "1.0", + "agent": {"name": "opencode"}, + "steps": [ + {"source": "agent", "metrics": {"completion_tokens": n}} for n in lengths + ], + **extra, + } + + +def codes(report): + return {f.code for f in report.findings} + + +def fatal_codes(report): + return {f.code for f in report.fatal} + + +# --- the agreeing case ------------------------------------------------------ +def test_identical_turn_lengths_reconcile(): + report = reconcile(document([10, 20, 30]), trace([10, 20, 30])) + assert report.ok + assert not fatal_codes(report) + + +def test_explicit_synthetic_api_error_is_not_a_model_response(): + native = trace([10, 20, 0]) + native["steps"][-1].update( + model_name="", message="API Error: 400 unsupported image" + ) + native["steps"][-1]["metrics"].update(prompt_tokens=0, cached_tokens=0) + report = reconcile(document([10, 20]), native) + assert report.ok + assert "atif_synthetic_api_error" in codes(report) + assert len(native["steps"]) == 3 # Preserve the native evidence. + + +@pytest.mark.parametrize( + "change", + [ + {"model_name": "Qwen3.5-2B"}, + {"message": "An ordinary empty reply"}, + {"tool_calls": [{"name": "read"}]}, + {"metrics": {"prompt_tokens": 2, "completion_tokens": 0}}, + {"metrics": {"prompt_tokens": 0, "completion_tokens": 1}}, + ], +) +def test_unproven_or_sampled_zero_token_steps_still_fail(change): + native = trace([10, 20, 0]) + native["steps"][-1].update( + model_name="", + message="API Error: 400 unsupported image", + metrics={"prompt_tokens": 0, "completion_tokens": 0}, + ) + native["steps"][-1].update(change) + assert "turn_mismatch" in fatal_codes(reconcile(document([10, 20]), native)) + + +def test_no_atif_is_not_a_failure(): + """Three of sixteen harnesses emit no trajectory; that is an absent cross-check, not a fault.""" + report = reconcile(document([10]), None) + assert report.ok + assert "no_atif" in codes(report) + + +# --- disagreement ----------------------------------------------------------- +def test_atif_logging_more_calls_than_were_captured_is_fatal(): + """Calls the harness made that never reached the proxy mean the capture is incomplete.""" + report = reconcile(document([10, 20]), trace([10, 20, 30])) + assert not report.ok + + +@pytest.mark.parametrize("recorded_stops", [0, 1]) +def test_proxy_stop_requires_recorded_provenance_and_leaves_trace_unchanged( + recorded_stops, +): + doc = document([10, 20]) + doc["budget_stop_count"] = recorded_stops + native = trace([10, 20, 0]) + native["steps"][-1]["message"] = atif.BUDGET_STOP_MESSAGE + report = reconcile(doc, native) + assert report.ok == bool(recorded_stops) + assert len(native["steps"]) == 3 + + +@pytest.mark.parametrize( + "message,count", + [("unexpected missing generation", 0), (atif.BUDGET_STOP_MESSAGE, 7)], +) +def test_stop_allowance_cannot_hide_unknown_or_sampled_model_turns(message, count): + doc = document([10]) + doc["budget_stop_count"] = 1 + native = trace([10, count]) + native["steps"][-1]["message"] = message + assert not reconcile(doc, native).ok + + +def test_stop_allowance_is_bounded_by_responses_actually_emitted(): + doc = document([10]) + doc["budget_stop_count"] = 1 + native = trace([10, 0, 0]) + for step in native["steps"][1:]: + step["message"] = atif.BUDGET_STOP_MESSAGE + assert not reconcile(doc, native).ok + + +def test_extra_captured_calls_embed_as_auxiliary_when_coverage_is_high(): + """Seeing MORE than the harness logged is benign and explainable: a next-speaker check, a title + generator. The asymmetry is deliberate — missing calls mean we lost something, extra ones do not.""" + report = reconcile(document([58, 132, 266, 370, 33]), trace([58, 132, 266, 370])) + assert report.ok + assert "atif_aux_calls" in codes(report) + assert report.aux_node_ids, "the aux call must be identified so it can be demoted" + + +def test_low_coverage_refuses_rather_than_discarding_most_of_a_rollout(): + """The mimo case: 49 captured calls, ATIF logged 5, and a subsequence match would have demoted 44 + to auxiliary under a warning. Below the floor that inference is as likely coincidence as signal.""" + captured = list(range(1, 50)) + report = reconcile(document(captured), trace(captured[:5])) + assert not report.ok + assert "atif_coverage_too_low" in fatal_codes(report) + + +# --- converters that give nothing to compare against ------------------------ +def test_all_zero_token_counts_downgrade_to_no_cross_check(): + """vibe reports completion_tokens=0 on every step while capturing perfectly. Failing the rollout + would punish it for its trace converter rather than for anything wrong.""" + report = reconcile(document([10, 20]), trace([0, 0])) + assert report.ok + assert "atif_no_token_counts" in codes(report) + + +def test_no_agent_steps_at_all_downgrades_the_same_way(): + report = reconcile(document([10, 20]), trace([])) + assert report.ok + assert "atif_no_token_counts" in codes(report) + + +# --- structural edge cases -------------------------------------------------- +def test_nothing_captured_defers_to_the_rollouts_own_finding(): + """`check_rollout` already reports no_turns plainly; a second FATAL here buries the real cause.""" + empty: dict = { + "rollout_type": "train", + "sequences": [], + "turns": [], + "stats": {"n_turns": 0, "n_roots": 0, "n_discarded": 0}, + } + report = reconcile(empty, trace([10])) + assert "no_turns_upstream" in codes(report) + assert not fatal_codes(report) + + +def test_calls_captured_but_none_labelled_agent_is_fatal(): + report = reconcile(document([10, 20], role="auxiliary"), trace([10, 20])) + assert not report.ok + assert "no_agent_sequence" in fatal_codes(report) + + +def test_subagent_trajectories_are_refused_not_merely_noted(): + """The warning used to say subagent turns must not carry the parent's reward and then did nothing + to stop it: no node ids collected, no role changed. ATIF does not say which captured calls belong + to the subagent, so the rollout cannot be attributed and is refused.""" + report = reconcile( + document([10, 20]), + trace([10, 20], subagent_trajectories=[{"agent": {"name": "sub"}}]), + ) + assert not report.ok + assert "atif_subagents" in fatal_codes(report) + + +# --- the eval path ---------------------------------------------------------- +def test_eval_rollouts_compare_call_counts_since_token_counts_do_not_exist(): + report = reconcile(document([0, 0, 0], rollout_type="eval"), trace([10, 20, 30])) + assert report.ok + assert "eval_reconcile_counts_only" in codes(report) + + +def test_eval_rollouts_still_notice_a_truncated_harness_trace(): + """The one real bug reconciliation has ever caught was a truncated trajectory. Counts alone are + enough to see it, which is why the eval path bothers comparing at all.""" + report = reconcile(document([0] * 2, rollout_type="eval"), trace([1] * 6)) + assert "atif_calls_missing" in codes(report) + + +def _partial_usage_pair(): + doc = document([64, 199, 10]) + native = trace([None, None, 10]) + for index in range(2): + call_id = f"model-call-{index}" + doc["turns"][index]["response_message"] = {"tool_calls": [{"id": call_id}]} + native["steps"][index]["tool_calls"] = [{"tool_call_id": call_id}] + return doc, native + + +def test_partial_usage_requires_exact_ordered_call_identity(): + doc, native = _partial_usage_pair() + report = reconcile(doc, native) + assert report.ok + assert "atif_partial_usage" in codes(report) + assert "turns_match" not in codes(report) + assert not report.aux_node_ids + + +@pytest.mark.parametrize( + "failure", + [ + "different_id", + "missing_id", + "duplicate_id", + "known_count", + "explicit_zero", + "extra_step", + ], +) +def test_partial_usage_cannot_hide_disagreement(failure): + doc, native = _partial_usage_pair() + if failure == "different_id": + native["steps"][0]["tool_calls"][0]["tool_call_id"] = "different" + elif failure == "missing_id": + doc["turns"][0]["response_message"] = {} + elif failure == "duplicate_id": + for index in range(2): + doc["turns"][index]["response_message"]["tool_calls"][0]["id"] = "duplicate" + native["steps"][index]["tool_calls"][0]["tool_call_id"] = "duplicate" + elif failure == "known_count": + native["steps"][-1]["metrics"]["completion_tokens"] = 11 + elif failure == "explicit_zero": + native["steps"][0]["metrics"]["completion_tokens"] = 0 + else: + native["steps"].append({"source": "agent", "metrics": {"completion_tokens": 5}}) + assert not reconcile(doc, native).ok diff --git a/tests/envs/test_harbor_result_rendering.py b/tests/envs/test_harbor_result_rendering.py new file mode 100644 index 0000000000..28b1428e7a --- /dev/null +++ b/tests/envs/test_harbor_result_rendering.py @@ -0,0 +1,330 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Turning a capture document into a result, and a result into something readable. + +Two dialect families put tool calls in different places (`tool_calls` vs `tool_use` content blocks), +so a reader that knows only one shows an agent as a stream of text with no visible actions. And a +forked conversation appears once per path, which rendered as several near-identical transcripts all +claiming to be the main one. +""" + +from __future__ import annotations + +import json + +import pytest + +models = pytest.importorskip("openenv.harbor.models") +ui = pytest.importorskip("openenv.harbor.ui") + +conversations_from_document = models.conversations_from_document +turns_from_document = models.turns_from_document + + +def document(sequences, turns): + return {"sequences": sequences, "turns": turns} + + +# --- conversations ---------------------------------------------------------- +def test_a_forked_root_yields_one_conversation_not_one_per_path(): + """The regression: two paths through one root rendered as two 'main conversation' blocks.""" + doc = document( + sequences=[ + {"root_id": "r1", "role": "agent", "node_ids": ["a"], "n_turns": 1}, + {"root_id": "r1", "role": "agent", "node_ids": ["a", "b"], "n_turns": 2}, + ], + turns=[ + { + "node_id": "a", + "request_messages": [{"role": "user", "content": "hi"}], + "response_message": {"content": "one"}, + }, + { + "node_id": "b", + "request_messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "one"}, + ], + "response_message": {"content": "two"}, + }, + ], + ) + convos = conversations_from_document(doc) + assert len(convos) == 1 + assert convos[0].n_turns == 2, "the longest path is the complete one" + + +def test_separate_roots_stay_separate(): + doc = document( + sequences=[ + {"root_id": "r1", "role": "agent", "node_ids": ["a"], "n_turns": 1}, + {"root_id": "r2", "role": "auxiliary", "node_ids": ["b"], "n_turns": 1}, + ], + turns=[ + { + "node_id": "a", + "request_messages": [{"role": "user", "content": "task"}], + "response_message": {"content": "working"}, + }, + { + "node_id": "b", + "request_messages": [{"role": "user", "content": "who next?"}], + "response_message": {"content": "agent"}, + }, + ], + ) + convos = conversations_from_document(doc) + assert {c.role for c in convos} == {"agent", "auxiliary"} + + +def test_a_conversation_keeps_the_system_prompt_and_tool_results(): + doc = document( + sequences=[{"root_id": "r", "role": "agent", "node_ids": ["a"], "n_turns": 1}], + turns=[ + { + "node_id": "a", + "request_messages": [ + {"role": "system", "content": "You are an assistant."}, + {"role": "user", "content": "count rows"}, + {"role": "tool", "content": "42"}, + ], + "response_message": {"content": "42 rows"}, + } + ], + ) + roles = [m["role"] for m in conversations_from_document(doc)[0].messages] + assert roles == ["system", "user", "tool", "assistant"] + + +def test_sequences_without_nodes_or_messages_are_skipped(): + doc = document( + sequences=[{"root_id": "r", "role": "agent", "node_ids": [], "n_turns": 0}], + turns=[], + ) + assert conversations_from_document(doc) == [] + + +# --- turns ------------------------------------------------------------------ +def _agent_doc(response): + return document( + sequences=[ + { + "root_id": "r", + "role": "agent", + "node_ids": ["a"], + "n_turns": 1, + "input_ids": [1, 2, 3], + "loss_mask": [0, 1, 1], + "logprobs": [0.0, -0.1, -0.2], + "prompt_len": 1, + "turn_lengths": [2], + } + ], + turns=[ + { + "node_id": "a", + "finish_reason": "stop", + "n_tools": 3, + "response_message": response, + } + ], + ) + + +def test_turn_text_and_tool_calls_from_chat_completions(): + turns = turns_from_document( + _agent_doc( + { + "content": "Reading the file.", + "tool_calls": [ + {"function": {"name": "bash", "arguments": '{"cmd":"ls"}'}} + ], + } + ) + ) + assert turns[0].text == "Reading the file." + assert turns[0].tool_calls == [{"name": "bash", "arguments": '{"cmd":"ls"}'}] + + +def test_turn_text_and_tool_calls_from_anthropic_blocks(): + """claude-code puts tool use in content blocks; reading only `tool_calls` shows no actions.""" + turns = turns_from_document( + _agent_doc( + { + "content": [ + {"type": "text", "text": "Checking."}, + {"type": "tool_use", "name": "Bash", "input": {"command": "ls"}}, + ], + } + ) + ) + assert turns[0].text == "Checking." + assert turns[0].tool_calls[0]["name"] == "Bash" + + +def test_a_turn_with_no_response_is_still_a_turn(): + turns = turns_from_document(_agent_doc({})) + assert len(turns) == 1 and turns[0].text == "" and turns[0].tool_calls == [] + + +def test_only_agent_sequences_become_turns(): + """An auxiliary call must never be credited with the reward for solving the task.""" + doc = _agent_doc({"content": "x"}) + doc["sequences"][0]["role"] = "auxiliary" + assert turns_from_document(doc) == [] + + +# --- rendering -------------------------------------------------------------- +@pytest.mark.parametrize( + "result,marker", + [ + ({"ok": True, "reward": 1.0}, "Solved"), + ({"ok": True, "reward": 0.0}, "Not solved"), + ({"ok": True, "reward": None}, "Not graded"), + ({"ok": False, "reward": None, "exception_type": "Boom"}, "Failed"), + ], +) +def test_every_verdict_state_renders(result, marker): + assert marker in ui._result_html({**result, "turns": []}) + + +def test_ungraded_shows_a_dash_rather_than_a_zero(): + """A dead sandbox rendered as 0.00 reads as the model getting the answer wrong.""" + out = ui._result_html({"ok": True, "reward": None, "turns": []}) + assert "0.00" not in out + + +def test_findings_are_grouped_by_severity(): + out = ui._findings_html(["[FATAL] gone", "[WARN] odd", "[INFO] fyi"]) + assert "FATAL" in out and "WARN" in out and "INFO" in out + assert out.index("FATAL") < out.index("WARN"), "worst first" + + +def test_no_findings_renders_nothing(): + assert ui._findings_html([]) == "" + + +def test_conversation_labels_are_unambiguous_when_several_exist(): + convos = [ + {"role": "agent", "n_turns": 1, "messages": [{"role": "user", "content": "a"}]}, + {"role": "agent", "n_turns": 1, "messages": [{"role": "user", "content": "b"}]}, + ] + out = ui._conversation_html({"conversations": convos}) + assert out.count("main conversation") == 0 + assert "conversation 1 of 2" in out and "conversation 2 of 2" in out + + +def test_turns_html_does_not_show_the_tools_offered_count(): + """That number is a property of the harness, identical on every row, and told nobody anything.""" + out = ui._turns_html( + { + "turns": [ + { + "turn": 0, + "completion_token_ids": [1, 2], + "per_token_logps": [-0.1, -0.2], + "tool_calls": [{"name": "bash", "arguments": "ls"}], + "n_tools": 24, + "finish_reason": "tool_calls", + } + ] + } + ) + assert "24 tools" not in out + assert "bash" in out and "confidence" in out + + +def test_escaping_prevents_markup_injection_from_a_model_reply(): + out = ui._conversation_html( + { + "conversations": [ + { + "role": "agent", + "n_turns": 1, + "messages": [ + {"role": "assistant", "content": ""} + ], + } + ] + } + ) + assert "