diff --git a/docs/training.md b/docs/training.md index fd569e5b22..161cabcb0d 100644 --- a/docs/training.md +++ b/docs/training.md @@ -387,9 +387,9 @@ Or set it in TOML: name = "my-experiment" ``` -Every 10th step the orchestrator uploads the step's episodes (full conversations with rewards and advantages) to the run's sample viewer. +The monitor is a thin layer over the [`prime-runs`](https://github.com/PrimeIntellect-ai/prime/tree/main/packages/prime-runs) SDK (installed as `prime-runs[train]`): it registers the run, streams per-step metrics, uploads every 10th step's episodes (full conversations with rewards and advantages) to the run's sample viewer, and closes the run out. A process that exits without finishing is reported as crashed. Uploaded episodes are keyed to the platform run by the SDK; the orchestrator's own run id (the launcher's `PRL_RUN_ID`) stays on W&B and in the local records. -Requires `PRIME_API_KEY` (set via `prime login` or env var) and an allowlisted team. Currently internal-only. +Requires `PRIME_API_KEY` (`prime login` or the env var) and a team (`PRIME_TEAM_ID`, or the team selected with `prime login`) enabled for external runs. A configured monitor must work: a missing key or a team outside the allowlist fails the launch. `PRIME_RUNS_MODE=disabled` keeps the monitor configured but opens no platform run; `RUN_ID=` attaches to an external run a launcher already created instead of registering a new one. Currently internal-only. ## Rules of Thumb diff --git a/pyproject.toml b/pyproject.toml index 627e7bb5bf..7b7d6015b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "wandb>=0.26.1", "wandb-workspaces>=0.4.3", "prime>=0.6.19", + "prime-runs[train]>=0.1.1", "pyzmq>=27.1.0", "aiolimiter>=1.2.1", "tenacity>=8.2.0", @@ -222,6 +223,8 @@ flash_attn_3 = false # appear in `uv tree` need to be listed; the resolver ignores entries for # packages it never sees. prime = false +prime-runs = false +prime-traces = false prime-sandboxes = false prime-tunnel = false prime-evals = false diff --git a/src/prime_rl/monitors/prime.py b/src/prime_rl/monitors/prime.py index 8bc375d906..27dfb48147 100644 --- a/src/prime_rl/monitors/prime.py +++ b/src/prime_rl/monitors/prime.py @@ -1,331 +1,116 @@ from __future__ import annotations import asyncio -import atexit -import io -import json import os -import time -from datetime import datetime, timezone -from typing import Any, Coroutine +from typing import TYPE_CHECKING, Any -import httpx -import pyarrow as pa -import pyarrow.parquet as pq -import verifiers.v1 as vf -from prime_cli.core.config import Config as PrimeConfig -from verifiers.v1.utils.platform import build_samples +import prime_runs as pr from prime_rl.configs.monitors import PrimeMonitorConfig from prime_rl.monitors.base import Kind, Monitor, Subset from prime_rl.utils.config import BaseConfig -from prime_rl.utils.logger import get_logger from prime_rl.utils.utils import sanitize -BASE_URL = "https://api.primeintellect.ai/api/v1/rft" +if TYPE_CHECKING: + import verifiers.v1 as vf + BASE_URL_VAR = "PRIME_API_BASE" -API_KEY_VAR = "PRIME_API_KEY" +# How long finish() and the SDK's atexit crash hook let queued uploads drain. The SDK +# default (300 s) is sized for eval sample batches; a crashed training process should +# not linger that long, and a clean finish rarely has more than the last step queued. +FINISH_TIMEOUT = 60.0 + -SAMPLE_SCHEMA = pa.schema( - [ - ("run_id", pa.string()), - ("step", pa.int64()), - ("tag", pa.string()), - ("problem_id", pa.int64()), - ("sample_id", pa.int64()), - ("prompt", pa.string()), - ("completion", pa.string()), - ("trajectory", pa.string()), - ("answer", pa.string()), - ("env_name", pa.string()), - ("task", pa.string()), - ("info", pa.string()), - ("reward", pa.float64()), - ("advantage", pa.float64()), - ("metrics", pa.string()), - ("timing", pa.string()), - ("num_input_tokens", pa.int64()), - ("num_output_tokens", pa.int64()), - ("created_at", pa.timestamp("us", tz="UTC")), - ] -) +def _base_url() -> str | None: + """$PRIME_API_BASE historically points at the RFT API root (``.../api/v1/rft``); + the SDK takes the platform base URL. Unset means the SDK resolves it.""" + base = os.getenv(BASE_URL_VAR) + return base.rstrip("/").removesuffix("/rft") if base else None class PrimeMonitor(Monitor): - """Logs metrics and episodes to the Prime platform. + """Logs metrics and episodes to the Prime platform through ``prime_runs``. + + The run handle owns what ``TrainRun`` used to do by hand: the RFT + lifecycle (register or attach, finalize), the per-step metrics POSTs, the + every-10th-step Parquet sample uploads (presign -> PUT -> confirm), and + the terminal status — a process that exits without finalizing is reported + crashed by the SDK's atexit hook, replacing the old ``_mark_failed`` one. - Uploads are fire-and-forget tasks on the caller's event loop — the prime - monitor only runs in the orchestrator, whose call sites are all async. The - platform calls and the episode serialization run in worker threads via - ``asyncio.to_thread``, so they never stall the loop. + ``init``/``finish`` do network I/O and run in worker threads; the log + calls are queue puts onto the SDK's uploader thread, which owns retries + and backpressure, so they never stall the loop. """ config: PrimeMonitorConfig + run: pr.Run async def init(self, config: BaseConfig | None = None) -> None: - api_key = os.getenv(API_KEY_VAR) or PrimeConfig().api_key - if not api_key: - raise RuntimeError(f"API key not found - set {API_KEY_VAR} or run `prime login`") - self.run = TrainRun(api_key) + init_kwargs: dict[str, Any] if run_id := os.getenv("RUN_ID"): # A managed launch pre-created the platform run and injected its id - # attach instead of registering a duplicate. The backend owns the run's - # failure marking then; finalize still marks it completed on clean exit. - self.run.id = run_id - self.logger.info(f"Logging metrics and episodes to platform run {run_id} (attached via $RUN_ID)") - return - run_fields: dict[str, Any] = {} - if config is not None: - run_fields = dict( - base_model=config.model.name, - max_steps=config.max_steps or 0, - batch_size=config.batch_size, - rollouts_per_example=config.group_size, - seq_len=config.seq_len, + # failure marking then; a clean finish() still marks it completed. + init_kwargs = {"id": run_id} + elif config is not None: + init_kwargs = dict( + name=self.config.name, + model=config.model.name, environments=[env.env_id for env in config.train.source], - run_config=config.model_dump(exclude_none=True, mode="json"), - wandb_project=config.monitors.wandb.project if config.monitors.wandb else None, + training=pr.TrainingSpec( + max_steps=config.max_steps or 0, + batch_size=config.batch_size, + rollouts_per_example=config.group_size, + seq_len=config.seq_len, + wandb_project=config.monitors.wandb.project if config.monitors.wandb else None, + ), + config=config.model_dump(exclude_none=True, mode="json"), ) - await self.run.create(name=self.config.name, **run_fields) + else: + # The RFT API requires a base model; "unknown" is what the + # pre-SDK register sent for a config-less init. + init_kwargs = {"name": self.config.name, "model": "unknown"} + + # A configured monitor must work (see monitors.setup), so the default is + # mode="online": a missing key or a team outside the external-runs allowlist + # raises here instead of training silently untracked. $PRIME_RUNS_MODE=disabled + # stays the explicit opt-out. + self.run = await asyncio.to_thread( + pr.init, + kind="train", + mode=os.getenv(pr.MODE_ENV) or "online", + base_url=_base_url(), + finish_timeout=FINISH_TIMEOUT, + **init_kwargs, + ) + if self.run.url: + attached = " (attached via $RUN_ID)" if self.run.attached else "" + self.logger.info(f"Logging metrics and episodes to platform run {self.run.id} ({self.run.url}){attached}") + else: + self.logger.info(f"Platform run disabled ({pr.MODE_ENV}=disabled)") async def log_metrics(self, metrics: dict[str, Any], step: int | None) -> None: + # The SDK also drops non-finite values, but silently; sanitize first so + # the dropped paths are named in the log. metrics, dropped = sanitize(metrics) if dropped: self.logger.warning(f"Dropping {len(dropped)} non-finite metric value(s): {', '.join(dropped[:5])}") - # every monitor stamps its own wall time; without it, step=None rows - # (e.g. inference metrics) reach the platform with no time anchor - metrics["_timestamp"] = time.time() - self.run.submit("metrics upload", self.run.log_metrics(metrics)) + # A queue put that can block briefly under backpressure - off the loop. The SDK + # stamps `_timestamp` on every row, so step=None rows keep a time anchor. + await asyncio.to_thread(self.run.log_metrics, metrics, step=step) async def log_episodes(self, episodes: list[vf.Episode], step: int, kind: Kind, subset: Subset) -> None: - """Upload one platform sample per episode via the presigned-URL Parquet flow. - Only the trained cohort ships to the platform.""" - # Upload every 10th step, unsampled - the pre-refactor cadence - to not - # overwhelm the platform's ingestion. TODO: Lift once we integrate the - # prime traces SDK. - if kind != "train" or subset != "effective" or not episodes or step % 10 != 0: + """Only the trained cohort ships to the platform. The upload cadence + (every 10th step) and the Parquet encoding live in the SDK's training + samples sink, which reads each episode's dispatch step off ``run.work`` + - the ``TrainRunInfo`` the dispatcher stamps at emit time.""" + if kind != "train" or subset != "effective" or not episodes: return - - async def upload() -> None: - # Serialization dumps every episode's full model - heavy pure-Python work - # that would stall the event loop (and with it dispatch) if run inline. - parquet_bytes = await asyncio.to_thread(episodes_to_parquet_bytes, episodes, self.run.id, step) - if parquet_bytes is not None: - await self.run.upload_samples(parquet_bytes, step) - - self.run.submit(f"episodes upload at step {step}", upload()) + # A queue put that can block briefly under backpressure - off the loop. + await asyncio.to_thread(self.run.log_episodes, episodes) async def finalize(self) -> None: - await self.run.finalize() - - -class TrainRun: - """A training run on the Prime platform's RFT API. - - Owns the HTTP client and the run lifecycle (create, log, finalize) — the - natural seam to be subsumed by the train SDK, and it mirrors ``wandb.Run``'s - exit behavior: a created run that is never finalized is marked failed at - process exit via an atexit hook that ``finalize`` disarms. Fully async, - except the atexit hook itself — at interpreter shutdown there is no event - loop, so it sends one synchronous request. - """ - - def __init__(self, api_key: str): - self.id: str | None = None - self.logger = get_logger() - self._tasks: set[asyncio.Task] = set() - self.base_url = (os.getenv(BASE_URL_VAR) or BASE_URL).rstrip("/") - self.headers = { - "Authorization": f"Bearer {api_key}", - "x-api-key": api_key, - "Content-Type": "application/json", - } - self.client = httpx.AsyncClient( - base_url=self.base_url, - headers=self.headers, - timeout=30, - transport=httpx.AsyncHTTPTransport(retries=3), - ) - - def submit(self, what: str, request: Coroutine[Any, Any, None]) -> None: - """Run a request as a fire-and-forget task; a failure only warns. The task set - keeps strong references - the loop alone won't.""" - - async def guarded() -> None: - try: - await request - except Exception as e: - self.logger.warning(f"Failed {what}: {type(e).__name__}: {e}") - - task = asyncio.get_running_loop().create_task(guarded()) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - - async def create( - self, - name: str | None = None, - team_id: str | None = None, - base_model: str = "unknown", - max_steps: int = 0, - batch_size: int | None = None, - rollouts_per_example: int | None = None, - seq_len: int | None = None, - environments: list[str] | None = None, - run_config: dict[str, Any] | None = None, - wandb_project: str | None = None, - ) -> str: - """Register the run with the platform and return its id.""" - prime_config = PrimeConfig() - team_id = team_id or prime_config.team_id - - payload: dict[str, Any] = {"base_model": base_model, "max_steps": max_steps} - if batch_size is not None: - payload["batch_size"] = batch_size - if rollouts_per_example is not None: - payload["rollouts_per_example"] = rollouts_per_example - if seq_len is not None: - payload["seq_len"] = seq_len - if environments is not None: - payload["environments"] = [{"id": env_id} for env_id in environments] - if run_config is not None: - payload["run_config"] = run_config - if wandb_project is not None: - payload["wandb_project"] = wandb_project - if name: - payload["name"] = name - if team_id: - payload["team_id"] = team_id - - response = await self.client.post("/external-runs", json=payload) - if response.status_code != 201: - raise RuntimeError(f"Failed to create platform run (HTTP {response.status_code}): {response.text}") - - self.id = response.json()["run"]["id"] - self._owner_pid = os.getpid() - atexit.register(self._mark_failed) - if prime_config.frontend_url: - self.logger.info( - f"Logging metrics and episodes to platform run {self.id} ({prime_config.frontend_url.rstrip('/')}/dashboard/training/{self.id})" - ) - else: - self.logger.info(f"Logging metrics and episodes to platform run {self.id}") - return self.id - - async def log_metrics(self, metrics: dict[str, Any]) -> None: - (await self.client.post("/metrics", json={"run_id": self.id, "metrics": metrics})).raise_for_status() - - async def upload_samples(self, parquet_bytes: bytes, step: int) -> None: - """Presigned-URL flow: presign -> R2 PUT -> confirm.""" - presign = await self.client.post("/samples/presign", json={"run_id": self.id, "step": step}) - presign.raise_for_status() - data = presign.json()["data"] - # Bare client - the presigned URL rejects the run client's auth headers. - async with httpx.AsyncClient(timeout=30) as client: - put = await client.put( - data["presignedUrl"], content=parquet_bytes, headers={"Content-Type": "application/parquet"} - ) - put.raise_for_status() - confirm = await self.client.post( - "/samples/confirm", json={"run_id": self.id, "step": step, "s3_key": data["s3Key"]} - ) - confirm.raise_for_status() - - async def finalize(self) -> None: - """Finalize the run as completed.""" - self.logger.info(f"Finalizing platform run {self.id}") - # Drain in-flight uploads so the final step's metrics and episodes land - # before the run is marked completed. - await asyncio.gather(*self._tasks, return_exceptions=True) - try: - (await self.client.post("/finalize", json={"run_id": self.id, "summary": {}})).raise_for_status() - except httpx.HTTPError as e: - self.logger.warning(f"Failed to finalize platform run {self.id}: {e}") - await self.set_status(success=True) - atexit.unregister(self._mark_failed) - - def _mark_failed(self) -> None: - # Forked children inherit the atexit table; only the creating process may - # flip the run's status. At interpreter shutdown there is no event loop and - # no executor for async DNS, so this path must stay synchronous. - if os.getpid() != self._owner_pid: - return - self.logger.info(f"Marking platform run {self.id} as failed") - try: - httpx.put( - f"{self.base_url}/external-runs/{self.id}/status", - headers=self.headers, - json={"status": "failed"}, - timeout=30, - ).raise_for_status() - except httpx.HTTPError as e: - self.logger.warning(f"Failed to mark platform run {self.id} as failed: {e}") - - async def set_status(self, success: bool) -> None: - """Mark the run as completed or failed.""" - status = "completed" if success else "failed" - self.logger.info(f"Marking platform run {self.id} as {status}") - try: - put = await self.client.put(f"/external-runs/{self.id}/status", json={"status": status}) - put.raise_for_status() - except httpx.HTTPError as e: - self.logger.warning(f"Failed to mark platform run {self.id} as {status}: {e}") - - -def episodes_to_parquet_bytes(episodes: list[vf.Episode], run_id: str | None, step: int) -> bytes | None: - """One row per episode. Sample construction is shared with verifiers' eval - ``--push`` (``build_samples``: complete native episode in ``info.native_wrapper``, - flat summary from one trainable trace), so a training episode and an eval sample - land on the platform identically; the RFT-only columns (run/step/advantage/ - problem_id/env_name) are layered on here.""" - advantages: dict[str, float | None] = {} - env_names: dict[str, str] = {} - for episode in episodes: - summary_trace = next((trace for trace in episode.traces if trace.agent.trainable), episode.traces[0]) - advantages[episode.id] = summary_trace.info.get("advantage") - env_names[episode.id] = episode.env.id - - now = datetime.now(timezone.utc) - rows = [] - for sample_id, sample in enumerate(build_samples(episodes)): - trajectory = sample["trajectory"] - if not trajectory: # no branches (e.g. an episode that errored before any message) - continue - advantage = advantages.get(sample["episode_id"]) - trajectory = [{**branch, "advantage": advantage} for branch in trajectory] - - try: - problem_id = int(sample["example_id"]) if sample["example_id"] is not None else sample_id - except (TypeError, ValueError): - problem_id = sample_id - - rows.append( - { - "run_id": run_id, - "step": step, - "tag": "", - "problem_id": problem_id, - "sample_id": sample_id, - "prompt": "", - "completion": json.dumps(sample["completion"]), - "trajectory": json.dumps(trajectory), - "answer": "", - "env_name": env_names.get(sample["episode_id"], ""), - "task": json.dumps(sample["task"]), - "info": json.dumps(sample["info"]), - "reward": sample["reward"], - "advantage": advantage, - "metrics": json.dumps(sample["metrics"]), - "timing": json.dumps(sample["timing"]), - "num_input_tokens": trajectory[-1]["num_input_tokens"], - "num_output_tokens": trajectory[-1]["num_output_tokens"], - "created_at": now, - } - ) - - if not rows: - return None - - table = pa.Table.from_pylist(rows, schema=SAMPLE_SCHEMA) - buf = io.BytesIO() - pq.write_table(table, buf, compression="snappy", use_dictionary=True, write_statistics=True) - return buf.getvalue() + # Drains queued uploads so the final step's metrics and episodes land, + # then finalizes (idempotent on the platform side); an attached run's + # failure marking stays with the launcher. + await asyncio.to_thread(self.run.finish) diff --git a/tests/unit/monitors/__init__.py b/tests/unit/monitors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/monitors/test_prime.py b/tests/unit/monitors/test_prime.py new file mode 100644 index 0000000000..120fdf1a34 --- /dev/null +++ b/tests/unit/monitors/test_prime.py @@ -0,0 +1,102 @@ +import asyncio +from types import SimpleNamespace + +import prime_runs as pr +import pytest + +from prime_rl.configs.monitors import PrimeMonitorConfig +from prime_rl.monitors.prime import FINISH_TIMEOUT, PrimeMonitor, _base_url + + +@pytest.fixture +def init_calls(monkeypatch): + """Record what the monitor asks the SDK for; hand back a disabled run.""" + real_init, calls, runs = pr.init, [], [] + + def fake_init(**kwargs): + calls.append(kwargs) + runs.append(real_init(kind="train", mode="disabled", id=kwargs.get("id"), model="m")) + return runs[-1] + + monkeypatch.setattr(pr, "init", fake_init) + monkeypatch.delenv("RUN_ID", raising=False) + monkeypatch.delenv(pr.MODE_ENV, raising=False) + yield calls + for run in runs: # else the SDK's atexit hook reports them crashed + run.finish() + + +class FakeConfig(SimpleNamespace): + def model_dump(self, **kwargs): + return {"max_steps": self.max_steps} + + +def orchestrator_config(wandb=None): + return FakeConfig( + model=SimpleNamespace(name="Qwen/Qwen3-8B"), + train=SimpleNamespace(source=[SimpleNamespace(env_id="primeintellect/gsm8k")]), + max_steps=100, + batch_size=64, + group_size=8, + seq_len=4096, + monitors=SimpleNamespace(wandb=wandb), + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("https://api.primeintellect.ai/api/v1/rft", "https://api.primeintellect.ai/api/v1"), + ("https://api.primeintellect.ai/api/v1/rft/", "https://api.primeintellect.ai/api/v1"), + ("https://api.primeintellect.ai", "https://api.primeintellect.ai"), + ], +) +def test_base_url_strips_the_rft_root(monkeypatch, value, expected): + if value is None: + monkeypatch.delenv("PRIME_API_BASE", raising=False) + else: + monkeypatch.setenv("PRIME_API_BASE", value) + assert _base_url() == expected + + +def test_init_registers_the_run_from_the_orchestrator_config(init_calls): + monitor = PrimeMonitor(PrimeMonitorConfig(name="exp")) + + asyncio.run(monitor.init(orchestrator_config(wandb=SimpleNamespace(project="proj")))) + + assert init_calls == [ + dict( + kind="train", + mode="online", + base_url=None, + finish_timeout=FINISH_TIMEOUT, + name="exp", + model="Qwen/Qwen3-8B", + environments=["primeintellect/gsm8k"], + training=pr.TrainingSpec( + max_steps=100, batch_size=64, rollouts_per_example=8, seq_len=4096, wandb_project="proj" + ), + config={"max_steps": 100}, + ) + ] + assert monitor.run.kind == "train" + + +def test_init_attaches_to_the_launcher_s_run(init_calls, monkeypatch): + monkeypatch.setenv("RUN_ID", "run-managed") + monitor = PrimeMonitor(PrimeMonitorConfig()) + + asyncio.run(monitor.init(orchestrator_config())) + + (call,) = init_calls + assert call["id"] == "run-managed" and "model" not in call and "training" not in call + assert monitor.run.id == "run-managed" + + +def test_the_disabled_switch_reaches_the_sdk(init_calls, monkeypatch): + monkeypatch.setenv(pr.MODE_ENV, "disabled") + + asyncio.run(PrimeMonitor(PrimeMonitorConfig()).init(orchestrator_config())) + + assert init_calls[0]["mode"] == "disabled" diff --git a/uv.lock b/uv.lock index 81976715b5..59029d40ce 100644 --- a/uv.lock +++ b/uv.lock @@ -20,12 +20,14 @@ exclude-newer-span = "P7D" vllm = false verifiers = false harbor = "2026-08-11T00:00:00Z" +prime-traces = false prime-pydantic-config = false vllm-router = false dion = false -fastokens = false +prime-runs = false flash-attn-3 = false prime-tunnel = false +fastokens = false deep-gemm = false prime-evals = false torchao = false @@ -4963,6 +4965,7 @@ dependencies = [ { name = "prime" }, { name = "prime-pydantic-config" }, { name = "prime-rl-configs" }, + { name = "prime-runs", extra = ["train"] }, { name = "psutil" }, { name = "pyarrow" }, { name = "pybase64" }, @@ -5126,6 +5129,7 @@ requires-dist = [ { name = "prime-rl", extras = ["gpu"], marker = "extra == 'all'" }, { name = "prime-rl", extras = ["quack"], marker = "extra == 'all'" }, { name = "prime-rl-configs", editable = "packages/prime-rl-configs" }, + { name = "prime-runs", extras = ["train"], specifier = ">=0.1.1" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pyarrow", specifier = ">=21.0.0" }, { name = "pybase64", specifier = ">=1.4.2" }, @@ -5196,6 +5200,24 @@ requires-dist = [ { name = "verifiers", specifier = ">=0.3.0" }, ] +[[package]] +name = "prime-runs" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "prime-traces" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/db/3d5a9362f0d373d10145eaee3ac255cf227bf4164b36d04119b18e226607/prime_runs-0.1.1.tar.gz", hash = "sha256:0be97dcc95b3f1648ca25475efa45a5892fb12a775bf1f61728baec73a0088bb", size = 61110, upload-time = "2026-09-04T17:01:33.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/52/3fa41ac5e3c8bfa8eb33520f86634ae56474228f85c59b16aa04875efd7d/prime_runs-0.1.1-py3-none-any.whl", hash = "sha256:d512753ff84159a1234d1ea93091857f0a59fba12ef7ab2f69bfb579deb5ce75", size = 43006, upload-time = "2026-09-04T17:01:31.821Z" }, +] + +[package.optional-dependencies] +train = [ + { name = "pyarrow" }, +] + [[package]] name = "prime-sandboxes" version = "0.2.39" @@ -5215,6 +5237,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/a5/8a2a1aefecfc58ca926d6b21e8302a127884bf2a45952ed390b5f702eeda/prime_sandboxes-0.2.39-py3-none-any.whl", hash = "sha256:3ea355158473c1697f9ef6ec2a07f3189e9a2555c80d1c88e1f7e22979772e0a", size = 58161, upload-time = "2026-08-19T23:09:26.69Z" }, ] +[[package]] +name = "prime-traces" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/77/13fa99c0fb71909f2f35d048d9724efb9ae432484e47f12386b1542815e8/prime_traces-0.0.3.tar.gz", hash = "sha256:4071833db77a606ccbb718e5c73f2dfaf63982fe801aa096daed0bc605da554f", size = 50874, upload-time = "2026-08-27T19:46:44.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/9a/5a917de29a3a839c0ba36d445e3af562f744d9716b509571be643a01476c/prime_traces-0.0.3-py3-none-any.whl", hash = "sha256:0838f60651f1ed38371e383bd88b954e461d20ac424874e694bc83358934f5f3", size = 38345, upload-time = "2026-08-27T19:46:43.396Z" }, +] + [[package]] name = "prime-tunnel" version = "0.1.10"