Deploy HF RL Explorer
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- CUSTOM_ENVS.md +13 -2
- app/admin.py +53 -16
- app/admin_app.py +3 -2
- app/auto_index.py +131 -0
- app/catalog.py +14 -11
- app/community.py +98 -0
- app/config.py +2 -0
- app/envs/capabilities.py +127 -0
- app/envs/mimo.py +1 -1
- app/envs/nemo_gym_build.py +8 -0
- app/envs/nemo_gym_catalog.json +2 -2
- app/envs/nemogym.py +20 -5
- app/envs/registry.py +5 -1
- app/envs/rows.py +10 -0
- app/envs/tools.py +5 -0
- app/envs/verifiers_rows.py +8 -5
- app/http.py +23 -9
- app/indexer.py +7 -3
- app/main.py +47 -89
- app/mcp_bridge.py +26 -10
- app/mimo/__init__.py +1 -1
- app/mimo/runner/core.py +35 -6
- app/mimo/runner/opencode.py +7 -2
- app/nemo_runner.py +158 -0
- app/runner.py +36 -1
- app/runtime.py +1 -1
- app/search_api.py +180 -69
- app/seo.py +4 -2
- app/snapshot.py +14 -1
- app/space_checks.py +358 -0
- app/space_tasks.py +94 -0
- app/spaces_live.py +246 -46
- app/vendor/nemo_gym/LICENSE +201 -0
- app/vendor/nemo_gym/NOTICE +5 -0
- app/vendor/nemo_gym/verification_utils.py +398 -0
- app/version.py +2 -2
- pyproject.toml +1 -0
- scripts/deploy_spaces.py +4 -4
- tests/reward-stats.test.mjs +20 -0
- tests/schemaform.test.mjs +89 -0
- tests/space-media.test.mjs +24 -0
- tests/test_admin_dashboard.py +94 -0
- tests/test_audit_regressions.py +163 -0
- tests/test_auto_index.py +86 -0
- tests/test_capabilities.py +68 -0
- tests/test_community.py +96 -0
- tests/test_deploy_spaces.py +12 -0
- tests/test_discovery.py +69 -0
- tests/test_frameworks.py +13 -1
- tests/test_live.py +5 -5
CUSTOM_ENVS.md
CHANGED
|
@@ -107,6 +107,15 @@ can't load the renderer show.
|
|
| 107 |
|
| 108 |
## Running
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
A run option is one way to run a task, by a **runner**:
|
| 111 |
|
| 112 |
- `harbor`: OpenEnv's Harbor runner on an HF Sandbox (`app/runner.py`). Implement `materialize` (the task as a Harbor
|
|
@@ -189,8 +198,10 @@ A processor can say more than a row:
|
|
| 189 |
as dotted names, through lists. Put a format's set in `processors.HIDDEN` too: search never looks at them.
|
| 190 |
|
| 191 |
**The framework readers.** NeMo Gym, Verifiers and verl / SkyRL rows are read by modules of their own, which show a
|
| 192 |
-
row as its framework sees it and say how to run it there
|
| 193 |
-
|
|
|
|
|
|
|
| 194 |
|
| 195 |
| module | knows | how it's kept current |
|
| 196 |
|---|---|---|
|
|
|
|
| 107 |
|
| 108 |
## Running
|
| 109 |
|
| 110 |
+
Every summary and task also receives a versioned `support` descriptor from
|
| 111 |
+
`app/envs/capabilities.py`. It separates the source's framework, lifecycle, reward
|
| 112 |
+
semantics, transport and available capabilities. The UI and dataset MCP tools consume
|
| 113 |
+
the same descriptor. States are `available`, `external`, `unavailable`, and `unknown`;
|
| 114 |
+
only an implemented hosted runner can advertise execution here. Register a new
|
| 115 |
+
framework's documentation/lifecycle there and add its runner to `HOSTED_RUNNERS` only
|
| 116 |
+
after implementing dispatch and validation. See [EXPLORER_AUDIT.md](EXPLORER_AUDIT.md)
|
| 117 |
+
for the framework matrix and live protocol contract.
|
| 118 |
+
|
| 119 |
A run option is one way to run a task, by a **runner**:
|
| 120 |
|
| 121 |
- `harbor`: OpenEnv's Harbor runner on an HF Sandbox (`app/runner.py`). Implement `materialize` (the task as a Harbor
|
|
|
|
| 198 |
as dotted names, through lists. Put a format's set in `processors.HIDDEN` too: search never looks at them.
|
| 199 |
|
| 200 |
**The framework readers.** NeMo Gym, Verifiers and verl / SkyRL rows are read by modules of their own, which show a
|
| 201 |
+
row as its framework sees it and say how to run it there. Verifiers, verl / SkyRL and most NeMo
|
| 202 |
+
configurations require their native servers, sandboxes or trainers. The two supported NeMo Pivot tool-simulation
|
| 203 |
+
configurations can also run here through Responses inference and NVIDIA's pinned argument verifier; they predict
|
| 204 |
+
function calls without executing the functions. `nemogym.run_option` validates this narrow hosted subset:
|
| 205 |
|
| 206 |
| module | knows | how it's kept current |
|
| 207 |
|---|---|---|
|
app/admin.py
CHANGED
|
@@ -19,7 +19,7 @@ from typing import Any, Literal
|
|
| 19 |
from fastapi import APIRouter, HTTPException, Request
|
| 20 |
from pydantic import BaseModel, Field
|
| 21 |
|
| 22 |
-
from . import auth, catalog, config, runner, settings, store
|
| 23 |
|
| 24 |
STALE = 600 # a rollout whose record hasn't changed for this long is no longer live, whatever it says
|
| 25 |
|
|
@@ -40,22 +40,29 @@ def _admin(request: Request) -> dict[str, Any]:
|
|
| 40 |
@router.get("/overview")
|
| 41 |
def overview(request: Request):
|
| 42 |
_admin(request)
|
| 43 |
-
envs =
|
| 44 |
runs = _runs()
|
| 45 |
now = time.time()
|
| 46 |
day = 86400
|
| 47 |
by_day = Counter(time.strftime("%Y-%m-%d", time.gmtime(r.get("created_at", 0))) for r in runs if now - r.get("created_at", 0) < 14 * day)
|
| 48 |
finished = [r for r in runs if r.get("status") in ("done", "failed", "cancelled", "interrupted")]
|
| 49 |
new = sorted((e for e in envs if e.get("created") and now - _ts(e["created"]) < 14 * day), key=lambda e: e["created"], reverse=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
idx_files = list(config.INDEX_DIR.glob("*.json.gz"))
|
| 51 |
pack_files = list(config.PACK_DIR.glob("*.json.gz")) if config.PACK_DIR.is_dir() else []
|
| 52 |
return {
|
| 53 |
"environments": {"datasets": sum(1 for e in envs if e["kind"] == "dataset"), "spaces": sum(1 for e in envs if e["kind"] == "space"),
|
| 54 |
-
"openenv": sum(
|
| 55 |
-
"
|
|
|
|
| 56 |
"rollouts": {"total": len(runs), "live": sum(1 for r in runs if _live(r)), "today": sum(1 for r in runs if now - r.get("created_at", 0) < day),
|
| 57 |
"week": sum(1 for r in runs if now - r.get("created_at", 0) < 7 * day),
|
| 58 |
-
"public": sum(
|
|
|
|
|
|
|
| 59 |
"failed": sum(1 for r in finished if r.get("status") != "done"), "finished": len(finished),
|
| 60 |
"users": len({r.get("user") for r in runs}),
|
| 61 |
"by_day": [[time.strftime("%Y-%m-%d", time.gmtime(now - i * day)), by_day.get(time.strftime("%Y-%m-%d", time.gmtime(now - i * day)), 0)]
|
|
@@ -65,6 +72,8 @@ def overview(request: Request):
|
|
| 65 |
"storage": {"indexes": len(idx_files), "index_bytes": sum(f.stat().st_size for f in idx_files),
|
| 66 |
"packs": len(pack_files), "pack_bytes": sum(f.stat().st_size for f in pack_files)},
|
| 67 |
"recent": [_admin_view(r) for r in runs[:8]],
|
|
|
|
|
|
|
| 68 |
"new": new[:12],
|
| 69 |
"settings": _settings(),
|
| 70 |
}
|
|
@@ -117,6 +126,8 @@ def _admin_view(r: dict[str, Any]) -> dict[str, Any]:
|
|
| 117 |
v = dict(r)
|
| 118 |
if v.get("endpoint"):
|
| 119 |
v["endpoint"] = {"host": v["endpoint"].get("host"), "model": v["endpoint"].get("model")}
|
|
|
|
|
|
|
| 120 |
v["live"] = _live(r)
|
| 121 |
v["avatar"] = r.get("avatar") or avatar(r.get("user"))
|
| 122 |
v["moderated"] = r["id"] in set(settings.get("hidden_runs", []))
|
|
@@ -125,14 +136,22 @@ def _admin_view(r: dict[str, Any]) -> dict[str, Any]:
|
|
| 125 |
|
| 126 |
# ── rollouts ─────────────────────────────────────────────────────────────────
|
| 127 |
@router.get("/rollouts")
|
| 128 |
-
def rollouts(request: Request, status: str = "", q: str = "", user: str = "",
|
|
|
|
| 129 |
_admin(request)
|
| 130 |
ql = q.strip().lower()
|
| 131 |
-
|
|
|
|
|
|
|
| 132 |
if (not status or (status == "live" and _live(r)) or r.get("status") == status)
|
| 133 |
and (not user or r.get("user") == user)
|
| 134 |
-
and (not
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
@router.post("/rollouts/{run_id}/cancel")
|
|
@@ -173,14 +192,14 @@ def people(request: Request):
|
|
| 173 |
by: dict[str, dict[str, Any]] = {}
|
| 174 |
for r in _runs():
|
| 175 |
name = r.get("user") or "unknown"
|
| 176 |
-
p = by.setdefault(name, {"name": name, "avatar": None, "runs": 0, "done": 0, "
|
| 177 |
"first": r.get("created_at"), "last": r.get("created_at"), "models": Counter(), "datasets": Counter()})
|
| 178 |
p["avatar"] = p["avatar"] or r.get("avatar")
|
| 179 |
p["runs"] += 1
|
| 180 |
p["done"] += r.get("status") == "done"
|
| 181 |
-
p["
|
| 182 |
p["live"] += _live(r)
|
| 183 |
-
p["public"] += r.get("
|
| 184 |
p["first"] = min(p["first"] or 0, r.get("created_at") or 0)
|
| 185 |
p["last"] = max(p["last"] or 0, r.get("created_at") or 0)
|
| 186 |
p["models"][r.get("model") or "custom endpoint"] += 1
|
|
@@ -197,10 +216,10 @@ def people(request: Request):
|
|
| 197 |
@router.get("/environments")
|
| 198 |
def environments(request: Request):
|
| 199 |
_admin(request)
|
| 200 |
-
envs =
|
| 201 |
gone = catalog.hidden()
|
| 202 |
runs = Counter(r.get("dataset") for r in _runs())
|
| 203 |
-
return {"environments": [{**e, "hidden": e["key"] in gone, "rollouts": runs.get(e["id"], 0) if e["kind"] == "dataset" else 0} for e in envs],
|
| 204 |
"collections": catalog.collections(), "jobs": _jobs()}
|
| 205 |
|
| 206 |
|
|
@@ -238,7 +257,7 @@ def env_action(body: EnvAction, request: Request):
|
|
| 238 |
settings.save(u["name"], {"collections": cols}, note=f"{body.action} {key}" + (f" in {body.collection}" if body.collection else ""))
|
| 239 |
elif body.action == "index":
|
| 240 |
if key.startswith("space:"):
|
| 241 |
-
raise HTTPException(400, "
|
| 242 |
st = catalog.index_status(key) # anonymous: public datasets only
|
| 243 |
settings.save(u["name"], {}, note=f"indexed {key}")
|
| 244 |
return {"ok": True, "job": {k: v for k, v in st.items() if k != "index"}}
|
|
@@ -339,4 +358,22 @@ def indexes(request: Request):
|
|
| 339 |
out.append({"spec": f.name[:-8].replace("__", "/", 1), "bytes": f.stat().st_size, "pack_bytes": pack.stat().st_size if pack.exists() else None,
|
| 340 |
"tasks": head.get("tasks"), "version": head.get("version"), "current": head.get("version") == catalog.INDEX_VERSION,
|
| 341 |
"built": head.get("built")})
|
| 342 |
-
return {"indexes": out, "version": catalog.INDEX_VERSION, "jobs": _jobs()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from fastapi import APIRouter, HTTPException, Request
|
| 20 |
from pydantic import BaseModel, Field
|
| 21 |
|
| 22 |
+
from . import auth, auto_index, catalog, community, config, runner, search_api, settings, snapshot, space_checks, space_tasks, store
|
| 23 |
|
| 24 |
STALE = 600 # a rollout whose record hasn't changed for this long is no longer live, whatever it says
|
| 25 |
|
|
|
|
| 40 |
@router.get("/overview")
|
| 41 |
def overview(request: Request):
|
| 42 |
_admin(request)
|
| 43 |
+
envs = search_api.environments(include_hidden=True)
|
| 44 |
runs = _runs()
|
| 45 |
now = time.time()
|
| 46 |
day = 86400
|
| 47 |
by_day = Counter(time.strftime("%Y-%m-%d", time.gmtime(r.get("created_at", 0))) for r in runs if now - r.get("created_at", 0) < 14 * day)
|
| 48 |
finished = [r for r in runs if r.get("status") in ("done", "failed", "cancelled", "interrupted")]
|
| 49 |
new = sorted((e for e in envs if e.get("created") and now - _ts(e["created"]) < 14 * day), key=lambda e: e["created"], reverse=True)
|
| 50 |
+
hidden_runs = set(settings.get("hidden_runs", []))
|
| 51 |
+
ctx = search_api.context()
|
| 52 |
+
with snapshot.use() as (_, conn):
|
| 53 |
+
public_counts = search_api.sql_extra(conn, ctx, 0, search_api.Query(scope="ready"))["stats"]
|
| 54 |
idx_files = list(config.INDEX_DIR.glob("*.json.gz"))
|
| 55 |
pack_files = list(config.PACK_DIR.glob("*.json.gz")) if config.PACK_DIR.is_dir() else []
|
| 56 |
return {
|
| 57 |
"environments": {"datasets": sum(1 for e in envs if e["kind"] == "dataset"), "spaces": sum(1 for e in envs if e["kind"] == "space"),
|
| 58 |
+
"openenv": sum(space_checks.verified(e.get("api_check")) for e in envs), "indexed": sum(1 for e in envs if e.get("indexed")),
|
| 59 |
+
"checked_spaces": sum(space_checks.browseable(e.get("api_check")) for e in envs),
|
| 60 |
+
"public": public_counts, "hidden": len(catalog.hidden()), "pinned": len(catalog.pinned()), "collections": len(catalog.collections())},
|
| 61 |
"rollouts": {"total": len(runs), "live": sum(1 for r in runs if _live(r)), "today": sum(1 for r in runs if now - r.get("created_at", 0) < day),
|
| 62 |
"week": sum(1 for r in runs if now - r.get("created_at", 0) < 7 * day),
|
| 63 |
+
"public": sum(community.is_public(r, hidden_runs) for r in runs),
|
| 64 |
+
"private": sum(r.get("visibility") != "public" for r in runs),
|
| 65 |
+
"by_runner": Counter(community.runner(r) for r in runs).most_common(),
|
| 66 |
"failed": sum(1 for r in finished if r.get("status") != "done"), "finished": len(finished),
|
| 67 |
"users": len({r.get("user") for r in runs}),
|
| 68 |
"by_day": [[time.strftime("%Y-%m-%d", time.gmtime(now - i * day)), by_day.get(time.strftime("%Y-%m-%d", time.gmtime(now - i * day)), 0)]
|
|
|
|
| 72 |
"storage": {"indexes": len(idx_files), "index_bytes": sum(f.stat().st_size for f in idx_files),
|
| 73 |
"packs": len(pack_files), "pack_bytes": sum(f.stat().st_size for f in pack_files)},
|
| 74 |
"recent": [_admin_view(r) for r in runs[:8]],
|
| 75 |
+
"live": [_admin_view(r) for r in runs if _live(r)][:24],
|
| 76 |
+
"operations": _operations(envs),
|
| 77 |
"new": new[:12],
|
| 78 |
"settings": _settings(),
|
| 79 |
}
|
|
|
|
| 126 |
v = dict(r)
|
| 127 |
if v.get("endpoint"):
|
| 128 |
v["endpoint"] = {"host": v["endpoint"].get("host"), "model": v["endpoint"].get("model")}
|
| 129 |
+
v["runner"] = community.runner(r)
|
| 130 |
+
v["community"] = community.is_public(r, set(settings.get("hidden_runs", [])))
|
| 131 |
v["live"] = _live(r)
|
| 132 |
v["avatar"] = r.get("avatar") or avatar(r.get("user"))
|
| 133 |
v["moderated"] = r["id"] in set(settings.get("hidden_runs", []))
|
|
|
|
| 136 |
|
| 137 |
# ── rollouts ─────────────────────────────────────────────────────────────────
|
| 138 |
@router.get("/rollouts")
|
| 139 |
+
def rollouts(request: Request, status: str = "", q: str = "", user: str = "", runner: str = "",
|
| 140 |
+
visibility: str = "", limit: int = 100, offset: int = 0):
|
| 141 |
_admin(request)
|
| 142 |
ql = q.strip().lower()
|
| 143 |
+
hidden = set(settings.get("hidden_runs", []))
|
| 144 |
+
all_runs = _runs()
|
| 145 |
+
runs = [r for r in all_runs
|
| 146 |
if (not status or (status == "live" and _live(r)) or r.get("status") == status)
|
| 147 |
and (not user or r.get("user") == user)
|
| 148 |
+
and (not runner or community.runner(r) == runner)
|
| 149 |
+
and (not visibility or (visibility == "community" and community.is_public(r, hidden))
|
| 150 |
+
or (visibility == "moderated" and r["id"] in hidden) or r.get("visibility") == visibility)
|
| 151 |
+
and (not ql or ql in f"{r.get('title', '')} {r.get('dataset', '')} {r.get('model', '')} {r.get('user', '')} {r['id']} {community.runner(r)}".lower())]
|
| 152 |
+
offset = max(0, offset)
|
| 153 |
+
return {"total": len(runs), "runners": sorted({community.runner(r) for r in all_runs}),
|
| 154 |
+
"runs": [_admin_view(r) for r in runs[offset:offset + max(1, min(limit, 500))]]}
|
| 155 |
|
| 156 |
|
| 157 |
@router.post("/rollouts/{run_id}/cancel")
|
|
|
|
| 192 |
by: dict[str, dict[str, Any]] = {}
|
| 193 |
for r in _runs():
|
| 194 |
name = r.get("user") or "unknown"
|
| 195 |
+
p = by.setdefault(name, {"name": name, "avatar": None, "runs": 0, "done": 0, "scored": 0, "live": 0, "public": 0,
|
| 196 |
"first": r.get("created_at"), "last": r.get("created_at"), "models": Counter(), "datasets": Counter()})
|
| 197 |
p["avatar"] = p["avatar"] or r.get("avatar")
|
| 198 |
p["runs"] += 1
|
| 199 |
p["done"] += r.get("status") == "done"
|
| 200 |
+
p["scored"] += r.get("status") == "done" and community.scalar_reward(r)
|
| 201 |
p["live"] += _live(r)
|
| 202 |
+
p["public"] += community.is_public(r, set(settings.get("hidden_runs", [])))
|
| 203 |
p["first"] = min(p["first"] or 0, r.get("created_at") or 0)
|
| 204 |
p["last"] = max(p["last"] or 0, r.get("created_at") or 0)
|
| 205 |
p["models"][r.get("model") or "custom endpoint"] += 1
|
|
|
|
| 216 |
@router.get("/environments")
|
| 217 |
def environments(request: Request):
|
| 218 |
_admin(request)
|
| 219 |
+
envs = search_api.environments(include_hidden=True)
|
| 220 |
gone = catalog.hidden()
|
| 221 |
runs = Counter(r.get("dataset") for r in _runs())
|
| 222 |
+
return {"environments": [{**e, "hidden": e["key"] in gone, "ready": e["kind"] == "dataset" or space_checks.browseable(e.get("api_check")), "rollouts": runs.get(e["id"], 0) if e["kind"] == "dataset" else 0} for e in envs],
|
| 223 |
"collections": catalog.collections(), "jobs": _jobs()}
|
| 224 |
|
| 225 |
|
|
|
|
| 257 |
settings.save(u["name"], {"collections": cols}, note=f"{body.action} {key}" + (f" in {body.collection}" if body.collection else ""))
|
| 258 |
elif body.action == "index":
|
| 259 |
if key.startswith("space:"):
|
| 260 |
+
raise HTTPException(400, "Space task counts are discovered automatically from their Task API; dataset indexes are separate")
|
| 261 |
st = catalog.index_status(key) # anonymous: public datasets only
|
| 262 |
settings.save(u["name"], {}, note=f"indexed {key}")
|
| 263 |
return {"ok": True, "job": {k: v for k, v in st.items() if k != "index"}}
|
|
|
|
| 358 |
out.append({"spec": f.name[:-8].replace("__", "/", 1), "bytes": f.stat().st_size, "pack_bytes": pack.stat().st_size if pack.exists() else None,
|
| 359 |
"tasks": head.get("tasks"), "version": head.get("version"), "current": head.get("version") == catalog.INDEX_VERSION,
|
| 360 |
"built": head.get("built")})
|
| 361 |
+
return {"indexes": out, "version": catalog.INDEX_VERSION, "jobs": _jobs(), "operations": _operations()}
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _operations(envs=None):
|
| 365 |
+
"""Evidence from the shared store, not the admin process's worker settings."""
|
| 366 |
+
envs = envs if envs is not None else search_api.environments(include_hidden=True)
|
| 367 |
+
spaces = [e for e in envs if e["kind"] == "space"]
|
| 368 |
+
records = space_checks.inventory()
|
| 369 |
+
states = Counter(space_checks.status(records.get(e["id"])) for e in spaces)
|
| 370 |
+
versions = Counter(e.get("declared_version") or "Unknown" for e in spaces if space_checks.verified(records.get(e["id"])))
|
| 371 |
+
job = auto_index.status()
|
| 372 |
+
now = time.time()
|
| 373 |
+
stale = bool(job and now > (job.get("next_at") or job.get("started_at") or 0) + auto_index.INTERVAL)
|
| 374 |
+
return {"snapshot": snapshot.status(), "indexer": {**job, "stale": stale},
|
| 375 |
+
"checks": {"candidates": len(spaces), "ready": sum(space_checks.browseable(records.get(e["id"])) for e in spaces),
|
| 376 |
+
"openenv": sum(space_checks.verified(records.get(e["id"])) for e in spaces),
|
| 377 |
+
"statuses": dict(states), "versions": versions.most_common(), "fresh_seconds": space_checks.FRESH,
|
| 378 |
+
"latest_at": max((records.get(e["id"], {}).get("checked_at", 0) for e in spaces), default=0)},
|
| 379 |
+
"task_catalog": space_tasks.census(records, [e["id"] for e in spaces if e["key"] not in catalog.hidden() and space_checks.browseable(records.get(e["id"]))])}
|
app/admin_app.py
CHANGED
|
@@ -34,12 +34,13 @@ ADMIN_WEB = config.ROOT / "web-admin"
|
|
| 34 |
def _startup() -> None:
|
| 35 |
import threading
|
| 36 |
|
| 37 |
-
from . import
|
| 38 |
|
| 39 |
config.STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
| 40 |
config.INDEX_DIR.mkdir(parents=True, exist_ok=True)
|
| 41 |
# the Hub listing the dashboard opens with: fetched now, so the first visit doesn't wait on it
|
| 42 |
-
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
@app.get("/healthz", include_in_schema=False)
|
|
|
|
| 34 |
def _startup() -> None:
|
| 35 |
import threading
|
| 36 |
|
| 37 |
+
from . import search_api, snapshot
|
| 38 |
|
| 39 |
config.STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
| 40 |
config.INDEX_DIR.mkdir(parents=True, exist_ok=True)
|
| 41 |
# the Hub listing the dashboard opens with: fetched now, so the first visit doesn't wait on it
|
| 42 |
+
snapshot.start()
|
| 43 |
+
threading.Thread(target=lambda: search_api.environments(include_hidden=True), daemon=True, name="warm").start()
|
| 44 |
|
| 45 |
|
| 46 |
@app.get("/healthz", include_in_schema=False)
|
app/auto_index.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounded automatic public catalog maintenance, independent of page visits.
|
| 2 |
+
|
| 3 |
+
The existing indexer runs in a child process so stalled downloads cannot leave
|
| 4 |
+
builder threads behind. A process lease prevents duplicate app workers from
|
| 5 |
+
publishing concurrently. No HF Jobs, inference, or episode execution is used.
|
| 6 |
+
Deployments with an external indexer can set RLX_AUTO_INDEX=0.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import gzip
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
import subprocess
|
| 15 |
+
import sys
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
|
| 19 |
+
from . import catalog, config
|
| 20 |
+
|
| 21 |
+
INTERVAL = 600
|
| 22 |
+
LISTING_REFRESH = 3600
|
| 23 |
+
MAX_SECONDS = 600
|
| 24 |
+
_stop = threading.Event()
|
| 25 |
+
_worker = None
|
| 26 |
+
log = logging.getLogger("rlx")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def status():
|
| 30 |
+
try:
|
| 31 |
+
record = json.loads((config.STORAGE_DIR / "auto-index.json").read_bytes())
|
| 32 |
+
return record if isinstance(record, dict) else {}
|
| 33 |
+
except (OSError, ValueError):
|
| 34 |
+
return {}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _save(record):
|
| 38 |
+
catalog.atomic_write(config.STORAGE_DIR / "auto-index.json", json.dumps(record).encode())
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def cached_listing(now):
|
| 42 |
+
"""Reuse only a recent complete public discovery; never extend its age."""
|
| 43 |
+
try:
|
| 44 |
+
with gzip.open(config.STORAGE_DIR / "listing.json.gz", "rt") as f:
|
| 45 |
+
doc = json.load(f)
|
| 46 |
+
if not isinstance(doc, dict):
|
| 47 |
+
return None, None
|
| 48 |
+
at = doc.get("at", 0)
|
| 49 |
+
if doc.get("full") and isinstance(doc.get("rows"), list) and doc["rows"] and 0 <= now - at < LISTING_REFRESH:
|
| 50 |
+
return doc["rows"], at
|
| 51 |
+
except (OSError, ValueError, TypeError):
|
| 52 |
+
pass
|
| 53 |
+
return None, None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def cycle():
|
| 57 |
+
"""One lease-protected cycle; publication remains atomic in indexer.run."""
|
| 58 |
+
import fcntl
|
| 59 |
+
from . import indexer, snapshot
|
| 60 |
+
|
| 61 |
+
config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
with (config.CACHE_DIR / "auto-index.lock").open("a") as lease:
|
| 63 |
+
try:
|
| 64 |
+
fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 65 |
+
except BlockingIOError:
|
| 66 |
+
return
|
| 67 |
+
now = time.time()
|
| 68 |
+
previous = status()
|
| 69 |
+
if previous.get("next_at", 0) > now:
|
| 70 |
+
return
|
| 71 |
+
record = {"state": "indexing", "started_at": now, "last_success": previous.get("last_success"),
|
| 72 |
+
"failures": previous.get("failures", 0), "next_at": now + MAX_SECONDS + INTERVAL}
|
| 73 |
+
_save(record)
|
| 74 |
+
try:
|
| 75 |
+
rows, at = cached_listing(now)
|
| 76 |
+
report = indexer.run(snapshot.open_store(), rows=rows, listing_at=at, budget_s=90, max_builds=4)
|
| 77 |
+
_save({"state": "idle", "last_success": time.time(), "next_at": time.time() + INTERVAL,
|
| 78 |
+
"failures": 0, "counts": report["counts"]})
|
| 79 |
+
except Exception as exc:
|
| 80 |
+
failures = record["failures"] + 1
|
| 81 |
+
_save({**record, "state": "retrying", "failures": failures, "error": type(exc).__name__,
|
| 82 |
+
"next_at": time.time() + min(3600, INTERVAL * 2 ** min(failures - 1, 3))})
|
| 83 |
+
log.warning("Automatic catalog update failed: %s", type(exc).__name__)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _child():
|
| 87 |
+
# A public index must not accidentally depend on a developer's HF login.
|
| 88 |
+
env = {**os.environ, "HF_HUB_DISABLE_IMPLICIT_TOKEN": "1"}
|
| 89 |
+
child = subprocess.Popen([sys.executable, "-m", "app.auto_index"], env=env,
|
| 90 |
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
| 91 |
+
deadline = time.monotonic() + MAX_SECONDS
|
| 92 |
+
try:
|
| 93 |
+
while child.poll() is None:
|
| 94 |
+
if _stop.wait(1) or time.monotonic() >= deadline:
|
| 95 |
+
break
|
| 96 |
+
finally:
|
| 97 |
+
if child.poll() is None:
|
| 98 |
+
child.terminate()
|
| 99 |
+
try:
|
| 100 |
+
child.wait(timeout=3)
|
| 101 |
+
except subprocess.TimeoutExpired:
|
| 102 |
+
child.kill()
|
| 103 |
+
child.wait()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def start():
|
| 107 |
+
global _worker
|
| 108 |
+
if os.environ.get("RLX_TEST_TMP") or os.environ.get("RLX_AUTO_INDEX", "1") != "1" or (_worker and _worker.is_alive()):
|
| 109 |
+
return
|
| 110 |
+
_stop.clear()
|
| 111 |
+
def watch():
|
| 112 |
+
while not _stop.is_set():
|
| 113 |
+
try:
|
| 114 |
+
_child()
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
log.warning("Automatic catalog worker failed: %s", type(exc).__name__)
|
| 117 |
+
_stop.wait(60)
|
| 118 |
+
_worker = threading.Thread(target=watch, daemon=True, name="auto-index")
|
| 119 |
+
_worker.start()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def stop():
|
| 123 |
+
_stop.set()
|
| 124 |
+
if _worker:
|
| 125 |
+
_worker.join(timeout=5)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
from . import runtime
|
| 130 |
+
runtime.hub_timeouts()
|
| 131 |
+
cycle()
|
app/catalog.py
CHANGED
|
@@ -92,7 +92,7 @@ def hidden() -> set[str]:
|
|
| 92 |
return set(settings.get("hidden", []))
|
| 93 |
|
| 94 |
|
| 95 |
-
#
|
| 96 |
DEFAULT_PINNED = ["space:FineEnvs/geoguesser-env", "space:FineEnvs/latex-ocr-env"]
|
| 97 |
|
| 98 |
|
|
@@ -263,7 +263,9 @@ def _listing_fetch(full: bool) -> list[dict[str, Any]]:
|
|
| 263 |
for d in _api().list_datasets(filter=tag, sort="trending_score", limit=None if full else 1000, expand=_EXPAND):
|
| 264 |
if d.id not in rows and not d.private and not d.gated:
|
| 265 |
rows[d.id] = _summary(d)
|
| 266 |
-
except Exception: # noqa: BLE001 -
|
|
|
|
|
|
|
| 267 |
continue
|
| 268 |
missing = [s for s in featured_datasets() if s not in rows]
|
| 269 |
with ThreadPoolExecutor(max_workers=12) as pool: # one Hub call each: in parallel
|
|
@@ -361,9 +363,6 @@ def environments(include_hidden: bool = False) -> list[dict[str, Any]]:
|
|
| 361 |
indexed = headline(r["id"]) if r["kind"] == "dataset" else None
|
| 362 |
fw = "harbor" if indexed and indexed.get("tasks") else r.get("framework") # its index found task folders
|
| 363 |
coll = collection_of(r["key"])
|
| 364 |
-
if r["kind"] == "space" and coll == "fineenvs" and fw != "openenv": # curated as OpenEnv servers, manifest or not
|
| 365 |
-
r = {**r, "openenv": True, "badges": ["OpenEnv"]}
|
| 366 |
-
fw = "openenv"
|
| 367 |
out.append({**r, "framework": fw, "collection": coll, "pinned": r["key"] in pins, "indexed": indexed})
|
| 368 |
return out
|
| 369 |
|
|
@@ -374,12 +373,12 @@ _SPACE_EXPAND = ["likes", "trendingScore", "sdk", "runtime", "tags", "lastModifi
|
|
| 374 |
|
| 375 |
def _space_summary(s: Any) -> dict[str, Any]:
|
| 376 |
card = (s.card_data.to_dict() if s.card_data else {}) or {}
|
| 377 |
-
raw = s.tags or []
|
| 378 |
stage = getattr(s.runtime, "stage", None) if s.runtime is not None else None
|
| 379 |
version = next((t[8:] for t in raw if re.match(r"^openenv-\d", t)), None)
|
| 380 |
files = [f.rfilename for f in (getattr(s, "siblings", None) or [])]
|
| 381 |
manifest = min((f for f in files if _MANIFEST.match(f) and not _VENDORED.match(f)), key=lambda f: f.count("/"), default=None)
|
| 382 |
-
openenv = "openenv"
|
| 383 |
ors = bool({"ors", "openreward"} & set(raw))
|
| 384 |
return {"id": s.id, "key": f"space:{s.id}", "kind": "space", "openenv": openenv, "manifest": manifest,
|
| 385 |
"framework": "openenv" if openenv else "ors" if ors else "space", "files": len(files) or None,
|
|
@@ -395,10 +394,10 @@ def _space_summary(s: Any) -> dict[str, Any]:
|
|
| 395 |
"private": bool(s.private), "gated": False}
|
| 396 |
|
| 397 |
|
| 398 |
-
SPACE_TAGS = ("openenv", "rl-environment", "ors", "openreward")
|
| 399 |
# a manifest a Space's own environment declares (not one vendored from OpenEnv's source, or its templates)
|
| 400 |
_MANIFEST = re.compile(r"^(?:[^/]+/)?openenv\.ya?ml$")
|
| 401 |
-
_VENDORED = re.compile(r"^(src/
|
| 402 |
|
| 403 |
|
| 404 |
def spaces(full: bool = True) -> list[dict[str, Any]]:
|
|
@@ -415,12 +414,16 @@ def spaces(full: bool = True) -> list[dict[str, Any]]:
|
|
| 415 |
if sp.private or sp.sdk != "docker" or "research-article-template" in tags:
|
| 416 |
continue
|
| 417 |
rows.setdefault(sp.id, _space_summary(sp))
|
| 418 |
-
except Exception: # noqa: BLE001 -
|
|
|
|
|
|
|
| 419 |
continue
|
| 420 |
for sid in featured:
|
| 421 |
if sid not in rows:
|
| 422 |
try:
|
| 423 |
-
|
|
|
|
|
|
|
| 424 |
except Exception: # noqa: BLE001
|
| 425 |
continue
|
| 426 |
return list(rows.values())
|
|
|
|
| 92 |
return set(settings.get("hidden", []))
|
| 93 |
|
| 94 |
|
| 95 |
+
# Editorial picks, shown separately from the metric-based trending order.
|
| 96 |
DEFAULT_PINNED = ["space:FineEnvs/geoguesser-env", "space:FineEnvs/latex-ocr-env"]
|
| 97 |
|
| 98 |
|
|
|
|
| 263 |
for d in _api().list_datasets(filter=tag, sort="trending_score", limit=None if full else 1000, expand=_EXPAND):
|
| 264 |
if d.id not in rows and not d.private and not d.gated:
|
| 265 |
rows[d.id] = _summary(d)
|
| 266 |
+
except Exception: # noqa: BLE001 - only the quick bootstrap tolerates incomplete discovery
|
| 267 |
+
if full:
|
| 268 |
+
raise
|
| 269 |
continue
|
| 270 |
missing = [s for s in featured_datasets() if s not in rows]
|
| 271 |
with ThreadPoolExecutor(max_workers=12) as pool: # one Hub call each: in parallel
|
|
|
|
| 363 |
indexed = headline(r["id"]) if r["kind"] == "dataset" else None
|
| 364 |
fw = "harbor" if indexed and indexed.get("tasks") else r.get("framework") # its index found task folders
|
| 365 |
coll = collection_of(r["key"])
|
|
|
|
|
|
|
|
|
|
| 366 |
out.append({**r, "framework": fw, "collection": coll, "pinned": r["key"] in pins, "indexed": indexed})
|
| 367 |
return out
|
| 368 |
|
|
|
|
| 373 |
|
| 374 |
def _space_summary(s: Any) -> dict[str, Any]:
|
| 375 |
card = (s.card_data.to_dict() if s.card_data else {}) or {}
|
| 376 |
+
raw = [str(t).lower() for t in (s.tags or [])]
|
| 377 |
stage = getattr(s.runtime, "stage", None) if s.runtime is not None else None
|
| 378 |
version = next((t[8:] for t in raw if re.match(r"^openenv-\d", t)), None)
|
| 379 |
files = [f.rfilename for f in (getattr(s, "siblings", None) or [])]
|
| 380 |
manifest = min((f for f in files if _MANIFEST.match(f) and not _VENDORED.match(f)), key=lambda f: f.count("/"), default=None)
|
| 381 |
+
openenv = bool({"openenv", "library:openenv"} & set(raw)) or bool(version) or manifest is not None
|
| 382 |
ors = bool({"ors", "openreward"} & set(raw))
|
| 383 |
return {"id": s.id, "key": f"space:{s.id}", "kind": "space", "openenv": openenv, "manifest": manifest,
|
| 384 |
"framework": "openenv" if openenv else "ors" if ors else "space", "files": len(files) or None,
|
|
|
|
| 394 |
"private": bool(s.private), "gated": False}
|
| 395 |
|
| 396 |
|
| 397 |
+
SPACE_TAGS = ("openenv", "library:openenv", "rl-environment", "ors", "openreward")
|
| 398 |
# a manifest a Space's own environment declares (not one vendored from OpenEnv's source, or its templates)
|
| 399 |
_MANIFEST = re.compile(r"^(?:[^/]+/)?openenv\.ya?ml$")
|
| 400 |
+
_VENDORED = re.compile(r"^(src/|build/|dist/|templates?/|examples?/|tests?/|vendor/|\.venv/|.*\.egg-info/|envs/)")
|
| 401 |
|
| 402 |
|
| 403 |
def spaces(full: bool = True) -> list[dict[str, Any]]:
|
|
|
|
| 414 |
if sp.private or sp.sdk != "docker" or "research-article-template" in tags:
|
| 415 |
continue
|
| 416 |
rows.setdefault(sp.id, _space_summary(sp))
|
| 417 |
+
except Exception: # noqa: BLE001 - a full census must not silently publish partial counts
|
| 418 |
+
if full:
|
| 419 |
+
raise
|
| 420 |
continue
|
| 421 |
for sid in featured:
|
| 422 |
if sid not in rows:
|
| 423 |
try:
|
| 424 |
+
sp = _api().space_info(sid, expand=expand)
|
| 425 |
+
if not sp.private and sp.sdk == "docker" and "research-article-template" not in (sp.tags or []):
|
| 426 |
+
rows[sid] = _space_summary(sp)
|
| 427 |
except Exception: # noqa: BLE001
|
| 428 |
continue
|
| 429 |
return list(rows.values())
|
app/community.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runner-independent filtering and honest summaries of saved public rollouts.
|
| 2 |
+
|
| 3 |
+
Visibility is checked by the API before records enter this module. Facets ignore
|
| 4 |
+
their own selection; headline counts always describe the filtered population.
|
| 5 |
+
Rewards are grouped by environment, runner, domain, metric, judge and revision.
|
| 6 |
+
There is no cross-environment model ranking.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
from collections import Counter, defaultdict
|
| 12 |
+
|
| 13 |
+
DOMAINS = {"code": "Code", "webdev": "Webdev", "cyber": "Cyber", "music": "Music", "general": "General"}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def is_public(r, hidden=()):
|
| 17 |
+
return (r.get("visibility") == "public" and r.get("status") == "done" and scalar_reward(r)
|
| 18 |
+
and not r.get("restricted") and r.get("id") not in hidden)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def scalar_reward(r):
|
| 22 |
+
value = r.get("reward")
|
| 23 |
+
try:
|
| 24 |
+
return type(value) in (int, float) and math.isfinite(value)
|
| 25 |
+
except OverflowError:
|
| 26 |
+
return False
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def runner(r):
|
| 30 |
+
value = r.get("runner") or ("mimo" if r.get("domain") in DOMAINS else "harbor")
|
| 31 |
+
return {"nemo": "nemo-gym", "nemogym": "nemo-gym", "nemo_gym": "nemo-gym"}.get(value, value)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def group(r):
|
| 35 |
+
return DOMAINS.get(r.get("domain")) or r.get("dataset") or "Unknown environment"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def served(r):
|
| 39 |
+
return "own endpoint" if r.get("endpoint") else r.get("provider") or "auto"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def band(r):
|
| 43 |
+
if r.get("status") != "done" or not scalar_reward(r):
|
| 44 |
+
return "unscored"
|
| 45 |
+
return "full" if r["reward"] >= .999 else "zero" if r["reward"] <= 0 else "partial"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
VALUES = {"dataset": lambda r: r.get("dataset") or "", "group": group, "runner": runner,
|
| 49 |
+
"model": lambda r: r.get("model") or "custom endpoint", "served": served,
|
| 50 |
+
"judge": lambda r: r.get("judge") or "", "reward": band,
|
| 51 |
+
"thinking": lambda r: (r.get("params") or {}).get("thinking") or "default"}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def context(r):
|
| 55 |
+
return (r.get("dataset") or "Unknown environment", runner(r), r.get("domain") or "",
|
| 56 |
+
r.get("reward_key") or "reward", r.get("judge") or "",
|
| 57 |
+
r.get("sha") or (r.get("provenance") or {}).get("commit") or "")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def stats(runs):
|
| 61 |
+
groups = defaultdict(list)
|
| 62 |
+
for r in runs:
|
| 63 |
+
if r.get("status") == "done" and scalar_reward(r):
|
| 64 |
+
groups[(*context(r), VALUES["model"](r), served(r))].append(r["reward"])
|
| 65 |
+
results = [{"dataset": k[0], "runner": k[1], "domain": k[2], "metric": k[3], "judge": k[4], "revision": k[5],
|
| 66 |
+
"model": k[6], "served": k[7], "runs": len(v), "mean": math.fsum(x / len(v) for x in v), "min": min(v), "max": max(v)}
|
| 67 |
+
for k, v in sorted(groups.items())]
|
| 68 |
+
contexts = {context(r) for r in runs}
|
| 69 |
+
return {"runs": len(runs), "scored": sum(len(v) for v in groups.values()), "datasets": len({r.get("dataset") for r in runs}),
|
| 70 |
+
"tasks": len({(r.get("dataset"), r.get("path")) for r in runs}), "models": len({VALUES["model"](r) for r in runs}),
|
| 71 |
+
"runners": len({runner(r) for r in runs}), "comparable": len(contexts) == 1, "results": results}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def query(everyone, filters, *, sort="new", offset=0, limit=100):
|
| 75 |
+
def keep(r, skip=None):
|
| 76 |
+
if filters.get("path") is not None and r.get("path") != filters["path"]:
|
| 77 |
+
return False
|
| 78 |
+
words = str(filters.get("q") or "").lower().split()[:16]
|
| 79 |
+
haystack = " ".join(str(r.get(k) or "") for k in ("title", "task_id", "path", "dataset", "model", "runner", "adapter")).lower()
|
| 80 |
+
if not all(word in haystack for word in words):
|
| 81 |
+
return False
|
| 82 |
+
return all(not value or name == skip or VALUES[name](r) == value
|
| 83 |
+
for name, value in filters.items() if name in VALUES)
|
| 84 |
+
runs = [r for r in everyone if keep(r)]
|
| 85 |
+
# Stable explicit ordering also makes pagination deterministic for tied times.
|
| 86 |
+
runs.sort(key=lambda r: (-(r.get("created_at") or 0), r["id"]))
|
| 87 |
+
key = {"reward_desc": lambda r: -r["reward"], "reward_asc": lambda r: r["reward"],
|
| 88 |
+
"cost_asc": lambda r: (r.get("cost") or {}).get("total") or 0,
|
| 89 |
+
"cost_desc": lambda r: -((r.get("cost") or {}).get("total") or 0)}.get(sort)
|
| 90 |
+
if key:
|
| 91 |
+
runs.sort(key=key)
|
| 92 |
+
facets = {}
|
| 93 |
+
for name, value in VALUES.items():
|
| 94 |
+
counts = Counter(value(r) for r in everyone if keep(r, name) and value(r))
|
| 95 |
+
facets[name] = [[k, n] for k, n in sorted(counts.items(), key=lambda x: (-x[1], x[0]))]
|
| 96 |
+
summary = stats(runs)
|
| 97 |
+
return {"stats": summary, "facets": facets, "total": len(runs), "tasks": summary["tasks"],
|
| 98 |
+
"runs": runs[max(0, offset):max(0, offset) + max(1, min(limit, 500))]}
|
app/config.py
CHANGED
|
@@ -23,6 +23,8 @@ OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface
|
|
| 23 |
SPACE_HOST = os.environ.get("SPACE_HOST", "")
|
| 24 |
PUBLIC_URL = (os.environ.get("RLX_PUBLIC_URL") or (f"https://{SPACE_HOST}" if SPACE_HOST else "")).rstrip("/")
|
| 25 |
LOCAL_MODE = not OAUTH_CLIENT_ID
|
|
|
|
|
|
|
| 26 |
# `jobs` runs the sandbox on the visitor's account, `inference-api` calls Inference Providers as them,
|
| 27 |
# `read-repos` opens their private and gated datasets.
|
| 28 |
OAUTH_SCOPES = os.environ.get("RLX_OAUTH_SCOPES", "openid profile inference-api jobs read-repos").split() # the admin Space asks for less
|
|
|
|
| 23 |
SPACE_HOST = os.environ.get("SPACE_HOST", "")
|
| 24 |
PUBLIC_URL = (os.environ.get("RLX_PUBLIC_URL") or (f"https://{SPACE_HOST}" if SPACE_HOST else "")).rstrip("/")
|
| 25 |
LOCAL_MODE = not OAUTH_CLIENT_ID
|
| 26 |
+
# Enable only behind a proxy that replaces/appends the visitor's address.
|
| 27 |
+
TRUST_PROXY = os.environ.get("RLX_TRUST_PROXY", "1" if os.environ.get("SPACE_ID") else "0") == "1"
|
| 28 |
# `jobs` runs the sandbox on the visitor's account, `inference-api` calls Inference Providers as them,
|
| 29 |
# `read-repos` opens their private and gated datasets.
|
| 30 |
OAUTH_SCOPES = os.environ.get("RLX_OAUTH_SCOPES", "openid profile inference-api jobs read-repos").split() # the admin Space asks for less
|
app/envs/capabilities.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evidence-based capabilities shared by dataset adapters, live Spaces, UI and MCP.
|
| 2 |
+
|
| 3 |
+
A framework label describes a format, not permission or proof that it can run.
|
| 4 |
+
A dataset's native harness may be external; a live server may be asleep or may
|
| 5 |
+
speak HTTP instead of OpenEnv's WebSocket protocol. Keep those axes independent.
|
| 6 |
+
The contract is additive so older clients can keep using run.options/step_api.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import asdict, dataclass
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class Framework:
|
| 16 |
+
id: str
|
| 17 |
+
label: str
|
| 18 |
+
task: str
|
| 19 |
+
lifecycle: str
|
| 20 |
+
reward: str
|
| 21 |
+
docs: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
FRAMEWORKS = {
|
| 25 |
+
f.id: f for f in (
|
| 26 |
+
Framework("harbor", "Harbor", "Task folder or packed task files",
|
| 27 |
+
"Prepare sandbox → run agent → verify → collect artifacts",
|
| 28 |
+
"The task's verifier writes a scalar or named reward metrics.",
|
| 29 |
+
"https://www.harborframework.com/docs/tasks"),
|
| 30 |
+
Framework("openenv", "OpenEnv", "Live environment with typed actions and observations",
|
| 31 |
+
"Open session → reset → step until done → close",
|
| 32 |
+
"The environment returns rewards and termination with observations.",
|
| 33 |
+
"https://huggingface.co/docs/openenv/main/reference/core"),
|
| 34 |
+
Framework("verifiers", "Verifiers", "Rows loaded by an installed environment or taskset",
|
| 35 |
+
"Load environment/taskset → run its harness → score the rollout",
|
| 36 |
+
"The installed environment's rubric or judge defines the reward; rows alone do not include that runtime.",
|
| 37 |
+
"https://github.com/PrimeIntellect-ai/verifiers/tree/main/docs"),
|
| 38 |
+
Framework("nemo-gym", "NeMo Gym", "Task data plus agent and resources-server configuration",
|
| 39 |
+
"Seed → interact → verify, or reset → step for Gymnasium servers",
|
| 40 |
+
"A resources server or embedded agent harness computes the reward.",
|
| 41 |
+
"https://docs.nvidia.com/nemo/gym/build-verifiers/"),
|
| 42 |
+
Framework("verl", "verl", "Training rows with prompt and reward metadata",
|
| 43 |
+
"Generate responses → route to the configured reward function → train",
|
| 44 |
+
"data_source selects a scorer unless training config supplies a custom reward function.",
|
| 45 |
+
"https://verl.readthedocs.io/en/latest/advance/reward_loop.html"),
|
| 46 |
+
Framework("skyrl", "SkyRL", "Training rows routed to a skyrl-gym environment",
|
| 47 |
+
"Load rows → run environment interactions → compute training rewards",
|
| 48 |
+
"The selected environment and its configuration define the reward.",
|
| 49 |
+
"https://skyrl.readthedocs.io/en/latest/"),
|
| 50 |
+
Framework("mimo", "MiMo", "Tasks from the MiMo RL environment release",
|
| 51 |
+
"Prepare task → run the release's domain harness → grade",
|
| 52 |
+
"Each domain uses its own verifier; supported conversions also run through Harbor.",
|
| 53 |
+
"https://huggingface.co/datasets/XiaomiMiMo/MiMo-V2.6-RL-oss"),
|
| 54 |
+
Framework("gymnasium", "Gymnasium HTTP", "A server exposing reset and step over HTTP",
|
| 55 |
+
"Reset → step until terminated or truncated → close",
|
| 56 |
+
"The server returns step rewards; its HTTP schema defines the request and response.",
|
| 57 |
+
"https://gymnasium.farama.org/api/env/"),
|
| 58 |
+
Framework("mcp", "MCP", "Tools exposed by a live server", "Connect → call advertised tools → close",
|
| 59 |
+
"A reward is available only when the server exposes a grader or returns one.",
|
| 60 |
+
"https://modelcontextprotocol.io/specification/2025-06-18/server/tools"),
|
| 61 |
+
Framework("ors", "ORS", "A server with named environments and sessions",
|
| 62 |
+
"Create session → call environment → close session", "The server defines its grading contract.", ""),
|
| 63 |
+
Framework("api", "HTTP API", "A live server's published operations", "Call the server's documented routes",
|
| 64 |
+
"Inspect the published schema and grading routes for this server.", ""),
|
| 65 |
+
Framework("rows", "RL dataset", "Dataset rows", "Browse task data; use the source's environment to execute it",
|
| 66 |
+
"A dataset label or answer column alone does not establish a runnable grader.", ""),
|
| 67 |
+
)
|
| 68 |
+
}
|
| 69 |
+
HOSTED_RUNNERS = frozenset({"harbor", "mimo", "nemo-gym"})
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def framework(value: str | None) -> Framework:
|
| 73 |
+
key = str(value or "").lower().replace("_", "-").replace(" ", "-")
|
| 74 |
+
aliases = {"nemo": "nemo-gym", "nemogym": "nemo-gym", "generic": "rows", "rl": "rows", "harbor-(packed)": "harbor"}
|
| 75 |
+
key = aliases.get(key, key)
|
| 76 |
+
if key.startswith("mimo"):
|
| 77 |
+
key = "mimo"
|
| 78 |
+
return FRAMEWORKS.get(key, FRAMEWORKS["rows"])
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def capability(id: str, label: str, state: str, detail: str) -> dict[str, str]:
|
| 82 |
+
if state not in {"available", "external", "unavailable", "unknown"}:
|
| 83 |
+
raise ValueError(f"invalid capability state: {state}")
|
| 84 |
+
return {"id": id, "label": label, "state": state, "detail": detail}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def dataset_support(name: str, *, state: str = "ready", options: list[dict[str, Any]] | None = None,
|
| 88 |
+
note: str = "", revision: str | None = None, restricted: bool = False) -> dict[str, Any]:
|
| 89 |
+
f = framework(name)
|
| 90 |
+
ready = state == "ready"
|
| 91 |
+
features = [capability("browse", "Browse tasks", "available" if ready else "unknown",
|
| 92 |
+
"Inspect the prompt, grading information and source files." if ready else "Task discovery is still in progress.")]
|
| 93 |
+
if options is None:
|
| 94 |
+
features.append(capability("run", "Run an agent", "unknown", "Open a task to check its harness and runtime requirements."))
|
| 95 |
+
else:
|
| 96 |
+
runnable = [o for o in options if o.get("ok") and o.get("runner") in HOSTED_RUNNERS and not o.get("href")]
|
| 97 |
+
external = [o for o in options if o.get("href") or (not o.get("ok") and o.get("runner") in {"verifiers", "nemo-gym", "nemogym", "verl", "skyrl"})]
|
| 98 |
+
why = "; ".join(dict.fromkeys(o.get("why") for o in options if o.get("why")))
|
| 99 |
+
features.append(capability("run", "Run an agent", "available" if runnable else "external" if external else "unavailable",
|
| 100 |
+
"Runs here with " + ", ".join(o["label"] for o in runnable) + "." if runnable else
|
| 101 |
+
why or note or "This source has no executable harness configured here."))
|
| 102 |
+
features.append(capability("mcp", "Explore with an agent", "unavailable" if restricted else "available" if ready else "unknown",
|
| 103 |
+
"The MCP bridge only serves public datasets; this source requires your sign-in." if restricted else
|
| 104 |
+
"MCP exposes public task browsing. Execution follows the runtime support above."))
|
| 105 |
+
return {"version": 1, "framework": asdict(f), "source": "dataset", "evidence": "adapter", "revision": revision,
|
| 106 |
+
"transport": None, "capabilities": features}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def space_support(info: dict[str, Any]) -> dict[str, Any]:
|
| 110 |
+
up = bool(info.get("running"))
|
| 111 |
+
f = framework(info.get("framework") or "api")
|
| 112 |
+
transport = "openenv-ws" if info.get("openenv") else "http" if info.get("api") else "mcp-http" if info.get("mcp") else None
|
| 113 |
+
can_play = bool(info.get("step_api") or info.get("mcp") or info.get("api"))
|
| 114 |
+
status = "available" if up and can_play else "unavailable" if up else "unknown"
|
| 115 |
+
detail = ("Play an episode over OpenEnv's WebSocket session." if transport == "openenv-ws" else
|
| 116 |
+
"Call the published HTTP routes; each explorer session keeps its own cookies." if transport == "http" else
|
| 117 |
+
"Call MCP tools. Episode state depends on the server's MCP implementation.") if can_play else "No supported interactive API was discovered."
|
| 118 |
+
if not up:
|
| 119 |
+
detail = "The Space is " + str(info.get("stage") or "unreachable").lower().replace("_", " ") + "; check its live interface when it is running."
|
| 120 |
+
return {"version": 1, "framework": asdict(f), "source": "space", "evidence": "live" if up else "unverified",
|
| 121 |
+
"transport": transport, "capabilities": [
|
| 122 |
+
capability("play", "Interactive playground", status, detail),
|
| 123 |
+
capability("browse", "Browse tasks", "available" if up and info.get("task_api") else "unavailable" if up else "unknown",
|
| 124 |
+
"Tasks are served by the environment's Task API." if info.get("task_api") else "No live Task API was discovered; source files may describe the tasks."),
|
| 125 |
+
capability("mcp", "Connect an agent", status,
|
| 126 |
+
"The explorer bridges the advertised interface to MCP." if up and can_play else detail),
|
| 127 |
+
]}
|
app/envs/mimo.py
CHANGED
|
@@ -250,7 +250,7 @@ class MiMoAdapter(c.Adapter):
|
|
| 250 |
if not rows and not how:
|
| 251 |
return None
|
| 252 |
net = ("Web search and fetch tools are off." if d == "webdev" else "Web search and fetch tools are off, and the sites where answers live "
|
| 253 |
-
"(code hosting, bug trackers, search engines) are
|
| 254 |
return c.section("setup", "How the sandbox is set up", [c.kv(rows), c.note(f"{how} {net}", "shield") if how else None], icon="box")
|
| 255 |
|
| 256 |
def _glance(self, v: dict[str, Any]) -> list[list[str]]:
|
|
|
|
| 250 |
if not rows and not how:
|
| 251 |
return None
|
| 252 |
net = ("Web search and fetch tools are off." if d == "webdev" else "Web search and fetch tools are off, and the sites where answers live "
|
| 253 |
+
"(code hosting, bug trackers, search engines) are added to a hosts-file blocklist. This reduces answer leakage but is not an egress firewall. Other connections depend on the hosting provider's network policy.")
|
| 254 |
return c.section("setup", "How the sandbox is set up", [c.kv(rows), c.note(f"{how} {net}", "shield") if how else None], icon="box")
|
| 255 |
|
| 256 |
def _glance(self, v: dict[str, Any]) -> list[list[str]]:
|
app/envs/nemo_gym_build.py
CHANGED
|
@@ -18,6 +18,12 @@ from pathlib import Path
|
|
| 18 |
|
| 19 |
import yaml
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
def main(ROOT: Path) -> None:
|
| 23 |
OUT = CAT = Path(__file__).with_name("nemo_gym_catalog.json")
|
|
@@ -93,6 +99,8 @@ def main(ROOT: Path) -> None:
|
|
| 93 |
continue
|
| 94 |
t = table.get(d.name, {})
|
| 95 |
servers[d.name] = {k: v for k, v in {"description": t.get("description"), "verification": t.get("verification"), "readme": readme_para(d)}.items() if v}
|
|
|
|
|
|
|
| 96 |
for cfg in sorted((d / "configs").glob("*.yaml")):
|
| 97 |
flavor = d.name if cfg.stem == d.name else f"{d.name}/{cfg.stem}"
|
| 98 |
scan(cfg, d.name, flavor, None)
|
|
|
|
| 18 |
|
| 19 |
import yaml
|
| 20 |
|
| 21 |
+
# Reviewed against each server's implementation; the upstream environment table
|
| 22 |
+
# incorrectly calls these LLM judges. Keep regenerated catalogs accurate too.
|
| 23 |
+
VERIFICATION_OVERRIDES = {
|
| 24 |
+
"instruction_following": "Rule-based instruction checks",
|
| 25 |
+
"math_proof_judgement": "Deterministic answer parsing",
|
| 26 |
+
}
|
| 27 |
|
| 28 |
def main(ROOT: Path) -> None:
|
| 29 |
OUT = CAT = Path(__file__).with_name("nemo_gym_catalog.json")
|
|
|
|
| 99 |
continue
|
| 100 |
t = table.get(d.name, {})
|
| 101 |
servers[d.name] = {k: v for k, v in {"description": t.get("description"), "verification": t.get("verification"), "readme": readme_para(d)}.items() if v}
|
| 102 |
+
if d.name in VERIFICATION_OVERRIDES:
|
| 103 |
+
servers[d.name]["verification"] = VERIFICATION_OVERRIDES[d.name]
|
| 104 |
for cfg in sorted((d / "configs").glob("*.yaml")):
|
| 105 |
flavor = d.name if cfg.stem == d.name else f"{d.name}/{cfg.stem}"
|
| 106 |
scan(cfg, d.name, flavor, None)
|
app/envs/nemo_gym_catalog.json
CHANGED
|
@@ -1985,7 +1985,7 @@
|
|
| 1985 |
},
|
| 1986 |
"instruction_following": {
|
| 1987 |
"description": "IFEval and IFBench-style instruction following",
|
| 1988 |
-
"verification": "
|
| 1989 |
"readme": "Evaluates language model responses against instruction-following criteria using Open-Instruct and IFEval constraints.",
|
| 1990 |
"fields": {
|
| 1991 |
"id": {
|
|
@@ -2454,7 +2454,7 @@
|
|
| 2454 |
},
|
| 2455 |
"math_proof_judgement": {
|
| 2456 |
"description": "Binary proof judgement — model reads a problem and proof, outputs Yes/No",
|
| 2457 |
-
"verification": "
|
| 2458 |
"readme": "Binary judgement of math proofs. The policy model reads a problem + candidate proof and outputs `Judgement: Yes` (proof correct) or `Judgement: No` (proof incorrect). Verification is fully deterministic — a single regex parses the final judgement — so **no external LLM judge is called**. This mirrors NeMo Skills' `answer-judgement` metric (`is_correct_judgement` format 1).",
|
| 2459 |
"fields": {
|
| 2460 |
"expected_judgement": {
|
|
|
|
| 1985 |
},
|
| 1986 |
"instruction_following": {
|
| 1987 |
"description": "IFEval and IFBench-style instruction following",
|
| 1988 |
+
"verification": "Rule-based instruction checks",
|
| 1989 |
"readme": "Evaluates language model responses against instruction-following criteria using Open-Instruct and IFEval constraints.",
|
| 1990 |
"fields": {
|
| 1991 |
"id": {
|
|
|
|
| 2454 |
},
|
| 2455 |
"math_proof_judgement": {
|
| 2456 |
"description": "Binary proof judgement — model reads a problem and proof, outputs Yes/No",
|
| 2457 |
+
"verification": "Deterministic answer parsing",
|
| 2458 |
"readme": "Binary judgement of math proofs. The policy model reads a problem + candidate proof and outputs `Judgement: Yes` (proof correct) or `Judgement: No` (proof incorrect). Verification is fully deterministic — a single regex parses the final judgement — so **no external LLM judge is called**. This mirrors NeMo Skills' `answer-judgement` metric (`is_correct_judgement` format 1).",
|
| 2459 |
"fields": {
|
| 2460 |
"expected_judgement": {
|
app/envs/nemogym.py
CHANGED
|
@@ -250,7 +250,8 @@ def run_blocks(spec: str, config: str, split: str, agents: list[dict[str, Any]],
|
|
| 250 |
artifact = next((u.get("artifact") for u in cat["datasets"].get(spec.lower()) or [] if u.get("type") == split and u.get("artifact")), None) \
|
| 251 |
if config == "default" else None
|
| 252 |
what = f"this dataset's {split} split" + ("" if config == "default" else f" of {config}")
|
| 253 |
-
lines = ["# NeMo Gym, from
|
|
|
|
| 254 |
"uv venv --python 3.13.14 && source .venv/bin/activate && uv sync", "",
|
| 255 |
f"# the rows: {what}, as JSON Lines" + (", and this row alone" if row is not None else ""), *_download(spec, config, split, local, artifact,
|
| 256 |
rowfiles.files_of(spec, meta or {}, config, split) if config != "default" else [])]
|
|
@@ -292,8 +293,21 @@ def run_blocks(spec: str, config: str, split: str, agents: list[dict[str, Any]],
|
|
| 292 |
return blocks
|
| 293 |
|
| 294 |
|
| 295 |
-
def run_option(agent: dict[str, Any]) -> dict[str, Any]:
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
rs = agent.get("resources_server")
|
| 298 |
return c.run_option("nemo-gym", "NeMo Gym", ok=False,
|
| 299 |
why=f"it is scored by NeMo Gym's {rs + ' ' if rs else ''}resources server, which runs only inside NeMo Gym",
|
|
@@ -430,8 +444,9 @@ class NemoGym(Processor):
|
|
| 430 |
rows = convo.field_rows({**dict(settings), **nested})
|
| 431 |
sections.append(section("request", "Request settings", [c.custom("rl", "fields", {"rows": rows}, convo.fields_text(rows))], kind="blocks",
|
| 432 |
note="the rest of responses_create_params"))
|
|
|
|
| 433 |
sections.append(section("run", "Run it with NeMo Gym", run_blocks(ds.spec, config, split, [agent], row=i, meta=ds.card), kind="blocks",
|
| 434 |
-
note="
|
| 435 |
rest = {k: v for k, v in clean.items() if k not in task_data and k not in FRAMEWORK and v not in (None, "", [], {})}
|
| 436 |
if rest:
|
| 437 |
label = "provenance and metrics" if roles_of else "the rest of the row"
|
|
@@ -452,7 +467,7 @@ class NemoGym(Processor):
|
|
| 452 |
glance.append(["Pass rate", p])
|
| 453 |
card = self.card(row, i, roles)
|
| 454 |
return {"title": title[:200], "id": card["id"], "chips": card["chips"], "sections": sections, "withheld": gone, "glance": glance,
|
| 455 |
-
"framework_run":
|
| 456 |
"summary": "Scored on the next action matching the expert's" if pivot else
|
| 457 |
(f"Verifier: {agent['verification'].lower()}" if agent.get("verification") else "Scored by its resources server")}
|
| 458 |
|
|
|
|
| 250 |
artifact = next((u.get("artifact") for u in cat["datasets"].get(spec.lower()) or [] if u.get("type") == split and u.get("artifact")), None) \
|
| 251 |
if config == "default" else None
|
| 252 |
what = f"this dataset's {split} split" + ("" if config == "default" else f" of {config}")
|
| 253 |
+
lines = ["# NeMo Gym, from the revision used by this explorer's catalog (Python 3.13+, uv)",
|
| 254 |
+
"git clone https://github.com/NVIDIA-NeMo/Gym.git && cd Gym", f"git checkout {cat['commit']}",
|
| 255 |
"uv venv --python 3.13.14 && source .venv/bin/activate && uv sync", "",
|
| 256 |
f"# the rows: {what}, as JSON Lines" + (", and this row alone" if row is not None else ""), *_download(spec, config, split, local, artifact,
|
| 257 |
rowfiles.files_of(spec, meta or {}, config, split) if config != "default" else [])]
|
|
|
|
| 293 |
return blocks
|
| 294 |
|
| 295 |
|
| 296 |
+
def run_option(agent: dict[str, Any], row: dict | None = None) -> dict[str, Any]:
|
| 297 |
+
from .. import nemo_runner
|
| 298 |
+
if row is not None and nemo_runner.supported(agent):
|
| 299 |
+
try:
|
| 300 |
+
nemo_runner.validate(row, agent)
|
| 301 |
+
except (ValueError, KeyError, TypeError) as error:
|
| 302 |
+
return c.run_option("nemo-gym", "NeMo Gym prediction", ok=False, why=str(error), endpoint=False, sandbox=False)
|
| 303 |
+
return c.run_option("nemo-gym", "NeMo Gym prediction", ok=True, endpoint=False, sandbox=False,
|
| 304 |
+
about="One model prediction, scored by NVIDIA's NeMo Gym argument comparator. Tool calls are predictions, not executed actions. No sandbox is needed.",
|
| 305 |
+
notes=[f"Verifier pinned to NVIDIA-NeMo/Gym `{nemo_runner.REVISION[:12]}` with its native 0.1 word-similarity threshold."] +
|
| 306 |
+
(["Some source tools have invalid strict schemas. Their parameters stay unchanged; strict enforcement is disabled so HF providers accept them."] if nemo_runner.relaxed_tools(row) else []),
|
| 307 |
+
harnesses=["opencode"], fields=[
|
| 308 |
+
c.field("max_tokens", "Max output tokens", "number", default=2048, min=256, max=8192, advanced=True),
|
| 309 |
+
c.field("temperature", "Temperature", "number", min=0, max=2, step=0.1, advanced=True)],
|
| 310 |
+
estimate={"tokens_in": 4000, "tokens_out": 1000, "minutes": 0})
|
| 311 |
rs = agent.get("resources_server")
|
| 312 |
return c.run_option("nemo-gym", "NeMo Gym", ok=False,
|
| 313 |
why=f"it is scored by NeMo Gym's {rs + ' ' if rs else ''}resources server, which runs only inside NeMo Gym",
|
|
|
|
| 444 |
rows = convo.field_rows({**dict(settings), **nested})
|
| 445 |
sections.append(section("request", "Request settings", [c.custom("rl", "fields", {"rows": rows}, convo.fields_text(rows))], kind="blocks",
|
| 446 |
note="the rest of responses_create_params"))
|
| 447 |
+
option = run_option(agent, row)
|
| 448 |
sections.append(section("run", "Run it with NeMo Gym", run_blocks(ds.spec, config, split, [agent], row=i, meta=ds.card), kind="blocks",
|
| 449 |
+
note="Native CLI alternative; prediction runs are also available here" if option["ok"] else "This task needs the native runtime"))
|
| 450 |
rest = {k: v for k, v in clean.items() if k not in task_data and k not in FRAMEWORK and v not in (None, "", [], {})}
|
| 451 |
if rest:
|
| 452 |
label = "provenance and metrics" if roles_of else "the rest of the row"
|
|
|
|
| 467 |
glance.append(["Pass rate", p])
|
| 468 |
card = self.card(row, i, roles)
|
| 469 |
return {"title": title[:200], "id": card["id"], "chips": card["chips"], "sections": sections, "withheld": gone, "glance": glance,
|
| 470 |
+
"framework_run": option,
|
| 471 |
"summary": "Scored on the next action matching the expert's" if pivot else
|
| 472 |
(f"Verifier: {agent['verification'].lower()}" if agent.get("verification") else "Scored by its resources server")}
|
| 473 |
|
app/envs/registry.py
CHANGED
|
@@ -16,6 +16,7 @@ from typing import Any, Callable
|
|
| 16 |
|
| 17 |
from .. import catalog
|
| 18 |
from .contract import Adapter, Env
|
|
|
|
| 19 |
from .harbor import HarborAdapter
|
| 20 |
from .mimo import MiMoAdapter, harbor_aliases, harbor_to_mimo
|
| 21 |
from .rows import RowsAdapter
|
|
@@ -86,8 +87,10 @@ def summary(spec: str, token: str | None = None, subset: str | None = None) -> d
|
|
| 86 |
env = resolve("dataset", spec, token)
|
| 87 |
s = env.adapter.summary(env, subset)
|
| 88 |
how = s.get("how") or {}
|
|
|
|
| 89 |
return {"env": {**describe_env(env), "framework": how.get("framework") or env.adapter.framework},
|
| 90 |
-
"info": {k: v for k, v in env.meta.items() if k != "all_tags"}, "collection": catalog.collection(spec), **s
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
def tasks(spec: str, token: str | None = None, **kw: Any) -> dict[str, Any]:
|
|
@@ -108,6 +111,7 @@ def task(spec: str, ref: str, token: str | None = None) -> dict[str, Any]:
|
|
| 108 |
out.setdefault("collection", catalog.collection(spec))
|
| 109 |
out["env"] = {**describe_env(env), "framework": view.get("framework") or env.adapter.framework}
|
| 110 |
out["run"] = {"options": options, "note": "" if options else env.adapter.run_note}
|
|
|
|
| 111 |
out["aliases"] = [{"env": k, "ref": r} for k, r in aliases(env, ref)]
|
| 112 |
return out
|
| 113 |
|
|
|
|
| 16 |
|
| 17 |
from .. import catalog
|
| 18 |
from .contract import Adapter, Env
|
| 19 |
+
from .capabilities import dataset_support
|
| 20 |
from .harbor import HarborAdapter
|
| 21 |
from .mimo import MiMoAdapter, harbor_aliases, harbor_to_mimo
|
| 22 |
from .rows import RowsAdapter
|
|
|
|
| 87 |
env = resolve("dataset", spec, token)
|
| 88 |
s = env.adapter.summary(env, subset)
|
| 89 |
how = s.get("how") or {}
|
| 90 |
+
name = how.get("framework") or env.adapter.framework
|
| 91 |
return {"env": {**describe_env(env), "framework": how.get("framework") or env.adapter.framework},
|
| 92 |
+
"info": {k: v for k, v in env.meta.items() if k != "all_tags"}, "collection": catalog.collection(spec), **s,
|
| 93 |
+
"support": dataset_support(name, state=s.get("state", "ready"), revision=env.meta.get("sha"), restricted=bool(env.meta.get("restricted")))}
|
| 94 |
|
| 95 |
|
| 96 |
def tasks(spec: str, token: str | None = None, **kw: Any) -> dict[str, Any]:
|
|
|
|
| 111 |
out.setdefault("collection", catalog.collection(spec))
|
| 112 |
out["env"] = {**describe_env(env), "framework": view.get("framework") or env.adapter.framework}
|
| 113 |
out["run"] = {"options": options, "note": "" if options else env.adapter.run_note}
|
| 114 |
+
out["support"] = dataset_support(out["env"]["framework"], options=options, note=env.adapter.run_note, revision=env.meta.get("sha"), restricted=bool(env.meta.get("restricted")))
|
| 115 |
out["aliases"] = [{"env": k, "ref": r} for k, r in aliases(env, ref)]
|
| 116 |
return out
|
| 117 |
|
app/envs/rows.py
CHANGED
|
@@ -635,6 +635,16 @@ class RowsAdapter(c.Adapter):
|
|
| 635 |
|
| 636 |
config, split, i = parse_ref(ref)
|
| 637 |
view = self.task(env, ref)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
if view.get("_packed_run"):
|
| 639 |
p = packed(env.id, config, split, i, env.token)
|
| 640 |
return {"title": p["title"], "restricted": p["restricted"], "sha": p["sha"], "bytes": p["bytes"], "runnable": p["runnable"],
|
|
|
|
| 635 |
|
| 636 |
config, split, i = parse_ref(ref)
|
| 637 |
view = self.task(env, ref)
|
| 638 |
+
if (view.get("_framework_run") or {}).get("runner") == "nemo-gym":
|
| 639 |
+
from .nemogym import agent_of
|
| 640 |
+
from .. import nemo_runner
|
| 641 |
+
*_, row, truncated = _row(env.id, config, split, i, env.token)
|
| 642 |
+
if truncated:
|
| 643 |
+
raise ValueError("This task row is truncated; use the complete dataset with NeMo Gym")
|
| 644 |
+
agent = agent_of(row, env.id)
|
| 645 |
+
nemo_runner.validate(row, agent)
|
| 646 |
+
return {"title": view["title"], "restricted": bool(env.meta.get("restricted")), "sha": env.meta.get("sha"),
|
| 647 |
+
"native_task": {"row": row, "agent": agent}}
|
| 648 |
if view.get("_packed_run"):
|
| 649 |
p = packed(env.id, config, split, i, env.token)
|
| 650 |
return {"title": p["title"], "restricted": p["restricted"], "sha": p["sha"], "bytes": p["bytes"], "runnable": p["runnable"],
|
app/envs/tools.py
CHANGED
|
@@ -49,6 +49,7 @@ def call(spec: str, name: str, args: dict[str, Any]) -> Any:
|
|
| 49 |
if s.get("state") != "ready":
|
| 50 |
return {"state": s.get("state"), "note": "Its index is being built; try again in a minute.", "progress": s.get("progress")}
|
| 51 |
return {"id": spec, "format": s["env"]["framework"], "read_as": (s.get("how") or {}).get("name"), "about": (s.get("how") or {}).get("about"),
|
|
|
|
| 52 |
"tasks": s.get("total"), "subsets": [x["id"] for x in s.get("subsets") or []], "subset": s.get("subset"),
|
| 53 |
"facets": {f["key"]: f["label"] for f in s.get("facets") or []},
|
| 54 |
"overview": blocks_text([b for sec in s.get("overview") or [] for b in [{"type": "markdown", "text": f"## {sec['title']}"}, *sec["blocks"]]])}
|
|
@@ -101,6 +102,10 @@ def task_text(v: dict[str, Any]) -> str:
|
|
| 101 |
if v.get("withheld"):
|
| 102 |
out.append("\nWithheld, as they hold the answer: " + ", ".join(v["withheld"]))
|
| 103 |
opts = (v.get("run") or {}).get("options") or []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
out.append("\n## Running it\n" + ("\n".join(f"- {o['label']}: " + ("runs here" if o["ok"] else f"can't run here ({o['why']})") for o in opts)
|
| 105 |
or (v.get("run") or {}).get("note") or ""))
|
| 106 |
for link in v.get("links") or []:
|
|
|
|
| 49 |
if s.get("state") != "ready":
|
| 50 |
return {"state": s.get("state"), "note": "Its index is being built; try again in a minute.", "progress": s.get("progress")}
|
| 51 |
return {"id": spec, "format": s["env"]["framework"], "read_as": (s.get("how") or {}).get("name"), "about": (s.get("how") or {}).get("about"),
|
| 52 |
+
"support": s.get("support"),
|
| 53 |
"tasks": s.get("total"), "subsets": [x["id"] for x in s.get("subsets") or []], "subset": s.get("subset"),
|
| 54 |
"facets": {f["key"]: f["label"] for f in s.get("facets") or []},
|
| 55 |
"overview": blocks_text([b for sec in s.get("overview") or [] for b in [{"type": "markdown", "text": f"## {sec['title']}"}, *sec["blocks"]]])}
|
|
|
|
| 102 |
if v.get("withheld"):
|
| 103 |
out.append("\nWithheld, as they hold the answer: " + ", ".join(v["withheld"]))
|
| 104 |
opts = (v.get("run") or {}).get("options") or []
|
| 105 |
+
support = v.get("support") or {}
|
| 106 |
+
if support.get("framework"):
|
| 107 |
+
f = support["framework"]
|
| 108 |
+
out.append(f"\nLifecycle: {f['lifecycle']}\nReward: {f['reward']}")
|
| 109 |
out.append("\n## Running it\n" + ("\n".join(f"- {o['label']}: " + ("runs here" if o["ok"] else f"can't run here ({o['why']})") for o in opts)
|
| 110 |
or (v.get("run") or {}).get("note") or ""))
|
| 111 |
for link in v.get("links") or []:
|
app/envs/verifiers_rows.py
CHANGED
|
@@ -196,8 +196,11 @@ def _prompt_turns(row: dict[str, Any], env: dict[str, Any]) -> list[dict[str, An
|
|
| 196 |
ts = [{"kind": "message", "role": "user", "text": row["question"]}]
|
| 197 |
else:
|
| 198 |
ts = []
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
| 201 |
return ts
|
| 202 |
|
| 203 |
|
|
@@ -308,7 +311,7 @@ class Verifiers(Processor):
|
|
| 308 |
gblocks += [c.note("`info`, which the rewards also get:", "list"), c.custom("rl", "fields", {"rows": rows}, convo.fields_text(rows))]
|
| 309 |
sections.append(section("grading", "How it's graded", gblocks, kind="blocks", note="the environment's rewards"))
|
| 310 |
sections.append(section("run", "Run it with Verifiers", run_blocks(ds.spec, env, config, split, row=i), kind="blocks", note="it doesn't run in this app"))
|
| 311 |
-
shown = {"problem_statement", "prompt", "question", "info", "task", *IMAGE_COLS, "repo", "base_commit", "parent_commit", "workdir", "language",
|
| 312 |
"repo_language", "version", "test_cmd", "install_config"}
|
| 313 |
rest = {k: v for k, v in clean.items() if k not in shown and v not in (None, "", [], {})}
|
| 314 |
if rest:
|
|
@@ -361,10 +364,10 @@ class Verifiers(Processor):
|
|
| 361 |
|
| 362 |
|
| 363 |
def run_blocks(spec: str, env: dict[str, Any], config: str, split: str, row: int | None = None) -> list[dict[str, Any]]:
|
| 364 |
-
"""Commands
|
| 365 |
eid = env.get("env")
|
| 366 |
head = ["# Verifiers (pip: verifiers 0.3.1) and the Prime CLI, in a new uv project",
|
| 367 |
-
"uv init vf-eval-here && cd vf-eval-here && uv add verifiers", "uv tool install prime"]
|
| 368 |
if not eid:
|
| 369 |
lines = head + ["", "# find and install the environment that loads this dataset (its card or repository names it)",
|
| 370 |
"prime env list", "prime env install <owner>/<environment>", "",
|
|
|
|
| 196 |
ts = [{"kind": "message", "role": "user", "text": row["question"]}]
|
| 197 |
else:
|
| 198 |
ts = []
|
| 199 |
+
# v1 TaskData carries an explicit system_prompt. It belongs in the agent's
|
| 200 |
+
# conversation, ahead of the row's prompt, and overrides a known v0 default.
|
| 201 |
+
system = row.get("system_prompt") if isinstance(row.get("system_prompt"), str) else env.get("system")
|
| 202 |
+
if system and not any(t["role"] == "system" for t in ts):
|
| 203 |
+
ts.insert(0, {"kind": "message", "role": "system", "text": system})
|
| 204 |
return ts
|
| 205 |
|
| 206 |
|
|
|
|
| 311 |
gblocks += [c.note("`info`, which the rewards also get:", "list"), c.custom("rl", "fields", {"rows": rows}, convo.fields_text(rows))]
|
| 312 |
sections.append(section("grading", "How it's graded", gblocks, kind="blocks", note="the environment's rewards"))
|
| 313 |
sections.append(section("run", "Run it with Verifiers", run_blocks(ds.spec, env, config, split, row=i), kind="blocks", note="it doesn't run in this app"))
|
| 314 |
+
shown = {"problem_statement", "prompt", "system_prompt", "question", "info", "task", *IMAGE_COLS, "repo", "base_commit", "parent_commit", "workdir", "language",
|
| 315 |
"repo_language", "version", "test_cmd", "install_config"}
|
| 316 |
rest = {k: v for k, v in clean.items() if k not in shown and v not in (None, "", [], {})}
|
| 317 |
if rest:
|
|
|
|
| 364 |
|
| 365 |
|
| 366 |
def run_blocks(spec: str, env: dict[str, Any], config: str, split: str, row: int | None = None) -> list[dict[str, Any]]:
|
| 367 |
+
"""Commands targeting the release that exposes both the v0 and v1 CLIs."""
|
| 368 |
eid = env.get("env")
|
| 369 |
head = ["# Verifiers (pip: verifiers 0.3.1) and the Prime CLI, in a new uv project",
|
| 370 |
+
"uv init vf-eval-here && cd vf-eval-here && uv add 'verifiers==0.3.1'", "uv tool install prime"]
|
| 371 |
if not eid:
|
| 372 |
lines = head + ["", "# find and install the environment that loads this dataset (its card or repository names it)",
|
| 373 |
"prime env list", "prime env install <owner>/<environment>", "",
|
app/http.py
CHANGED
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
| 6 |
import os
|
| 7 |
import threading
|
| 8 |
import time
|
|
|
|
| 9 |
|
| 10 |
from fastapi import FastAPI, Request
|
| 11 |
from fastapi.responses import JSONResponse
|
|
@@ -21,7 +22,7 @@ CSP = ("default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-sr
|
|
| 21 |
def client_ip(request: Request) -> str:
|
| 22 |
"""The visitor's address: the last X-Forwarded-For entry is the one the Space's proxy added (earlier ones are
|
| 23 |
whatever the client claimed)."""
|
| 24 |
-
fwd = [p.strip() for p in request.headers.get("x-forwarded-for", "").split(",") if p.strip()]
|
| 25 |
return fwd[-1] if fwd else (request.client.host if request.client else "?")
|
| 26 |
|
| 27 |
|
|
@@ -51,9 +52,22 @@ class Limiter:
|
|
| 51 |
|
| 52 |
|
| 53 |
def same_origin(request: Request) -> bool:
|
|
|
|
| 54 |
origin = request.headers.get("origin")
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def install(app: FastAPI) -> None:
|
|
@@ -61,7 +75,11 @@ def install(app: FastAPI) -> None:
|
|
| 61 |
async def _revalidate(request: Request, call_next):
|
| 62 |
"""Code and styles revalidate on every load (cheap: ETag -> 304), so a deploy is never half-cached."""
|
| 63 |
resp = await call_next(request)
|
| 64 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
resp.headers.setdefault("Cache-Control", "no-cache")
|
| 66 |
return resp
|
| 67 |
|
|
@@ -88,10 +106,6 @@ def install(app: FastAPI) -> None:
|
|
| 88 |
"""The session cookie is SameSite=None (it has to work in the huggingface.co iframe), so a state-changing
|
| 89 |
request must prove it came from this page: another site could otherwise start rollouts billed to you."""
|
| 90 |
if request.method not in ("GET", "HEAD", "OPTIONS") and request.url.path.startswith("/api/"):
|
| 91 |
-
|
| 92 |
-
host = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
| 93 |
-
if origin and origin.split("://", 1)[-1] != host:
|
| 94 |
-
return JSONResponse({"detail": "cross-site request refused"}, 403)
|
| 95 |
-
if not origin and request.headers.get("sec-fetch-site") not in (None, "same-origin", "none"):
|
| 96 |
return JSONResponse({"detail": "cross-site request refused"}, 403)
|
| 97 |
return await call_next(request)
|
|
|
|
| 6 |
import os
|
| 7 |
import threading
|
| 8 |
import time
|
| 9 |
+
from urllib.parse import urlsplit
|
| 10 |
|
| 11 |
from fastapi import FastAPI, Request
|
| 12 |
from fastapi.responses import JSONResponse
|
|
|
|
| 22 |
def client_ip(request: Request) -> str:
|
| 23 |
"""The visitor's address: the last X-Forwarded-For entry is the one the Space's proxy added (earlier ones are
|
| 24 |
whatever the client claimed)."""
|
| 25 |
+
fwd = [p.strip() for p in request.headers.get("x-forwarded-for", "").split(",") if p.strip()] if config.TRUST_PROXY else []
|
| 26 |
return fwd[-1] if fwd else (request.client.host if request.client else "?")
|
| 27 |
|
| 28 |
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def same_origin(request: Request) -> bool:
|
| 55 |
+
"""Compare complete origins; forwarding headers supplied by a client aren't authority."""
|
| 56 |
origin = request.headers.get("origin")
|
| 57 |
+
if not origin:
|
| 58 |
+
return request.headers.get("sec-fetch-site") in (None, "same-origin", "none")
|
| 59 |
+
|
| 60 |
+
def canonical(value: str):
|
| 61 |
+
try:
|
| 62 |
+
u = urlsplit(value)
|
| 63 |
+
if u.scheme not in ("http", "https") or not u.hostname or u.username or u.password or u.path or u.query or u.fragment:
|
| 64 |
+
return None
|
| 65 |
+
return u.scheme, u.hostname.lower(), u.port or (443 if u.scheme == "https" else 80)
|
| 66 |
+
except ValueError:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
expected = canonical(config.PUBLIC_URL) if config.PUBLIC_URL else canonical(f"{request.url.scheme}://{request.headers.get('host', '')}")
|
| 70 |
+
return expected is not None and canonical(origin) == expected
|
| 71 |
|
| 72 |
|
| 73 |
def install(app: FastAPI) -> None:
|
|
|
|
| 75 |
async def _revalidate(request: Request, call_next):
|
| 76 |
"""Code and styles revalidate on every load (cheap: ETag -> 304), so a deploy is never half-cached."""
|
| 77 |
resp = await call_next(request)
|
| 78 |
+
if request.url.path.startswith(("/api/", "/mcp/", "/login")):
|
| 79 |
+
# APIs can contain a visitor's private dataset, run, or account. A shared
|
| 80 |
+
# proxy or the browser's cache must never reuse them for another visitor.
|
| 81 |
+
resp.headers["Cache-Control"] = "private, no-store"
|
| 82 |
+
else:
|
| 83 |
resp.headers.setdefault("Cache-Control", "no-cache")
|
| 84 |
return resp
|
| 85 |
|
|
|
|
| 106 |
"""The session cookie is SameSite=None (it has to work in the huggingface.co iframe), so a state-changing
|
| 107 |
request must prove it came from this page: another site could otherwise start rollouts billed to you."""
|
| 108 |
if request.method not in ("GET", "HEAD", "OPTIONS") and request.url.path.startswith("/api/"):
|
| 109 |
+
if not same_origin(request):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
return JSONResponse({"detail": "cross-site request refused"}, 403)
|
| 111 |
return await call_next(request)
|
app/indexer.py
CHANGED
|
@@ -231,6 +231,9 @@ def refresh_indexes(items: list[dict[str, Any]], state: dict[str, Any], *, budge
|
|
| 231 |
th.join(timeout=max(60.0, deadline - time.time() + 120))
|
| 232 |
if th.is_alive():
|
| 233 |
out["abandoned"].append(spec)
|
|
|
|
|
|
|
|
|
|
| 234 |
_ev("build_abandoned", spec=spec, seconds=round(time.time() - t0))
|
| 235 |
continue
|
| 236 |
if "error" in result:
|
|
@@ -318,7 +321,7 @@ def prune(store, keep: int) -> list[str]:
|
|
| 318 |
|
| 319 |
def run(store, *, rows: list[dict[str, Any]] | None = None, budget_s: float = 1800, max_builds: int = 100, keep: int = 5,
|
| 320 |
min_ratio: float = 0.8, force: bool = False, index: bool = True, dry_run: bool = False,
|
| 321 |
-
out: Path | None = None) -> dict[str, Any]:
|
| 322 |
"""One indexer run (see the module docstring). `rows` stands in for the Hub listing (tests). Raises IndexerError
|
| 323 |
(or anything else) without publishing."""
|
| 324 |
from . import catalog, config, snapshot
|
|
@@ -330,17 +333,18 @@ def run(store, *, rows: list[dict[str, Any]] | None = None, budget_s: float = 18
|
|
| 330 |
_ev("pointer_unreadable", error=str(e))
|
| 331 |
prev = None
|
| 332 |
rows = rows if rows is not None else fetch_listing()
|
|
|
|
| 333 |
rows = [r for r in rows if not r.get("private") and not r.get("gated")]
|
| 334 |
if not force:
|
| 335 |
guard(rows, prev, min_ratio)
|
| 336 |
# catalog's own readers (environments, featured lookups) see this listing, and the app's fallback path finds it
|
| 337 |
with catalog._listing_lock:
|
| 338 |
-
catalog._env_list.update(rows=rows, at=
|
| 339 |
if not dry_run:
|
| 340 |
import gzip
|
| 341 |
|
| 342 |
catalog.atomic_write(config.STORAGE_DIR / "listing.json.gz",
|
| 343 |
-
gzip.compress(json.dumps({"at":
|
| 344 |
report: dict[str, Any] = {"listing": len(rows)}
|
| 345 |
state = load_state()
|
| 346 |
if index and not dry_run:
|
|
|
|
| 231 |
th.join(timeout=max(60.0, deadline - time.time() + 120))
|
| 232 |
if th.is_alive():
|
| 233 |
out["abandoned"].append(spec)
|
| 234 |
+
# A bounded automatic worker exits after publication. Avoid retrying
|
| 235 |
+
# the same oversized build at the front of every subsequent batch.
|
| 236 |
+
state["failed"][spec] = {"sha": sha, "at": time.time(), "error": "Index build exceeded its time budget"}
|
| 237 |
_ev("build_abandoned", spec=spec, seconds=round(time.time() - t0))
|
| 238 |
continue
|
| 239 |
if "error" in result:
|
|
|
|
| 321 |
|
| 322 |
def run(store, *, rows: list[dict[str, Any]] | None = None, budget_s: float = 1800, max_builds: int = 100, keep: int = 5,
|
| 323 |
min_ratio: float = 0.8, force: bool = False, index: bool = True, dry_run: bool = False,
|
| 324 |
+
out: Path | None = None, listing_at: float | None = None) -> dict[str, Any]:
|
| 325 |
"""One indexer run (see the module docstring). `rows` stands in for the Hub listing (tests). Raises IndexerError
|
| 326 |
(or anything else) without publishing."""
|
| 327 |
from . import catalog, config, snapshot
|
|
|
|
| 333 |
_ev("pointer_unreadable", error=str(e))
|
| 334 |
prev = None
|
| 335 |
rows = rows if rows is not None else fetch_listing()
|
| 336 |
+
listing_at = listing_at if listing_at is not None else time.time()
|
| 337 |
rows = [r for r in rows if not r.get("private") and not r.get("gated")]
|
| 338 |
if not force:
|
| 339 |
guard(rows, prev, min_ratio)
|
| 340 |
# catalog's own readers (environments, featured lookups) see this listing, and the app's fallback path finds it
|
| 341 |
with catalog._listing_lock:
|
| 342 |
+
catalog._env_list.update(rows=rows, at=listing_at, full=True)
|
| 343 |
if not dry_run:
|
| 344 |
import gzip
|
| 345 |
|
| 346 |
catalog.atomic_write(config.STORAGE_DIR / "listing.json.gz",
|
| 347 |
+
gzip.compress(json.dumps({"at": listing_at, "full": True, "rows": rows}, default=str).encode()))
|
| 348 |
report: dict[str, Any] = {"listing": len(rows)}
|
| 349 |
state = load_state()
|
| 350 |
if index and not dry_run:
|
app/main.py
CHANGED
|
@@ -23,6 +23,7 @@ from pydantic import BaseModel, Field
|
|
| 23 |
|
| 24 |
from . import auth, catalog, config, endpoints, envs, http, mcp_bridge, models, runner, runtime, search_api, seo, settings, space_files, spaces_live, store, version
|
| 25 |
from .envs import registry
|
|
|
|
| 26 |
|
| 27 |
app = FastAPI(title="HF RL Explorer", docs_url="/api/docs")
|
| 28 |
app.include_router(auth.router)
|
|
@@ -50,6 +51,10 @@ def _shutdown() -> None:
|
|
| 50 |
"""Live rollouts' events to the store, and locally the capture proxy's tunnels closed: gradio starts `frpc` as a child
|
| 51 |
that outlives this process otherwise, and stale tunnels pile up across restarts."""
|
| 52 |
import sys
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
store.flush_due(force=True)
|
| 55 |
tunneling = sys.modules.get("gradio.tunneling")
|
|
@@ -63,9 +68,12 @@ def _shutdown() -> None:
|
|
| 63 |
@app.on_event("startup")
|
| 64 |
def _startup() -> None:
|
| 65 |
import threading
|
|
|
|
| 66 |
|
| 67 |
config.STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
| 68 |
config.INDEX_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
| 69 |
# rollouts whose worker is gone are marked interrupted by the watcher below, once they've been silent a while
|
| 70 |
threading.Thread(target=runner.watch_cancel_requests, daemon=True, name="cancel-requests").start()
|
| 71 |
if os.environ.get("RLX_WARM", "1") == "1":
|
|
@@ -157,8 +165,15 @@ def environments():
|
|
| 157 |
|
| 158 |
@app.get("/api/spaces/{org}/{name}")
|
| 159 |
def space_page(org: str, name: str):
|
| 160 |
-
"""
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
# ── live Spaces: status, wake/restart, Task API, playground ─────────────────
|
|
@@ -181,7 +196,7 @@ def space_live(org: str, name: str, fresh: bool = False):
|
|
| 181 |
with no MCP tools of its own (a reset/step environment) still has tools for an agent: `bridge_tools` are exactly what
|
| 182 |
this explorer's MCP bridge offers for it (reset, step with its typed action, state, its Task API), for the page."""
|
| 183 |
info = _live(spaces_live.probe, f"{org}/{name}", fresh)
|
| 184 |
-
if info.get("running") and
|
| 185 |
info = {**info, "bridge_tools": mcp_bridge._tools(info)}
|
| 186 |
return info
|
| 187 |
|
|
@@ -204,14 +219,14 @@ def space_restart(org: str, name: str, request: Request):
|
|
| 204 |
|
| 205 |
|
| 206 |
@app.get("/api/spaces/{org}/{name}/tasks")
|
| 207 |
-
def space_tasks(org: str, name: str, split: str = "", start: int = 0, stop: int = 20):
|
| 208 |
"""A page of tasks from the server's Task API (answer-like fields left out)."""
|
| 209 |
-
return _live(spaces_live.tasks, f"{org}/{name}", split, start, stop)
|
| 210 |
|
| 211 |
|
| 212 |
@app.get("/api/spaces/{org}/{name}/task")
|
| 213 |
-
def space_task(org: str, name: str, split: str = "", index: int = 0):
|
| 214 |
-
return _live(spaces_live.task, f"{org}/{name}", split, index)
|
| 215 |
|
| 216 |
|
| 217 |
class PlayIn(BaseModel):
|
|
@@ -335,9 +350,10 @@ def env_data(org: str, name: str, request: Request, ref: str, part: str):
|
|
| 335 |
def env_raw(org: str, name: str, request: Request, ref: str, f: str, download: bool = False):
|
| 336 |
"""One of a task's files as it is (an image, a page for a sandboxed frame). Never runs with this site's origin."""
|
| 337 |
data, media = _env(registry.raw, f"{org}/{name}", ref, f, _token(request))
|
| 338 |
-
|
|
|
|
| 339 |
return Response(data, media_type=media, headers={"Content-Security-Policy": "sandbox", "X-Content-Type-Options": "nosniff",
|
| 340 |
-
"Content-Disposition": f
|
| 341 |
|
| 342 |
|
| 343 |
@app.get("/api/env/{org}/{name}/random")
|
|
@@ -484,19 +500,19 @@ PUBLIC_FIELDS = ("id", "dataset", "path", "task_id", "title", "collection", "dif
|
|
| 484 |
|
| 485 |
def _shareable(r: dict) -> bool:
|
| 486 |
"""Shown to everyone: finished and graded, on a public dataset, and not taken off Community by an admin."""
|
| 487 |
-
return
|
| 488 |
-
and r["id"] not in set(settings.get("hidden_runs", [])))
|
| 489 |
|
| 490 |
|
| 491 |
def _public_view(r: dict) -> dict:
|
| 492 |
v = {k: r.get(k) for k in PUBLIC_FIELDS}
|
|
|
|
| 493 |
if r.get("endpoint"):
|
| 494 |
v["endpoint"] = {"custom": True}
|
| 495 |
return {**v, "visibility": "public"}
|
| 496 |
|
| 497 |
|
| 498 |
def _owner_view(r: dict) -> dict:
|
| 499 |
-
return {**r, "is_owner": True}
|
| 500 |
|
| 501 |
|
| 502 |
def _visible(request: Request, run_id: str) -> tuple[dict, bool]:
|
|
@@ -530,7 +546,7 @@ def my_runs(request: Request, d: str = "", p: str | None = None):
|
|
| 530 |
@app.get("/api/runs/{run_id}")
|
| 531 |
def run_detail(run_id: str, request: Request, after: int = 0):
|
| 532 |
r, owner = _visible(request, run_id)
|
| 533 |
-
if
|
| 534 |
from .mimo.runner import core as mimo
|
| 535 |
|
| 536 |
live = mimo.live(run_id)
|
|
@@ -570,85 +586,17 @@ def set_visibility(run_id: str, body: VisibilityIn, request: Request):
|
|
| 570 |
return {"run": _owner_view(store.update(run_id, visibility=body.visibility))}
|
| 571 |
|
| 572 |
|
| 573 |
-
DOMAINS = {"code": "Code", "webdev": "Webdev", "cyber": "Cyber", "music": "Music", "general": "General"}
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
def _group(r: dict) -> str:
|
| 577 |
-
"""Where a rollout's task belongs, for the leaderboard's columns: a MiMo domain, else its environment."""
|
| 578 |
-
if r.get("domain") in DOMAINS:
|
| 579 |
-
return DOMAINS[r["domain"]]
|
| 580 |
-
return (r.get("dataset") or "?").split("/")[-1]
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
def _served(r: dict) -> str:
|
| 584 |
-
return "own endpoint" if r.get("endpoint") else (r.get("provider") or "auto")
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
def _band(r: dict) -> str:
|
| 588 |
-
rw = r.get("reward")
|
| 589 |
-
if rw is None or r.get("status") != "done":
|
| 590 |
-
return "unscored"
|
| 591 |
-
return "full" if rw >= 0.999 else "zero" if rw <= 0 else "partial"
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
def _stats(runs: list[dict]) -> dict:
|
| 595 |
-
"""How the rollouts shown did: the spread of rewards, each model's mean, and each model in each group."""
|
| 596 |
-
scored = [r for r in runs if r.get("reward") is not None and r.get("status") == "done"]
|
| 597 |
-
hist = [0] * 10
|
| 598 |
-
for r in scored:
|
| 599 |
-
hist[min(9, max(0, int(float(r["reward"]) * 10)))] += 1
|
| 600 |
-
by: dict[str, list[float]] = {}
|
| 601 |
-
for r in scored:
|
| 602 |
-
by.setdefault(r.get("model") or "custom endpoint", []).append(float(r["reward"]))
|
| 603 |
-
models_ = sorted(({"model": m, "custom": any(x.get("endpoint") for x in runs if (x.get("model") or "custom endpoint") == m), "runs": len(v),
|
| 604 |
-
"mean": round(sum(v) / len(v), 4), "best": max(v), "full": sum(x >= 0.999 for x in v),
|
| 605 |
-
"solved": sum(x > 0 for x in v)} for m, v in by.items()), key=lambda x: (-x["mean"], -x["runs"]))
|
| 606 |
-
cells: dict[str, dict[str, list[float]]] = {}
|
| 607 |
-
for r in scored:
|
| 608 |
-
cells.setdefault(r.get("model") or "custom endpoint", {}).setdefault(_group(r), []).append(float(r["reward"]))
|
| 609 |
-
matrix = {m: {g: {"mean": round(sum(v) / len(v), 4), "runs": len(v)} for g, v in gs.items()} for m, gs in cells.items()}
|
| 610 |
-
return {"runs": len(runs), "scored": len(scored), "mean": round(sum(float(r["reward"]) for r in scored) / len(scored), 4) if scored else None,
|
| 611 |
-
"full": sum(float(r["reward"]) >= 0.999 for r in scored), "histogram": hist, "by_model": models_, "models": models_, "matrix": matrix,
|
| 612 |
-
"datasets": len({r.get("dataset") for r in runs}), "tasks": len({(r.get("dataset"), r.get("path")) for r in runs})}
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
def _counts(runs: list[dict], f) -> list[list]:
|
| 616 |
-
c: dict[str, int] = {}
|
| 617 |
-
for r in runs:
|
| 618 |
-
v = f(r)
|
| 619 |
-
if v:
|
| 620 |
-
c[v] = c.get(v, 0) + 1
|
| 621 |
-
return [[k, n] for k, n in sorted(c.items(), key=lambda kv: -kv[1])]
|
| 622 |
-
|
| 623 |
-
|
| 624 |
@app.get("/api/community")
|
| 625 |
def community(dataset: str = "", group: str = "", model: str = "", served: str = "", judge: str = "", reward: str = "", thinking: str = "",
|
| 626 |
runner: str = "", q: str = "", sort: str = "new", d: str = "", p: str | None = None, limit: int = 100, offset: int = 0):
|
| 627 |
-
"""
|
| 628 |
-
model does, overall and in each environment (MiMo's domains count as environments of their own)."""
|
| 629 |
everyone = [r for r in store.list_runs(public=True, limit=100000) if _shareable(r)]
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
and (not thinking or ((r.get("params") or {}).get("thinking") or "default") == thinking)
|
| 637 |
-
and (not runner or (r.get("runner") or "harbor") == runner)
|
| 638 |
-
and (not q or q.lower() in f"{r.get('title', '')} {r.get('task_id', '')} {r.get('path', '')}".lower()))
|
| 639 |
-
runs = [r for r in everyone if keep(r)]
|
| 640 |
-
key = {"reward_desc": lambda r: -(r.get("reward") if r.get("reward") is not None else -1),
|
| 641 |
-
"reward_asc": lambda r: (r.get("reward") if r.get("reward") is not None else 2),
|
| 642 |
-
"cost_asc": lambda r: (r.get("cost") or {}).get("total", 0), "cost_desc": lambda r: -(r.get("cost") or {}).get("total", 0)}.get(sort)
|
| 643 |
-
if key:
|
| 644 |
-
runs.sort(key=key)
|
| 645 |
-
facets = {"group": _counts(everyone, _group), "dataset": _counts(everyone, lambda r: r.get("dataset")),
|
| 646 |
-
"model": _counts(everyone, lambda r: r.get("model")), "served": _counts(everyone, _served), "judge": _counts(everyone, lambda r: r.get("judge")),
|
| 647 |
-
"reward": _counts(everyone, _band), "thinking": _counts(everyone, lambda r: (r.get("params") or {}).get("thinking") or "default"),
|
| 648 |
-
"runner": _counts(everyone, lambda r: r.get("runner") or "harbor")}
|
| 649 |
-
page = runs[max(0, offset): max(0, offset) + max(1, min(limit, 500))]
|
| 650 |
-
return {"stats": _stats(runs), "facets": facets, "total": len(runs), "tasks": len({(r.get("dataset"), r.get("path")) for r in runs}),
|
| 651 |
-
"runs": [{**_public_view(r), "group": _group(r), "shot": r.get("domain") == "webdev" and store.has_artifact(r["id"], "screenshot.jpg")} for r in page]}
|
| 652 |
|
| 653 |
|
| 654 |
@app.get("/api/community/tasks")
|
|
@@ -664,7 +612,7 @@ def community_tasks(dataset: str):
|
|
| 664 |
if r.get("reward") is not None:
|
| 665 |
t["scored"] += 1
|
| 666 |
t["sum"] += float(r["reward"])
|
| 667 |
-
t["best"] = max(t["best"] if t["best"] is not None else
|
| 668 |
return {"tasks": {k: {"runs": v["runs"], "mean": round(v["sum"] / v["scored"], 4) if v["scored"] else None, "best": v["best"], "last": v["last"],
|
| 669 |
"title": v["title"]} for k, v in out.items()}}
|
| 670 |
|
|
@@ -692,6 +640,14 @@ def run_artifact(run_id: str, name: str, request: Request):
|
|
| 692 |
LLM_REPLY_MAX = 64 * 1024 * 1024
|
| 693 |
|
| 694 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
@app.post("/api/llm/{cap}/v1/chat/completions")
|
| 696 |
async def llm_proxy(cap: str, request: Request):
|
| 697 |
import asyncio
|
|
@@ -712,6 +668,8 @@ async def llm_proxy(cap: str, request: Request):
|
|
| 712 |
body = json.loads(raw)
|
| 713 |
except ValueError:
|
| 714 |
return JSONResponse({"error": {"message": "invalid JSON"}}, 400)
|
|
|
|
|
|
|
| 715 |
up = r.upstream(str(body.get("model") or ""))
|
| 716 |
if up is None:
|
| 717 |
return JSONResponse({"error": {"message": "this rollout may not call that model"}}, 403)
|
|
|
|
| 23 |
|
| 24 |
from . import auth, catalog, config, endpoints, envs, http, mcp_bridge, models, runner, runtime, search_api, seo, settings, space_files, spaces_live, store, version
|
| 25 |
from .envs import registry
|
| 26 |
+
from . import community as community_data
|
| 27 |
|
| 28 |
app = FastAPI(title="HF RL Explorer", docs_url="/api/docs")
|
| 29 |
app.include_router(auth.router)
|
|
|
|
| 51 |
"""Live rollouts' events to the store, and locally the capture proxy's tunnels closed: gradio starts `frpc` as a child
|
| 52 |
that outlives this process otherwise, and stale tunnels pile up across restarts."""
|
| 53 |
import sys
|
| 54 |
+
from . import auto_index, space_checks
|
| 55 |
+
|
| 56 |
+
space_checks.stop()
|
| 57 |
+
auto_index.stop()
|
| 58 |
|
| 59 |
store.flush_due(force=True)
|
| 60 |
tunneling = sys.modules.get("gradio.tunneling")
|
|
|
|
| 68 |
@app.on_event("startup")
|
| 69 |
def _startup() -> None:
|
| 70 |
import threading
|
| 71 |
+
from . import auto_index, space_checks
|
| 72 |
|
| 73 |
config.STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
| 74 |
config.INDEX_DIR.mkdir(parents=True, exist_ok=True)
|
| 75 |
+
space_checks.start()
|
| 76 |
+
auto_index.start()
|
| 77 |
# rollouts whose worker is gone are marked interrupted by the watcher below, once they've been silent a while
|
| 78 |
threading.Thread(target=runner.watch_cancel_requests, daemon=True, name="cancel-requests").start()
|
| 79 |
if os.environ.get("RLX_WARM", "1") == "1":
|
|
|
|
| 165 |
|
| 166 |
@app.get("/api/spaces/{org}/{name}")
|
| 167 |
def space_page(org: str, name: str):
|
| 168 |
+
"""Repository metadata is distinct from verified live OpenEnv support."""
|
| 169 |
+
from . import space_checks
|
| 170 |
+
source = _hub_errors(catalog.space, f"{org}/{name}")
|
| 171 |
+
check = space_checks.inventory().get(f"{org}/{name}", {})
|
| 172 |
+
verified = space_checks.verified(check)
|
| 173 |
+
return {**source, "declared_openenv": bool(source.get("openenv")), "openenv": verified,
|
| 174 |
+
"framework": "openenv" if verified else "ors" if source.get("framework") == "ors" else "space",
|
| 175 |
+
"badges": [b for b in source.get("badges", []) if verified or b != "OpenEnv"],
|
| 176 |
+
"api_status": space_checks.status(check)}
|
| 177 |
|
| 178 |
|
| 179 |
# ── live Spaces: status, wake/restart, Task API, playground ─────────────────
|
|
|
|
| 196 |
with no MCP tools of its own (a reset/step environment) still has tools for an agent: `bridge_tools` are exactly what
|
| 197 |
this explorer's MCP bridge offers for it (reset, step with its typed action, state, its Task API), for the page."""
|
| 198 |
info = _live(spaces_live.probe, f"{org}/{name}", fresh)
|
| 199 |
+
if info.get("running") and (info.get("mcp") or info.get("step_api") or info.get("api") or info.get("task_api")):
|
| 200 |
info = {**info, "bridge_tools": mcp_bridge._tools(info)}
|
| 201 |
return info
|
| 202 |
|
|
|
|
| 219 |
|
| 220 |
|
| 221 |
@app.get("/api/spaces/{org}/{name}/tasks")
|
| 222 |
+
def space_tasks(org: str, name: str, split: str = "", start: int = 0, stop: int = 20, env: str = ""):
|
| 223 |
"""A page of tasks from the server's Task API (answer-like fields left out)."""
|
| 224 |
+
return _live(spaces_live.tasks, f"{org}/{name}", split, start, stop, env)
|
| 225 |
|
| 226 |
|
| 227 |
@app.get("/api/spaces/{org}/{name}/task")
|
| 228 |
+
def space_task(org: str, name: str, split: str = "", index: int = 0, env: str = ""):
|
| 229 |
+
return _live(spaces_live.task, f"{org}/{name}", split, index, env)
|
| 230 |
|
| 231 |
|
| 232 |
class PlayIn(BaseModel):
|
|
|
|
| 350 |
def env_raw(org: str, name: str, request: Request, ref: str, f: str, download: bool = False):
|
| 351 |
"""One of a task's files as it is (an image, a page for a sandboxed frame). Never runs with this site's origin."""
|
| 352 |
data, media = _env(registry.raw, f"{org}/{name}", ref, f, _token(request))
|
| 353 |
+
# RFC 5987 handles Unicode and keeps control characters out of HTTP headers.
|
| 354 |
+
fname = quote(f.rsplit("/", 1)[-1], safe="")
|
| 355 |
return Response(data, media_type=media, headers={"Content-Security-Policy": "sandbox", "X-Content-Type-Options": "nosniff",
|
| 356 |
+
"Content-Disposition": f"{'attachment' if download else 'inline'}; filename*=UTF-8''{fname}"})
|
| 357 |
|
| 358 |
|
| 359 |
@app.get("/api/env/{org}/{name}/random")
|
|
|
|
| 500 |
|
| 501 |
def _shareable(r: dict) -> bool:
|
| 502 |
"""Shown to everyone: finished and graded, on a public dataset, and not taken off Community by an admin."""
|
| 503 |
+
return community_data.is_public(r, set(settings.get("hidden_runs", [])))
|
|
|
|
| 504 |
|
| 505 |
|
| 506 |
def _public_view(r: dict) -> dict:
|
| 507 |
v = {k: r.get(k) for k in PUBLIC_FIELDS}
|
| 508 |
+
v["runner"] = community_data.runner(r)
|
| 509 |
if r.get("endpoint"):
|
| 510 |
v["endpoint"] = {"custom": True}
|
| 511 |
return {**v, "visibility": "public"}
|
| 512 |
|
| 513 |
|
| 514 |
def _owner_view(r: dict) -> dict:
|
| 515 |
+
return {**r, "runner": community_data.runner(r), "is_owner": True}
|
| 516 |
|
| 517 |
|
| 518 |
def _visible(request: Request, run_id: str) -> tuple[dict, bool]:
|
|
|
|
| 546 |
@app.get("/api/runs/{run_id}")
|
| 547 |
def run_detail(run_id: str, request: Request, after: int = 0):
|
| 548 |
r, owner = _visible(request, run_id)
|
| 549 |
+
if community_data.runner(r) in ("mimo", "nemo-gym"): # event-based hosted runners
|
| 550 |
from .mimo.runner import core as mimo
|
| 551 |
|
| 552 |
live = mimo.live(run_id)
|
|
|
|
| 586 |
return {"run": _owner_view(store.update(run_id, visibility=body.visibility))}
|
| 587 |
|
| 588 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
@app.get("/api/community")
|
| 590 |
def community(dataset: str = "", group: str = "", model: str = "", served: str = "", judge: str = "", reward: str = "", thinking: str = "",
|
| 591 |
runner: str = "", q: str = "", sort: str = "new", d: str = "", p: str | None = None, limit: int = 100, offset: int = 0):
|
| 592 |
+
"""Finished, graded public rollouts from any saved runner, without account identities or private endpoint locations."""
|
|
|
|
| 593 |
everyone = [r for r in store.list_runs(public=True, limit=100000) if _shareable(r)]
|
| 594 |
+
filters = {"dataset": dataset or d, "path": p, "group": group, "model": model, "served": served, "judge": judge,
|
| 595 |
+
"reward": reward, "thinking": thinking, "runner": runner, "q": q[:200]}
|
| 596 |
+
out = community_data.query(everyone, filters, sort=sort, offset=offset, limit=limit)
|
| 597 |
+
out["runs"] = [{**_public_view(r), "runner": community_data.runner(r), "group": community_data.group(r),
|
| 598 |
+
"shot": r.get("domain") == "webdev" and store.has_artifact(r["id"], "screenshot.jpg")} for r in out["runs"]]
|
| 599 |
+
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
|
| 601 |
|
| 602 |
@app.get("/api/community/tasks")
|
|
|
|
| 612 |
if r.get("reward") is not None:
|
| 613 |
t["scored"] += 1
|
| 614 |
t["sum"] += float(r["reward"])
|
| 615 |
+
t["best"] = max(t["best"], float(r["reward"])) if t["best"] is not None else float(r["reward"])
|
| 616 |
return {"tasks": {k: {"runs": v["runs"], "mean": round(v["sum"] / v["scored"], 4) if v["scored"] else None, "best": v["best"], "last": v["last"],
|
| 617 |
"title": v["title"]} for k, v in out.items()}}
|
| 618 |
|
|
|
|
| 640 |
LLM_REPLY_MAX = 64 * 1024 * 1024
|
| 641 |
|
| 642 |
|
| 643 |
+
@app.get("/api/llm/{cap}/health")
|
| 644 |
+
def llm_health(cap: str):
|
| 645 |
+
from .mimo.runner import core as events
|
| 646 |
+
if events.by_cap(cap) is None:
|
| 647 |
+
raise HTTPException(404, "unknown or finished rollout")
|
| 648 |
+
return {"status": "ok"}
|
| 649 |
+
|
| 650 |
+
|
| 651 |
@app.post("/api/llm/{cap}/v1/chat/completions")
|
| 652 |
async def llm_proxy(cap: str, request: Request):
|
| 653 |
import asyncio
|
|
|
|
| 668 |
body = json.loads(raw)
|
| 669 |
except ValueError:
|
| 670 |
return JSONResponse({"error": {"message": "invalid JSON"}}, 400)
|
| 671 |
+
if not isinstance(body, dict):
|
| 672 |
+
return JSONResponse({"error": {"message": "JSON object required"}}, 400)
|
| 673 |
up = r.upstream(str(body.get("model") or ""))
|
| 674 |
if up is None:
|
| 675 |
return JSONResponse({"error": {"message": "this rollout may not call that model"}}, 403)
|
app/mcp_bridge.py
CHANGED
|
@@ -51,6 +51,9 @@ def _limited(owner: str) -> bool:
|
|
| 51 |
now = time.time()
|
| 52 |
with _lock:
|
| 53 |
hits = [t for t in _hits.get(owner, []) if now - t < RATE[1]]
|
|
|
|
|
|
|
|
|
|
| 54 |
hits.append(now)
|
| 55 |
_hits[owner] = hits
|
| 56 |
if len(_hits) > 5000:
|
|
@@ -84,19 +87,31 @@ def _tools(info: dict) -> list[dict[str, Any]]:
|
|
| 84 |
]
|
| 85 |
# a tool server takes its actions as tool calls: it gets reset and state, not step
|
| 86 |
tools += [t for t in episode if not (names and t["name"] == "step")]
|
| 87 |
-
#
|
| 88 |
-
if
|
| 89 |
taken = {t["name"] for t in tools}
|
| 90 |
for r in info["api"]:
|
| 91 |
name = _route_tool(r)
|
| 92 |
if name in taken:
|
| 93 |
continue
|
| 94 |
taken.add(name)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
props
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
tools.append({"name": name, "description": f"{r['method']} {r['path']}" + (f": {r['summary']}" if r["summary"] else ""),
|
| 99 |
-
"inputSchema": {"type": "object", "properties": props, "required":
|
| 100 |
if info.get("task_api"):
|
| 101 |
extra = [
|
| 102 |
{"name": "list_splits", "description": "The server's task splits and how many tasks each holds.", "inputSchema": {"type": "object", "properties": {}}},
|
|
@@ -163,11 +178,12 @@ def _call(spec: str, info: dict, client: dict, name: str, args: dict) -> dict[st
|
|
| 163 |
if name == "list_tasks":
|
| 164 |
return _content(live.tasks(spec, str(args.get("split", "")), int(args.get("start", 0) or 0), int(args.get("stop", 20) or 20)))
|
| 165 |
return _content(live.task(spec, str(args.get("split", "")), int(args.get("index", 0) or 0)))
|
| 166 |
-
route = next((r for r in info.get("api") or [] if _route_tool(r) == name), None)
|
| 167 |
if route is not None:
|
| 168 |
-
params =
|
| 169 |
-
|
| 170 |
-
|
|
|
|
| 171 |
payload = out.get("json", out.get("text"))
|
| 172 |
result = _content(payload if payload is not None else {"status": out["status"]})
|
| 173 |
if out["status"] >= 400:
|
|
|
|
| 51 |
now = time.time()
|
| 52 |
with _lock:
|
| 53 |
hits = [t for t in _hits.get(owner, []) if now - t < RATE[1]]
|
| 54 |
+
if len(hits) >= RATE[0]:
|
| 55 |
+
_hits[owner] = hits
|
| 56 |
+
return True
|
| 57 |
hits.append(now)
|
| 58 |
_hits[owner] = hits
|
| 59 |
if len(_hits) > 5000:
|
|
|
|
| 87 |
]
|
| 88 |
# a tool server takes its actions as tool calls: it gets reset and state, not step
|
| 89 |
tools += [t for t in episode if not (names and t["name"] == "step")]
|
| 90 |
+
# Keep published grading/seed routes alongside native tools, without naming collisions.
|
| 91 |
+
if info.get("api"):
|
| 92 |
taken = {t["name"] for t in tools}
|
| 93 |
for r in info["api"]:
|
| 94 |
name = _route_tool(r)
|
| 95 |
if name in taken:
|
| 96 |
continue
|
| 97 |
taken.add(name)
|
| 98 |
+
# Separate request locations: a body key named 'id' or 'query' must
|
| 99 |
+
# not overwrite a path/query parameter with the same name.
|
| 100 |
+
props, required = {}, []
|
| 101 |
+
if r["params"]:
|
| 102 |
+
props["path_params"] = {"type": "object", "properties": {p: {"type": "string"} for p in r["params"]},
|
| 103 |
+
"required": r["params"], "additionalProperties": False}
|
| 104 |
+
required.append("path_params")
|
| 105 |
+
if (r.get("query_schema") or {}).get("properties"):
|
| 106 |
+
props["query"] = r["query_schema"]
|
| 107 |
+
if r["query_schema"].get("required"):
|
| 108 |
+
required.append("query")
|
| 109 |
+
if r["method"] == "POST":
|
| 110 |
+
props["body"] = r.get("schema") or {"type": "object"}
|
| 111 |
+
if r.get("body_required"):
|
| 112 |
+
required.append("body")
|
| 113 |
tools.append({"name": name, "description": f"{r['method']} {r['path']}" + (f": {r['summary']}" if r["summary"] else ""),
|
| 114 |
+
"inputSchema": {"type": "object", "properties": props, "required": required, "additionalProperties": False}})
|
| 115 |
if info.get("task_api"):
|
| 116 |
extra = [
|
| 117 |
{"name": "list_splits", "description": "The server's task splits and how many tasks each holds.", "inputSchema": {"type": "object", "properties": {}}},
|
|
|
|
| 178 |
if name == "list_tasks":
|
| 179 |
return _content(live.tasks(spec, str(args.get("split", "")), int(args.get("start", 0) or 0), int(args.get("stop", 20) or 20)))
|
| 180 |
return _content(live.task(spec, str(args.get("split", "")), int(args.get("index", 0) or 0)))
|
| 181 |
+
route = next((r for r in info.get("api") or [] if _route_tool(r) == name), None)
|
| 182 |
if route is not None:
|
| 183 |
+
params = args.get("path_params") if isinstance(args.get("path_params"), dict) else {}
|
| 184 |
+
query = args.get("query") if isinstance(args.get("query"), dict) else {}
|
| 185 |
+
body = args.get("body") if route["method"] == "POST" else None
|
| 186 |
+
out = live.act(_play(spec, client), client["owner"], "http", {"method": route["method"], "path": route["path"], "params": params, "query": query, "body": body})["result"]
|
| 187 |
payload = out.get("json", out.get("text"))
|
| 188 |
result = _content(payload if payload is not None else {"status": out["status"]})
|
| 189 |
if out["status"] >= 400:
|
app/mimo/__init__.py
CHANGED
|
@@ -3,7 +3,7 @@ every one of its 7,780 tasks browsable in full (brief, agent prompt, systems and
|
|
| 3 |
repository, the grader with its judge prompts), and rollouts on its own harness: OpenCode on an HF Sandbox, graded by
|
| 4 |
Xiaomi's graders (hidden tests, a fuzz crash, rubric checks with a judge model, a screenshot judge, the music scorer).
|
| 5 |
|
| 6 |
-
The code is the MiMo explorer's (mimo-explorer/app), kept close to it so the two can be compared and synced:
|
| 7 |
catalog.py the task views and what the runner needs (data/: the browsing index, built by build_data.py)
|
| 8 |
judges.py the judge prompts, pinned
|
| 9 |
previews.py workspace file previews (spreadsheets, documents, slides, PDFs)
|
|
|
|
| 3 |
repository, the grader with its judge prompts), and rollouts on its own harness: OpenCode on an HF Sandbox, graded by
|
| 4 |
Xiaomi's graders (hidden tests, a fuzz crash, rubric checks with a judge model, a screenshot judge, the music scorer).
|
| 5 |
|
| 6 |
+
The code is the MiMo explorer's (tooling/mimo-rl-explorer/app), kept close to it so the two can be compared and synced:
|
| 7 |
catalog.py the task views and what the runner needs (data/: the browsing index, built by build_data.py)
|
| 8 |
judges.py the judge prompts, pinned
|
| 9 |
previews.py workspace file previews (spreadsheets, documents, slides, PDFs)
|
app/mimo/runner/core.py
CHANGED
|
@@ -137,7 +137,7 @@ class Rollout:
|
|
| 137 |
self.update(flavor=flavor)
|
| 138 |
t = time.time()
|
| 139 |
self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
|
| 140 |
-
forward_hf_token=
|
| 141 |
labels={"app": "rl-explorer", "runner": "mimo", "run": self.id})
|
| 142 |
self.update(sandbox_id=self.sandbox.id)
|
| 143 |
self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
|
|
@@ -152,8 +152,30 @@ class Rollout:
|
|
| 152 |
return config.ROUTER, self.token, mid
|
| 153 |
|
| 154 |
def llm_base(self) -> str | None:
|
| 155 |
-
"""
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
def upstream(self, model: str) -> tuple[str, str | None] | None:
|
| 159 |
"""For the model proxy: (base URL, key) for a model this rollout may call, else None."""
|
|
@@ -193,12 +215,16 @@ class Rollout:
|
|
| 193 |
def execute(self) -> None:
|
| 194 |
from .domains import ADAPTERS
|
| 195 |
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
| 197 |
try:
|
| 198 |
self.update(status="starting", started_at=time.time())
|
| 199 |
self.settle_model()
|
| 200 |
result = adapter.run(self)
|
| 201 |
-
self.update(status="done", reward=result.get("reward"), reward_error=result.get("error"),
|
|
|
|
| 202 |
finished_at=time.time(), cost=self.cost_now())
|
| 203 |
self.phase("done", "done", f"reward {result.get('reward')}" if result.get("reward") is not None
|
| 204 |
else f"not scored: {result.get('error')}")
|
|
@@ -217,10 +243,12 @@ class Rollout:
|
|
| 217 |
self._flush()
|
| 218 |
self.stop_sandbox()
|
| 219 |
self.agent_key = None
|
|
|
|
| 220 |
with _live_lock:
|
| 221 |
_caps.pop(self.cap, None)
|
| 222 |
with _live_lock:
|
| 223 |
_live.pop(self.id, None)
|
|
|
|
| 224 |
|
| 225 |
def stop_sandbox(self) -> None:
|
| 226 |
if self.sandbox is None:
|
|
@@ -270,11 +298,12 @@ def submit(user: str, token: str, task: dict, model: str, provider: str | None,
|
|
| 270 |
"facets": task.get("facets"), "model": model, "provider": provider, "judge": judge,
|
| 271 |
"status": "queued", "harness": "opencode", "endpoint": endpoint, "params": params or {},
|
| 272 |
"visibility": "private" if visibility == "private" else "public",
|
| 273 |
-
"image": catalog.image_for(task["id"]) if task["domain"]
|
| 274 |
"provenance": version.provenance(),
|
| 275 |
**(extra or {}),
|
| 276 |
})
|
| 277 |
r = Rollout(run, token, agent_key)
|
|
|
|
| 278 |
with _live_lock:
|
| 279 |
_live[r.id] = r
|
| 280 |
_caps[r.cap] = r
|
|
|
|
| 137 |
self.update(flavor=flavor)
|
| 138 |
t = time.time()
|
| 139 |
self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
|
| 140 |
+
forward_hf_token=False, token=self.token, start_timeout=900,
|
| 141 |
labels={"app": "rl-explorer", "runner": "mimo", "run": self.id})
|
| 142 |
self.update(sandbox_id=self.sandbox.id)
|
| 143 |
self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
|
|
|
|
| 152 |
return config.ROUTER, self.token, mid
|
| 153 |
|
| 154 |
def llm_base(self) -> str | None:
|
| 155 |
+
"""Only a per-run capability reaches the sandbox, including local previews."""
|
| 156 |
+
if config.PUBLIC_URL:
|
| 157 |
+
return f"{config.PUBLIC_URL}/api/llm/{self.cap}/v1"
|
| 158 |
+
from ...runner import _proxy_url
|
| 159 |
+
return f"{_proxy_url().rstrip('/')}/rlx/llm/{self.cap}/v1"
|
| 160 |
+
|
| 161 |
+
def check_model_network(self):
|
| 162 |
+
"""Check the exact relay path from the sandbox, without a model call."""
|
| 163 |
+
url = self.llm_base().removesuffix("/v1") + "/health"
|
| 164 |
+
script = ("import urllib.request,urllib.error\n"
|
| 165 |
+
f"url={url!r}\n"
|
| 166 |
+
"try:\n"
|
| 167 |
+
" r=urllib.request.urlopen(url,timeout=15)\n"
|
| 168 |
+
" print('relay HTTP',r.status)\n"
|
| 169 |
+
"except urllib.error.HTTPError as e:\n"
|
| 170 |
+
" print('relay HTTP',e.code);raise SystemExit(1)\n"
|
| 171 |
+
"except Exception as e:\n"
|
| 172 |
+
" print(type(e).__name__);raise SystemExit(1)\n")
|
| 173 |
+
import shlex
|
| 174 |
+
result = self.sh("python3 -c " + shlex.quote(script), timeout=25)
|
| 175 |
+
if result.exit_code:
|
| 176 |
+
raise RuntimeError("The sandbox cannot reach the model relay. Check HTTPS egress from the sandbox and ingress to the explorer. "
|
| 177 |
+
"No account token was forwarded. " + (result.stdout or "")[-120:])
|
| 178 |
+
self.log("Network check passed: sandbox → model relay. HF account token stays on the explorer.")
|
| 179 |
|
| 180 |
def upstream(self, model: str) -> tuple[str, str | None] | None:
|
| 181 |
"""For the model proxy: (base URL, key) for a model this rollout may call, else None."""
|
|
|
|
| 215 |
def execute(self) -> None:
|
| 216 |
from .domains import ADAPTERS
|
| 217 |
|
| 218 |
+
if self.run.get("runner") == "nemo-gym":
|
| 219 |
+
from ... import nemo_runner as adapter
|
| 220 |
+
else:
|
| 221 |
+
adapter = ADAPTERS[self.run["domain"]]
|
| 222 |
try:
|
| 223 |
self.update(status="starting", started_at=time.time())
|
| 224 |
self.settle_model()
|
| 225 |
result = adapter.run(self)
|
| 226 |
+
self.update(status="done", phase="done", reward=result.get("reward"), reward_error=result.get("error"),
|
| 227 |
+
graded=result.get("reward") is not None, wall_s=round(time.time() - self.t0, 2),
|
| 228 |
finished_at=time.time(), cost=self.cost_now())
|
| 229 |
self.phase("done", "done", f"reward {result.get('reward')}" if result.get("reward") is not None
|
| 230 |
else f"not scored: {result.get('error')}")
|
|
|
|
| 243 |
self._flush()
|
| 244 |
self.stop_sandbox()
|
| 245 |
self.agent_key = None
|
| 246 |
+
self.native_task = None
|
| 247 |
with _live_lock:
|
| 248 |
_caps.pop(self.cap, None)
|
| 249 |
with _live_lock:
|
| 250 |
_live.pop(self.id, None)
|
| 251 |
+
self.token = ""
|
| 252 |
|
| 253 |
def stop_sandbox(self) -> None:
|
| 254 |
if self.sandbox is None:
|
|
|
|
| 298 |
"facets": task.get("facets"), "model": model, "provider": provider, "judge": judge,
|
| 299 |
"status": "queued", "harness": "opencode", "endpoint": endpoint, "params": params or {},
|
| 300 |
"visibility": "private" if visibility == "private" else "public",
|
| 301 |
+
"image": catalog.image_for(task["id"]) if task["domain"] not in ("music", "nemo-gym") else None,
|
| 302 |
"provenance": version.provenance(),
|
| 303 |
**(extra or {}),
|
| 304 |
})
|
| 305 |
r = Rollout(run, token, agent_key)
|
| 306 |
+
r.native_task = task.get("native_task") # reference action stays in memory; never in the run record
|
| 307 |
with _live_lock:
|
| 308 |
_live[r.id] = r
|
| 309 |
_caps[r.cap] = r
|
app/mimo/runner/opencode.py
CHANGED
|
@@ -119,6 +119,7 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
|
|
| 119 |
steps = params.get("steps") or steps
|
| 120 |
timeout = params["timeout_min"] * 60 if params.get("timeout_min") else timeout
|
| 121 |
proxy = r.llm_base()
|
|
|
|
| 122 |
cfg = config_for(r.run["model"], r.run.get("provider"), steps, mcp, ep, params, proxy)
|
| 123 |
r.sandbox.files.write("/tmp/opencode.json", json.dumps(cfg))
|
| 124 |
r.sandbox.files.write("/tmp/prompt.txt", prompt)
|
|
@@ -166,6 +167,10 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
|
|
| 166 |
r.sandbox.run("rm -f /root/.agent_key", shell=True, check=False, timeout=30)
|
| 167 |
if buf[0].strip():
|
| 168 |
_event(r, buf[0], texts)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
if res.timed_out:
|
| 170 |
r.emit("error", text=f"The agent hit the {int(timeout // 60)}-minute limit; grading what it left.")
|
| 171 |
return texts[-1] if texts else ""
|
|
@@ -209,8 +214,8 @@ def _event(r, line: str, texts: list[str]) -> None:
|
|
| 209 |
"so OpenCode ends the session there, as it does in Xiaomi's harness.")
|
| 210 |
elif typ == "error":
|
| 211 |
err = ev.get("error") or part
|
| 212 |
-
r.
|
| 213 |
-
|
| 214 |
|
| 215 |
|
| 216 |
def _scripted(r, cwd: str, user: str | None) -> str:
|
|
|
|
| 119 |
steps = params.get("steps") or steps
|
| 120 |
timeout = params["timeout_min"] * 60 if params.get("timeout_min") else timeout
|
| 121 |
proxy = r.llm_base()
|
| 122 |
+
r.check_model_network()
|
| 123 |
cfg = config_for(r.run["model"], r.run.get("provider"), steps, mcp, ep, params, proxy)
|
| 124 |
r.sandbox.files.write("/tmp/opencode.json", json.dumps(cfg))
|
| 125 |
r.sandbox.files.write("/tmp/prompt.txt", prompt)
|
|
|
|
| 167 |
r.sandbox.run("rm -f /root/.agent_key", shell=True, check=False, timeout=30)
|
| 168 |
if buf[0].strip():
|
| 169 |
_event(r, buf[0], texts)
|
| 170 |
+
if getattr(r, "agent_failure", None):
|
| 171 |
+
raise RuntimeError("The agent's model call failed; this run is not graded. " + r.agent_failure[:500])
|
| 172 |
+
if res.exit_code and not res.timed_out:
|
| 173 |
+
raise RuntimeError(f"The agent exited with code {res.exit_code}; this run is not graded.")
|
| 174 |
if res.timed_out:
|
| 175 |
r.emit("error", text=f"The agent hit the {int(timeout // 60)}-minute limit; grading what it left.")
|
| 176 |
return texts[-1] if texts else ""
|
|
|
|
| 214 |
"so OpenCode ends the session there, as it does in Xiaomi's harness.")
|
| 215 |
elif typ == "error":
|
| 216 |
err = ev.get("error") or part
|
| 217 |
+
r.agent_failure = ((err.get("data") or {}).get("message") or json.dumps(err)[:800]) if isinstance(err, dict) else str(err)[:800]
|
| 218 |
+
r.emit("error", text=r.agent_failure)
|
| 219 |
|
| 220 |
|
| 221 |
def _scripted(r, cwd: str, user: str | None) -> str:
|
app/nemo_runner.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-step NeMo Pivot prediction, scored by NVIDIA's unmodified comparator.
|
| 2 |
+
|
| 3 |
+
Tool definitions are prediction targets; tool calls are never executed. No task
|
| 4 |
+
code, model key, or reference action enters a sandbox. Other Gym agents require
|
| 5 |
+
their resources server and are intentionally outside this adapter's scope.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
import httpx
|
| 12 |
+
from pydantic import TypeAdapter
|
| 13 |
+
|
| 14 |
+
from .vendor.nemo_gym.verification_utils import (
|
| 15 |
+
ActionComparator, ExpectedAction, FunctionCallAction, FunctionCallBatchAction,
|
| 16 |
+
MessageAction, StepRewardCategory, ToolCallComparatorConfig,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
REVISION = "3ef478df1ee163134d32a3f291f0a9e5981d0e52"
|
| 20 |
+
CONFIGS = {
|
| 21 |
+
"resources_servers/single_step_tool_use_with_argument_comparison/configs/"
|
| 22 |
+
+ name + ".yaml" for name in (
|
| 23 |
+
"single_step_tool_use_with_argument_comparison",
|
| 24 |
+
"toolcall_schema_single_step_tool_use_with_argument_comparison",
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def supported(agent):
|
| 30 |
+
return agent.get("known") and agent.get("agent_type") == "tool_simulation_agent" and agent.get("config") in CONFIGS
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def strict_schema(schema):
|
| 34 |
+
if not isinstance(schema, dict):
|
| 35 |
+
return False
|
| 36 |
+
if schema.get("type") == "object" or "properties" in schema:
|
| 37 |
+
if schema.get("additionalProperties") is not False or set(schema.get("required") or []) != set(schema.get("properties") or {}):
|
| 38 |
+
return False
|
| 39 |
+
return all(strict_schema(v) for k, v in schema.items() if k in ("items", "additionalProperties") and isinstance(v, dict)) and all(
|
| 40 |
+
strict_schema(v) for v in (schema.get("properties") or {}).values()) and all(
|
| 41 |
+
strict_schema(v) for k in ("anyOf", "oneOf", "allOf") for v in schema.get(k, []))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def relaxed_tools(row):
|
| 45 |
+
return [t["name"] for t in (row.get("responses_create_params") or {}).get("tools", [])
|
| 46 |
+
if isinstance(t, dict) and t.get("name") and t.get("strict") is True and not strict_schema(t.get("parameters"))]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def responses_request(row):
|
| 50 |
+
"""Keep NeMo's native Responses input, including prior reasoning and tool turns.
|
| 51 |
+
|
| 52 |
+
Only function definitions are allowed: provider-hosted MCP, web search and
|
| 53 |
+
code execution would change a prediction task into a side-effecting agent.
|
| 54 |
+
"""
|
| 55 |
+
rcp = row.get("responses_create_params")
|
| 56 |
+
if not isinstance(rcp, dict) or not rcp.get("input"):
|
| 57 |
+
raise ValueError("This row has no NeMo Gym Responses request")
|
| 58 |
+
if len(json.dumps(rcp)) > 200_000:
|
| 59 |
+
raise ValueError("This task's model input exceeds the hosted limit")
|
| 60 |
+
raw = rcp["input"]
|
| 61 |
+
if not isinstance(raw, (str, list)) or isinstance(raw, list) and len(raw) > 500:
|
| 62 |
+
raise ValueError("This row needs the native NeMo Gym runtime")
|
| 63 |
+
tools = rcp.get("tools") or []
|
| 64 |
+
if not isinstance(tools, list) or len(tools) > 128 or not all(
|
| 65 |
+
isinstance(t, dict) and t.get("type") == "function" and isinstance(t.get("name"), str) for t in tools
|
| 66 |
+
):
|
| 67 |
+
raise ValueError("Only function prediction tools are supported here; use the native NeMo Gym runtime")
|
| 68 |
+
allowed = {"input", "instructions", "tools", "tool_choice", "temperature", "top_p", "parallel_tool_calls", "reasoning", "text"}
|
| 69 |
+
# Model, token limits, persistence and execution mode belong to the runner.
|
| 70 |
+
body = {k: v for k, v in rcp.items() if k in allowed and v is not None}
|
| 71 |
+
relaxed = set(relaxed_tools(row))
|
| 72 |
+
if tools:
|
| 73 |
+
body["tools"] = [{**t, "strict": False} if t["name"] in relaxed else t for t in tools]
|
| 74 |
+
return body
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def prediction(response):
|
| 78 |
+
"""Match NeMo's response_utils.extract_action: calls take precedence over text."""
|
| 79 |
+
if response.get("error") or response.get("status") in ("failed", "cancelled"):
|
| 80 |
+
error = response.get("error") or {}
|
| 81 |
+
raise ValueError("Inference provider did not produce a prediction: " + str(error.get("message") or response.get("status"))[:600])
|
| 82 |
+
if not isinstance(response.get("output"), list):
|
| 83 |
+
raise ValueError("Inference provider returned an invalid Responses payload")
|
| 84 |
+
if response.get("status") == "incomplete" and not response["output"]:
|
| 85 |
+
raise ValueError("The model reached its output limit without a prediction. Increase Max output tokens.")
|
| 86 |
+
calls, content = [], None
|
| 87 |
+
for item in response.get("output") or []:
|
| 88 |
+
if item.get("type") == "function_call":
|
| 89 |
+
calls.append({"function": {"name": item["name"], "arguments": item["arguments"]}})
|
| 90 |
+
elif item.get("type") == "message" and item.get("role") == "assistant" and content is None:
|
| 91 |
+
content = next((p["text"] for p in item.get("content", []) if p.get("type") == "output_text"), None)
|
| 92 |
+
return {"tool_calls": calls, "content": content}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def validate(row, agent):
|
| 96 |
+
if not supported(agent):
|
| 97 |
+
raise ValueError("This NeMo resources server is not hosted here")
|
| 98 |
+
responses_request(row)
|
| 99 |
+
if len(json.dumps(row.get("expected_action"))) > 100_000:
|
| 100 |
+
raise ValueError("The reference action exceeds the hosted verifier limit")
|
| 101 |
+
try:
|
| 102 |
+
return TypeAdapter(ExpectedAction).validate_python(row.get("expected_action"))
|
| 103 |
+
except ValueError:
|
| 104 |
+
raise ValueError("The reference action is invalid for this NeMo Gym verifier") from None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def score(expected, message):
|
| 108 |
+
calls = message.get("tool_calls") or []
|
| 109 |
+
if len(calls) > 64:
|
| 110 |
+
raise ValueError("The provider returned too many tool calls")
|
| 111 |
+
actions = [FunctionCallAction(type="function_call", name=c["function"]["name"], arguments=c["function"]["arguments"]) for c in calls]
|
| 112 |
+
actual = (actions[0] if len(actions) == 1 else FunctionCallBatchAction(type="function_call_batch", calls=actions)) if actions else (
|
| 113 |
+
MessageAction(type="message", content=message["content"]) if isinstance(message.get("content"), str) else None)
|
| 114 |
+
if actual is None:
|
| 115 |
+
return 0.0, str(StepRewardCategory.NO_ACTION_FOUND)
|
| 116 |
+
result = ActionComparator(config=ToolCallComparatorConfig(word_count_similarity_threshold=0.1)).compare_action(expected, actual)
|
| 117 |
+
return result.reward, str(result.category)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def run(r):
|
| 121 |
+
payload = r.native_task
|
| 122 |
+
expected = validate(payload["row"], payload["agent"])
|
| 123 |
+
body = responses_request(payload["row"])
|
| 124 |
+
base, key, model = r.agent_api()
|
| 125 |
+
params = r.run["params"]
|
| 126 |
+
body.update(model=model, max_output_tokens=params.get("max_tokens", 2048), stream=False, store=False)
|
| 127 |
+
if params.get("temperature") is not None:
|
| 128 |
+
body["temperature"] = params["temperature"]
|
| 129 |
+
r.check_cancel()
|
| 130 |
+
r.phase("agent", detail="One prediction; tools are not executed")
|
| 131 |
+
if relaxed_tools(payload["row"]):
|
| 132 |
+
r.log("Some source tools declare strict=true with incompatible parameter schemas. Kept the original parameters and disabled strict enforcement for: "
|
| 133 |
+
+ ", ".join(relaxed_tools(payload["row"])))
|
| 134 |
+
r.emit("prompt", harness="nemo-gym", text=json.dumps({k: v for k, v in body.items() if k != "model"}, ensure_ascii=False, indent=2))
|
| 135 |
+
response = httpx.post(base + "/responses", json=body, headers={"Authorization": f"Bearer {key}"}, timeout=90, follow_redirects=False)
|
| 136 |
+
if response.status_code != 200:
|
| 137 |
+
raise RuntimeError(f"Inference provider returned HTTP {response.status_code}; check model availability and Inference Providers permission")
|
| 138 |
+
r.check_cancel()
|
| 139 |
+
if len(response.content) > 2_000_000:
|
| 140 |
+
raise ValueError("The model response exceeds the hosted limit")
|
| 141 |
+
result = response.json()
|
| 142 |
+
message = prediction(result)
|
| 143 |
+
if message.get("content"):
|
| 144 |
+
r.emit("text", text=message["content"])
|
| 145 |
+
for call in (message.get("tool_calls") or [])[:64]:
|
| 146 |
+
r.emit("tool", tool=call["function"]["name"], title="Predicted tool call", input=call["function"]["arguments"],
|
| 147 |
+
output="Prediction only; no tool was executed.", status="done")
|
| 148 |
+
usage = result.get("usage") or {}
|
| 149 |
+
r.add_tokens({"input": usage.get("input_tokens", 0), "output": usage.get("output_tokens", 0)})
|
| 150 |
+
r.emit("step", tokens=r.tokens, cost=r.model_cost)
|
| 151 |
+
r.update(n_turns=1)
|
| 152 |
+
r.phase("agent", "done")
|
| 153 |
+
r.phase("verify", detail="NeMo Gym's pinned argument comparator")
|
| 154 |
+
reward, category = score(expected, message)
|
| 155 |
+
r.emit("checks", checks=[{"id": "native-comparator", "passed": reward == 1, "score": reward,
|
| 156 |
+
"method": "NeMo Gym", "message": category}], reward=reward, summary=category)
|
| 157 |
+
r.phase("verify", "done", category)
|
| 158 |
+
return {"reward": reward}
|
app/runner.py
CHANGED
|
@@ -20,6 +20,7 @@ import asyncio
|
|
| 20 |
import contextlib
|
| 21 |
import contextvars
|
| 22 |
import json
|
|
|
|
| 23 |
import logging
|
| 24 |
import re
|
| 25 |
import secrets
|
|
@@ -28,6 +29,7 @@ import threading
|
|
| 28 |
import time
|
| 29 |
from pathlib import Path
|
| 30 |
from typing import Any
|
|
|
|
| 31 |
|
| 32 |
from . import catalog, config, dockerfile, judge, store
|
| 33 |
|
|
@@ -243,6 +245,16 @@ async def _replay(env: Any, plan: Any) -> None:
|
|
| 243 |
|
| 244 |
|
| 245 |
# ── the capture proxy ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
def service() -> Any:
|
| 247 |
"""OpenEnv's Harbor service: the capture proxy, and the URL the sandbox reaches it at."""
|
| 248 |
global _service
|
|
@@ -253,6 +265,12 @@ def service() -> Any:
|
|
| 253 |
# No default engine: every rollout brings its own (the visitor's token or endpoint).
|
| 254 |
_service = HarborService(llm_url=config.ROUTER, model="", datasets=[], provider="hf", capture_level="text",
|
| 255 |
expose=config.CAPTURE_EXPOSE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
_patch_hf_sandbox()
|
| 257 |
_patch_harbor_env()
|
| 258 |
_patch_opencode_install()
|
|
@@ -525,10 +543,13 @@ def submit(user: dict[str, Any], dataset: str, path: str, harness: str, model: s
|
|
| 525 |
"""Start a rollout of task `path` (the environment's ref) of `dataset`, with agent `harness`, by `runner`; `fields`
|
| 526 |
are the run option's own inputs (contract.run_option fields), checked against it."""
|
| 527 |
from . import settings
|
|
|
|
| 528 |
|
| 529 |
token = user.get("token")
|
| 530 |
if not token:
|
| 531 |
raise PermissionError("sign in to run a rollout")
|
|
|
|
|
|
|
| 532 |
if not settings.get("rollouts_enabled", True):
|
| 533 |
raise RuntimeError("Rollouts are paused for maintenance. Try again later.")
|
| 534 |
if harness not in settings.get("agents", list(AGENT_IDS)):
|
|
@@ -547,6 +568,18 @@ def submit(user: dict[str, Any], dataset: str, path: str, harness: str, model: s
|
|
| 547 |
if endpoint and not opt.get("endpoint", True):
|
| 548 |
raise ValueError(f"the {opt['label']} runner can't use your own endpoint")
|
| 549 |
fields = _check_fields(opt.get("fields") or [], fields or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
if runner == "mimo":
|
| 551 |
return _submit_mimo(user, env, row, model, provider, endpoint, endpoint_key, fields, visibility, max_user, max_all)
|
| 552 |
params = {**params, **{k: fields[k] for k in ("steps", "timeout_min") if k in fields}} # the Harbor runner's limits
|
|
@@ -623,8 +656,10 @@ def _check_fields(spec: list[dict[str, Any]], given: dict[str, Any]) -> dict[str
|
|
| 623 |
|
| 624 |
if not isinstance(v, str) or v not in {m["id"] for m in models.catalog().get(f.get("pool") or "", [])}:
|
| 625 |
raise ValueError(f"{f.get('label', key)}: pick one of the models offered")
|
| 626 |
-
if kind == "number" and (not isinstance(v, (int, float)) or isinstance(v, bool)):
|
| 627 |
raise ValueError(f"{f.get('label', key)} should be a number")
|
|
|
|
|
|
|
| 628 |
if kind == "number" and ((f.get("min") is not None and v < f["min"]) or (f.get("max") is not None and v > f["max"])):
|
| 629 |
raise ValueError(f"{f.get('label', key)} should be between {f.get('min')} and {f.get('max')}")
|
| 630 |
if kind == "bool" and not isinstance(v, bool):
|
|
|
|
| 20 |
import contextlib
|
| 21 |
import contextvars
|
| 22 |
import json
|
| 23 |
+
import math
|
| 24 |
import logging
|
| 25 |
import re
|
| 26 |
import secrets
|
|
|
|
| 29 |
import time
|
| 30 |
from pathlib import Path
|
| 31 |
from typing import Any
|
| 32 |
+
from fastapi import Request
|
| 33 |
|
| 34 |
from . import catalog, config, dockerfile, judge, store
|
| 35 |
|
|
|
|
| 245 |
|
| 246 |
|
| 247 |
# ── the capture proxy ────────────────────────────────────────────────────────
|
| 248 |
+
async def _event_model_proxy(cap: str, request: Request):
|
| 249 |
+
from .main import llm_proxy
|
| 250 |
+
return await llm_proxy(cap, request)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _event_model_health(cap: str):
|
| 254 |
+
from .main import llm_health
|
| 255 |
+
return llm_health(cap)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
def service() -> Any:
|
| 259 |
"""OpenEnv's Harbor service: the capture proxy, and the URL the sandbox reaches it at."""
|
| 260 |
global _service
|
|
|
|
| 265 |
# No default engine: every rollout brings its own (the visitor's token or endpoint).
|
| 266 |
_service = HarborService(llm_url=config.ROUTER, model="", datasets=[], provider="hf", capture_level="text",
|
| 267 |
expose=config.CAPTURE_EXPOSE)
|
| 268 |
+
_service.capture.app.add_api_route("/rlx/llm/{cap}/v1/chat/completions", _event_model_proxy, methods=["POST"])
|
| 269 |
+
_service.capture.app.add_api_route("/rlx/llm/{cap}/health", _event_model_health, methods=["GET"])
|
| 270 |
+
# OpenEnv ends with a catch-all POST capture route. Specific relay
|
| 271 |
+
# routes must precede it; they keep their own per-run capability auth.
|
| 272 |
+
routes = _service.capture.app.router.routes
|
| 273 |
+
routes[:] = routes[-2:] + routes[:-2]
|
| 274 |
_patch_hf_sandbox()
|
| 275 |
_patch_harbor_env()
|
| 276 |
_patch_opencode_install()
|
|
|
|
| 543 |
"""Start a rollout of task `path` (the environment's ref) of `dataset`, with agent `harness`, by `runner`; `fields`
|
| 544 |
are the run option's own inputs (contract.run_option fields), checked against it."""
|
| 545 |
from . import settings
|
| 546 |
+
from .envs.capabilities import HOSTED_RUNNERS
|
| 547 |
|
| 548 |
token = user.get("token")
|
| 549 |
if not token:
|
| 550 |
raise PermissionError("sign in to run a rollout")
|
| 551 |
+
if runner not in HOSTED_RUNNERS:
|
| 552 |
+
raise ValueError(f"The {runner} runtime is not hosted here. Use the task's native framework instructions.")
|
| 553 |
if not settings.get("rollouts_enabled", True):
|
| 554 |
raise RuntimeError("Rollouts are paused for maintenance. Try again later.")
|
| 555 |
if harness not in settings.get("agents", list(AGENT_IDS)):
|
|
|
|
| 568 |
if endpoint and not opt.get("endpoint", True):
|
| 569 |
raise ValueError(f"the {opt['label']} runner can't use your own endpoint")
|
| 570 |
fields = _check_fields(opt.get("fields") or [], fields or {})
|
| 571 |
+
if runner == "nemo-gym":
|
| 572 |
+
from . import nemo_runner
|
| 573 |
+
from .mimo.runner import core as events
|
| 574 |
+
with _slot(user["name"], max_user, max_all):
|
| 575 |
+
return events.submit(user["name"], token,
|
| 576 |
+
{"id": path, "domain": "nemo-gym", "title": row["title"], "native_task": row["native_task"]},
|
| 577 |
+
model, provider, None, params=fields,
|
| 578 |
+
visibility="private" if row.get("restricted") else visibility,
|
| 579 |
+
extra={"dataset": env.id, "path": path, "task_id": f"{env.id}:{path}", "env": env.key, "adapter": env.adapter.id,
|
| 580 |
+
"runner": runner, "harness": "single-prediction", "restricted": bool(row.get("restricted")),
|
| 581 |
+
"sha": row.get("sha"), "provenance": {"framework": "NeMo Gym", "commit": nemo_runner.REVISION,
|
| 582 |
+
"source": "https://github.com/NVIDIA-NeMo/Gym/tree/" + nemo_runner.REVISION}}, reserved=True)
|
| 583 |
if runner == "mimo":
|
| 584 |
return _submit_mimo(user, env, row, model, provider, endpoint, endpoint_key, fields, visibility, max_user, max_all)
|
| 585 |
params = {**params, **{k: fields[k] for k in ("steps", "timeout_min") if k in fields}} # the Harbor runner's limits
|
|
|
|
| 656 |
|
| 657 |
if not isinstance(v, str) or v not in {m["id"] for m in models.catalog().get(f.get("pool") or "", [])}:
|
| 658 |
raise ValueError(f"{f.get('label', key)}: pick one of the models offered")
|
| 659 |
+
if kind == "number" and (not isinstance(v, (int, float)) or isinstance(v, bool) or not math.isfinite(v)):
|
| 660 |
raise ValueError(f"{f.get('label', key)} should be a number")
|
| 661 |
+
if kind == "number" and f.get("step") == 1 and v != int(v):
|
| 662 |
+
raise ValueError(f"{f.get('label', key)} should be a whole number")
|
| 663 |
if kind == "number" and ((f.get("min") is not None and v < f["min"]) or (f.get("max") is not None and v > f["max"])):
|
| 664 |
raise ValueError(f"{f.get('label', key)} should be between {f.get('min')} and {f.get('max')}")
|
| 665 |
if kind == "bool" and not isinstance(v, bool):
|
app/runtime.py
CHANGED
|
@@ -52,7 +52,7 @@ def more_threads() -> None:
|
|
| 52 |
|
| 53 |
|
| 54 |
# ── logs ─────────────────────────────────────────────────────────────────────
|
| 55 |
-
SECRET_PATH = re.compile(r"(/api/llm/)[^/\s]+|((?:session|cap|token)=)[^&\s]+", re.I)
|
| 56 |
|
| 57 |
|
| 58 |
class _Json(logging.Formatter):
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
# ── logs ─────────────────────────────────────────────────────────────────────
|
| 55 |
+
SECRET_PATH = re.compile(r"(/(?:api|rlx)/llm/)[^/\s]+|((?:session|cap|token)=)[^&\s]+", re.I)
|
| 56 |
|
| 57 |
|
| 58 |
class _Json(logging.Formatter):
|
app/search_api.py
CHANGED
|
@@ -5,14 +5,15 @@
|
|
| 5 |
|
| 6 |
/api/search answers exactly as the Explore page always filtered its full listing in the browser:
|
| 7 |
|
| 8 |
-
kind
|
| 9 |
environment Spaces (ORS included), Harbor datasets (tagged, or an index found task folders), Verifiers,
|
| 10 |
NeMo Gym, OpenEnv datasets, verl and SkyRL, other RL datasets
|
|
|
|
| 11 |
collection an admin's collection, "other" (in none) or "mine" (the signed-in visitor's datasets, with mine=1)
|
| 12 |
f facet filters as the page's own URL writes them: `type:Benchmark|Neither;tags:code` (values
|
| 13 |
URI-encoded); values of one facet are alternatives, facets all apply
|
| 14 |
q every word must be part of the id, heading, brief or tags (substrings, any case)
|
| 15 |
-
sort trending (
|
| 16 |
rollouts | updated | new; ties keep the listing's order
|
| 17 |
|
| 18 |
Each facet is counted over what every *other* filter leaves (so picking a value never hides its alternatives).
|
|
@@ -31,20 +32,24 @@ import re
|
|
| 31 |
import threading
|
| 32 |
import time
|
| 33 |
from collections import OrderedDict
|
| 34 |
-
from dataclasses import dataclass, field
|
| 35 |
from typing import Any
|
| 36 |
from urllib.parse import unquote
|
| 37 |
|
| 38 |
from fastapi import APIRouter, HTTPException, Request
|
| 39 |
from fastapi.responses import Response
|
| 40 |
|
| 41 |
-
from . import snapshot
|
| 42 |
|
| 43 |
router = APIRouter(on_startup=[snapshot.start])
|
| 44 |
|
| 45 |
-
KINDS = ("all", "harbor", "openenv", "other-space", "verifiers", "nemo-gym", "openenv-data", "verl", "rows")
|
| 46 |
SORTS = ("trending", "downloads", "likes", "tasks", "rollouts", "updated", "new")
|
| 47 |
-
FACETS = ("type", "size", "stage", "mcp", "oe", "tags")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
OLD_KINDS = {"dataset": "harbor", "space": "openenv", "ors": "other-space"} # the page's older links
|
| 49 |
FACET_LIMIT = 100 # values returned per facet (most common first); the rest are counted in facets_more
|
| 50 |
MAX_WORDS = 16
|
|
@@ -55,7 +60,9 @@ MAX_VALUES = 50
|
|
| 55 |
@dataclass
|
| 56 |
class Query:
|
| 57 |
q: str = ""
|
|
|
|
| 58 |
kind: str = "all"
|
|
|
|
| 59 |
coll: str | None = None
|
| 60 |
sel: dict[str, list[str]] = field(default_factory=dict)
|
| 61 |
sort: str = "trending"
|
|
@@ -67,7 +74,7 @@ class Query:
|
|
| 67 |
return self.q.lower().split()[:MAX_WORDS]
|
| 68 |
|
| 69 |
def public(self) -> dict[str, Any]:
|
| 70 |
-
return {"q": self.q, "kind": self.kind, "collection": self.coll, "f": self.sel, "sort": self.sort,
|
| 71 |
"page": self.page, "size": self.size}
|
| 72 |
|
| 73 |
|
|
@@ -79,6 +86,10 @@ def parse_f(f: str) -> dict[str, list[str]]:
|
|
| 79 |
if k not in FACETS or not v:
|
| 80 |
continue
|
| 81 |
vals = [unquote(x)[:200] for x in v.split("|") if x]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
for x in vals:
|
| 83 |
if x not in sel.setdefault(k, []) and len(sel[k]) < MAX_VALUES:
|
| 84 |
sel[k].append(x)
|
|
@@ -101,7 +112,7 @@ def parse_query(params: Any, collections: list[dict[str, Any]], mine: bool) -> Q
|
|
| 101 |
coll = g("collection", "c")
|
| 102 |
known = {c["id"] for c in collections} | {"other"} | ({"mine"} if mine else set())
|
| 103 |
sort = g("sort", "s") or "trending"
|
| 104 |
-
return Query(q=(g("q") or "").strip()[:200], kind=kind if kind in KINDS else "all", coll=coll if coll in known else None,
|
| 105 |
sel=parse_f(g("f") or ""), sort=sort if sort in SORTS else "trending",
|
| 106 |
page=_int(g("page"), 1, 1, 10_000), size=_int(g("size"), 40, 1, 100))
|
| 107 |
|
|
@@ -155,12 +166,14 @@ class Ctx:
|
|
| 155 |
hidden: list[str]
|
| 156 |
mine: list[str]
|
| 157 |
rolls: dict[str, int]
|
|
|
|
| 158 |
|
| 159 |
def params(self) -> dict[str, str]:
|
| 160 |
"""The admin settings as query parameters: each collection's keys, pins, hidden ones, the visitor's, rollouts.
|
| 161 |
(Lists checked with IN, which SQLite evaluates once per statement: no per-row JSON.)"""
|
| 162 |
out = {"pins": json.dumps(self.pins), "hidden": json.dumps(self.hidden), "mine": json.dumps(self.mine),
|
| 163 |
-
"rolls": json.dumps(self.rolls)
|
|
|
|
| 164 |
for i, g in enumerate(self.collections):
|
| 165 |
out[f"c{i}"] = json.dumps([k for k, c in self.cmap.items() if c == g["id"]])
|
| 166 |
out[f"cid{i}"] = g["id"]
|
|
@@ -181,32 +194,53 @@ def context(mine: list[str] | None = None) -> Ctx:
|
|
| 181 |
for k in g.get("ids") or []:
|
| 182 |
cmap.setdefault(k, g["id"]) # the first collection naming it, as catalog.collection_of
|
| 183 |
return Ctx(collections=[{k: v for k, v in g.items() if k != "ids"} for g in colls], cmap=cmap, pins=list(catalog.pinned()),
|
| 184 |
-
hidden=sorted(catalog.hidden()), mine=list(mine or []), rolls=rollout_counts())
|
| 185 |
|
| 186 |
|
| 187 |
# ── SQL over the snapshot ────────────────────────────────────────────────────
|
| 188 |
BASE = """
|
| 189 |
-
WITH
|
|
|
|
|
|
|
| 190 |
SELECT e.rowid AS rid, e.ord, e.id, e.key, e.kind, e.framework, e.openenv, e.heading, e.brief, e.downloads, e.likes,
|
| 191 |
e.trending, e.created, e.updated, e.created_ms, e.updated_ms, e.stage, e.mcp, e.openenv_version, e.manifest,
|
| 192 |
e.hardware, e.badges, e.tags, e.tasks, e.indexed, e.size_f, e.stage_f, e.mcp_f, e.oe_f, e.blob,
|
|
|
|
| 193 |
{coll} AS coll,
|
| 194 |
(e.key IN (SELECT value FROM json_each(:pins))) AS pinned,
|
| 195 |
(e.key IN (SELECT value FROM json_each(:mine))) AS mine
|
| 196 |
FROM envs e
|
|
|
|
| 197 |
WHERE e.key NOT IN (SELECT value FROM json_each(:hidden))
|
| 198 |
),
|
| 199 |
-
b1 AS (SELECT b0.*, (kind = 'space' AND coll IS 'fineenvs' AND framework IS NOT 'openenv') AS ov FROM b0),
|
| 200 |
base AS (
|
| 201 |
-
SELECT
|
| 202 |
-
CASE WHEN
|
| 203 |
-
CASE WHEN
|
| 204 |
-
CASE WHEN
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
)
|
| 209 |
-
"""
|
|
|
|
| 210 |
ROLLOUTS = "COALESCE((SELECT value FROM json_each(:rolls) WHERE key = b.id), 0)"
|
| 211 |
|
| 212 |
|
|
@@ -214,33 +248,51 @@ TYPE_VALUES = "json_each(CASE WHEN json_array_length(b.badges_q) = 0 THEN '[\"Ne
|
|
| 214 |
FACET_FILTER = {
|
| 215 |
"type": f"EXISTS (SELECT 1 FROM {TYPE_VALUES} j WHERE j.value IN (SELECT value FROM json_each({{p}})))",
|
| 216 |
"size": "b.size_f IN (SELECT value FROM json_each({p}))",
|
| 217 |
-
"stage": "b.
|
| 218 |
-
"
|
| 219 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
"tags": "EXISTS (SELECT 1 FROM json_each(b.tags) j WHERE j.value IN (SELECT value FROM json_each({p})))",
|
| 221 |
}
|
| 222 |
FACET_COUNT = { # FROM, value expression, extra condition
|
| 223 |
"type": (f"base b, {TYPE_VALUES} j", "j.value", "1"),
|
| 224 |
"size": ("base b", "b.size_f", "b.size_f IS NOT NULL"),
|
| 225 |
-
"stage": ("base b", "b.
|
| 226 |
-
"
|
| 227 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
"tags": ("base b, json_each(b.tags) j", "j.value", "1"),
|
| 229 |
}
|
| 230 |
-
TRENDING = "
|
| 231 |
SORT_SQL = {"trending": TRENDING, "downloads": "b.downloads", "likes": "b.likes", "tasks": "COALESCE(b.tasks, -1)",
|
| 232 |
"rollouts": ROLLOUTS, "updated": "b.updated_ms", "new": "b.created_ms"}
|
| 233 |
|
| 234 |
|
| 235 |
def sort_sql(q: Query) -> str:
|
| 236 |
-
if q.sort == "trending" and q.kind == "openenv": # FineEnvs' curated servers lead the OpenEnv Spaces
|
| 237 |
-
return f"((b.coll IS 'fineenvs') * 1e14 + {TRENDING})"
|
| 238 |
return SORT_SQL[q.sort]
|
| 239 |
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
def where(q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_kind: bool = False) -> tuple[str, dict[str, Any]]:
|
| 242 |
parts: list[str] = []
|
| 243 |
params: dict[str, Any] = {}
|
|
|
|
|
|
|
| 244 |
if not ignore_kind and q.kind != "all":
|
| 245 |
parts.append("b.fw = :kind")
|
| 246 |
params["kind"] = q.kind
|
|
@@ -250,6 +302,9 @@ def where(q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_k
|
|
| 250 |
else:
|
| 251 |
parts.append("COALESCE(b.coll, 'other') = :coll")
|
| 252 |
params["coll"] = q.coll
|
|
|
|
|
|
|
|
|
|
| 253 |
words = q.words
|
| 254 |
if words:
|
| 255 |
fts = envs_fts_query(words)
|
|
@@ -274,6 +329,9 @@ def card(r: Any, rolls: dict[str, int]) -> dict[str, Any]:
|
|
| 274 |
"downloads": r["downloads"], "likes": r["likes"], "trending": r["trending"], "updated": r["updated"],
|
| 275 |
"created": r["created"], "stage": r["stage"], "mcp": bool(r["mcp"]), "openenv_version": r["openenv_version"],
|
| 276 |
"manifest": r["manifest"], "hardware": r["hardware"], "badges": json.loads(r["badges_q"]), "tags": json.loads(r["tags"]),
|
|
|
|
|
|
|
|
|
|
| 277 |
"pinned": bool(r["pinned"]), "mine": bool(r["mine"]), "indexed": json.loads(r["indexed"]) if r["indexed"] else None,
|
| 278 |
"rollouts": rolls.get(r["id"], 0), "private": False, "_sk": r["sk"], "_ord": r["ord"]}
|
| 279 |
|
|
@@ -289,7 +347,7 @@ def sql_search(conn, q: Query, ctx: Ctx, *, facets: bool = True, limit: int | No
|
|
| 289 |
w, p = where(q)
|
| 290 |
lim = q.size if limit is None else limit
|
| 291 |
off = (q.page - 1) * q.size if offset is None else offset
|
| 292 |
-
rows = conn.execute(f"{B} SELECT b.*, {sort_sql(q)} AS sk FROM base b WHERE {w} ORDER BY
|
| 293 |
{**base, **p, "lim": lim, "off": off}).fetchall()
|
| 294 |
total = conn.execute(f"{B} SELECT COUNT(*) FROM base b WHERE {w}", {**base, **p}).fetchone()[0]
|
| 295 |
out: dict[str, Any] = {"rows": [card(r, ctx.rolls) for r in rows], "total": total}
|
|
@@ -317,22 +375,46 @@ def sql_search(conn, q: Query, ctx: Ctx, *, facets: bool = True, limit: int | No
|
|
| 317 |
return out
|
| 318 |
|
| 319 |
|
| 320 |
-
def sql_extra(conn, ctx: Ctx, trending: int) -> dict[str, Any]:
|
| 321 |
-
"""
|
| 322 |
base, B = ctx.params(), ctx.base()
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
if trending:
|
| 327 |
-
|
| 328 |
-
out["trending"] =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
return out
|
| 330 |
|
| 331 |
|
| 332 |
# ── the same, in Python: the visitor's own datasets that aren't in the snapshot, and the tests' reference ────────
|
| 333 |
-
def py_fw(d: dict[str, Any]) -> str:
|
| 334 |
if d.get("kind") == "space":
|
| 335 |
-
|
|
|
|
|
|
|
| 336 |
return d.get("framework") or "harbor"
|
| 337 |
|
| 338 |
|
|
@@ -341,15 +423,19 @@ PY_FACETS = {
|
|
| 341 |
"size": lambda d: [snapshot.size_bucket((d.get("indexed") or {}).get("tasks"))]
|
| 342 |
if d.get("kind") == "dataset" and (d.get("framework") or "harbor") == "harbor" else [],
|
| 343 |
"stage": lambda d: [snapshot.stage_value(d.get("stage"))] if d.get("kind") == "space" else [],
|
| 344 |
-
"mcp": lambda d: (["
|
| 345 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
"tags": lambda d: d.get("tags") or [],
|
| 347 |
}
|
| 348 |
|
| 349 |
|
| 350 |
def py_cards(rows: list[dict[str, Any]], ctx: Ctx, ord0: int = 0) -> list[dict[str, Any]]:
|
| 351 |
-
"""
|
| 352 |
-
as OpenEnv (catalog.environments' rule). Hidden ones left out."""
|
| 353 |
hidden, pins, mine = set(ctx.hidden), set(ctx.pins), set(ctx.mine)
|
| 354 |
out = []
|
| 355 |
for i, r in enumerate(rows):
|
|
@@ -360,9 +446,26 @@ def py_cards(rows: list[dict[str, Any]], ctx: Ctx, ord0: int = 0) -> list[dict[s
|
|
| 360 |
d = {**r, "key": key, "collection": coll, "pinned": key in pins, "mine": key in mine or bool(r.get("mine")),
|
| 361 |
"rollouts": ctx.rolls.get(str(r["id"]), 0), "_ord": ord0 + i, "_blob": snapshot.blob_of(r),
|
| 362 |
"badges": list(r.get("badges") or []), "tags": list(r.get("tags") or [])}
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
d["fw"] = py_fw(d)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
out.append(d)
|
| 367 |
return out
|
| 368 |
|
|
@@ -372,22 +475,22 @@ def _time(iso: Any) -> int:
|
|
| 372 |
|
| 373 |
|
| 374 |
def py_key(q: Query):
|
| 375 |
-
|
| 376 |
-
keys = {"trending": lambda d: (1e15 if d["pinned"] else 0) + tk(d), "downloads": lambda d: d.get("downloads") or 0,
|
| 377 |
"likes": lambda d: d.get("likes") or 0, "tasks": lambda d: ((d.get("indexed") or {}).get("tasks") if (d.get("indexed") or {}).get("tasks") is not None else -1),
|
| 378 |
"rollouts": lambda d: d["rollouts"], "updated": lambda d: _time(d.get("updated")), "new": lambda d: _time(d.get("created"))}
|
| 379 |
-
|
| 380 |
-
if q.sort == "trending" and q.kind == "openenv":
|
| 381 |
-
return lambda d: (1e14 if d.get("collection") == "fineenvs" else 0) + k(d)
|
| 382 |
-
return k
|
| 383 |
|
| 384 |
|
| 385 |
def py_passes(d: dict[str, Any], q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_kind: bool = False) -> bool:
|
| 386 |
"""The page's own `passes()`, line for line."""
|
|
|
|
|
|
|
| 387 |
if not ignore_kind and q.kind != "all" and d["fw"] != q.kind:
|
| 388 |
return False
|
| 389 |
if not ignore_coll and q.coll and not (d["mine"] if q.coll == "mine" else (d.get("collection") or "other") == q.coll):
|
| 390 |
return False
|
|
|
|
|
|
|
| 391 |
if q.words and not all(w in d["_blob"] for w in q.words):
|
| 392 |
return False
|
| 393 |
for k, vals in q.sel.items():
|
|
@@ -401,9 +504,10 @@ def py_passes(d: dict[str, Any], q: Query, skip: str | None = None, ignore_coll:
|
|
| 401 |
def py_search(cards: list[dict[str, Any]], q: Query, *, with_mine: bool = False) -> dict[str, Any]:
|
| 402 |
"""`cards` (from py_cards) filtered, sorted and counted as the page did it: every match (sorted) and raw facets."""
|
| 403 |
key = py_key(q)
|
| 404 |
-
matches =
|
| 405 |
for d in matches:
|
| 406 |
d["_sk"] = key(d)
|
|
|
|
| 407 |
fc: dict[str, dict[Any, int]] = {"kind": {}, "collection": {}}
|
| 408 |
for d in cards:
|
| 409 |
if py_passes(d, q, ignore_kind=True):
|
|
@@ -428,7 +532,8 @@ def py_search(cards: list[dict[str, Any]], q: Query, *, with_mine: bool = False)
|
|
| 428 |
def _public_card(d: dict[str, Any]) -> dict[str, Any]:
|
| 429 |
keep = ("id", "key", "kind", "framework", "fw", "openenv", "collection", "heading", "brief", "downloads", "likes",
|
| 430 |
"trending", "updated", "created", "stage", "mcp", "openenv_version", "manifest", "hardware", "badges", "tags",
|
| 431 |
-
"pinned", "mine", "indexed", "rollouts", "private"
|
|
|
|
| 432 |
out = {k: d.get(k) for k in keep}
|
| 433 |
out["openenv"], out["mcp"], out["private"] = bool(out["openenv"]), bool(out["mcp"]), bool(out["private"])
|
| 434 |
ix = d.get("indexed")
|
|
@@ -557,7 +662,7 @@ def search(request: Request):
|
|
| 557 |
res = sql_search(conn, q, ctx, facets=facets, limit=off + q.size, offset=0)
|
| 558 |
more = py_search(py_cards([{**r, "key": r.get("key") or r["id"], "kind": "dataset", "mine": True} for r in extras],
|
| 559 |
ctx, ord0=10**9), q, with_mine=True)
|
| 560 |
-
merged = sorted(res["rows"] + more["matches"], key=lambda d: (
|
| 561 |
res["rows"], res["total"] = merged[off:off + q.size], res["total"] + more["total"]
|
| 562 |
if facets:
|
| 563 |
for k, counts in more["facets"].items():
|
|
@@ -565,15 +670,17 @@ def search(request: Request):
|
|
| 565 |
res["facets"][k][v] = res["facets"][k].get(v, 0) + n
|
| 566 |
else:
|
| 567 |
res = sql_search(conn, q, ctx, facets=facets)
|
| 568 |
-
head = sql_extra(conn, ctx, trending) if trending or facets else {}
|
| 569 |
if extras and head:
|
| 570 |
-
head["stats"]["datasets"] +=
|
| 571 |
-
|
|
|
|
|
|
|
| 572 |
if trending:
|
| 573 |
-
tq =
|
| 574 |
more = py_search(py_cards([{**r, "key": r.get("key") or r["id"], "kind": "dataset", "mine": True} for r in extras],
|
| 575 |
ctx, ord0=10**9), tq)["matches"]
|
| 576 |
-
head["trending"] = sorted(head["trending"] + more, key=lambda d: (
|
| 577 |
info = {"built_at": snap.built_at, "source": snap.source}
|
| 578 |
except snapshot.SnapshotError as e:
|
| 579 |
raise HTTPException(503, f"the catalog isn't ready: {e}")
|
|
@@ -593,6 +700,11 @@ def search(request: Request):
|
|
| 593 |
out["stats"] = head["stats"]
|
| 594 |
if trending:
|
| 595 |
out["trending"] = [_public_card(d) for d in head.get("trending", [])]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
out["snapshot"] = info
|
| 597 |
if key is not None:
|
| 598 |
with _answers_lock:
|
|
@@ -636,19 +748,18 @@ def search_tasks(request: Request, q: str = "", env: str = "", page: int = 1, si
|
|
| 636 |
|
| 637 |
|
| 638 |
@router.get("/api/search/rank")
|
| 639 |
-
def search_rank(key: str, request: Request):
|
| 640 |
-
"""Where one environment stands among its kind (Spaces, or datasets) by the page's trending order
|
| 641 |
{n, of}. One query, so a page can say "#599 of 6,818 Spaces" without loading the whole listing."""
|
| 642 |
ctx = context()
|
| 643 |
-
|
| 644 |
-
sql = ctx.base() + f"""
|
| 645 |
-
SELECT
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
FROM base t WHERE t.key = :key"""
|
| 649 |
try:
|
| 650 |
with snapshot.use() as (_snap, conn):
|
| 651 |
-
row = conn.execute(sql, {**ctx.params(), "key": key}).fetchone()
|
| 652 |
except snapshot.SnapshotError as e:
|
| 653 |
raise HTTPException(503, f"the catalog isn't ready: {e}")
|
| 654 |
if not row:
|
|
|
|
| 5 |
|
| 6 |
/api/search answers exactly as the Explore page always filtered its full listing in the browser:
|
| 7 |
|
| 8 |
+
kind OpenEnv Spaces require fresh successful API checks. Metadata-only matches are unverified-space; other
|
| 9 |
environment Spaces (ORS included), Harbor datasets (tagged, or an index found task folders), Verifiers,
|
| 10 |
NeMo Gym, OpenEnv datasets, verl and SkyRL, other RL datasets
|
| 11 |
+
owner exact Hub namespace, case-insensitive; applies to datasets and Spaces
|
| 12 |
collection an admin's collection, "other" (in none) or "mine" (the signed-in visitor's datasets, with mine=1)
|
| 13 |
f facet filters as the page's own URL writes them: `type:Benchmark|Neither;tags:code` (values
|
| 14 |
URI-encoded); values of one facet are alternatives, facets all apply
|
| 15 |
q every word must be part of the id, heading, brief or tags (substrings, any case)
|
| 16 |
+
sort trending (Hub score, then likes, then monthly dataset downloads; no editorial boosts) | downloads | likes | tasks |
|
| 17 |
rollouts | updated | new; ties keep the listing's order
|
| 18 |
|
| 19 |
Each facet is counted over what every *other* filter leaves (so picking a value never hides its alternatives).
|
|
|
|
| 32 |
import threading
|
| 33 |
import time
|
| 34 |
from collections import OrderedDict
|
| 35 |
+
from dataclasses import dataclass, field, replace
|
| 36 |
from typing import Any
|
| 37 |
from urllib.parse import unquote
|
| 38 |
|
| 39 |
from fastapi import APIRouter, HTTPException, Request
|
| 40 |
from fastapi.responses import Response
|
| 41 |
|
| 42 |
+
from . import snapshot, space_checks
|
| 43 |
|
| 44 |
router = APIRouter(on_startup=[snapshot.start])
|
| 45 |
|
| 46 |
+
KINDS = ("all", "harbor", "openenv", "unverified-space", "other-space", "verifiers", "nemo-gym", "openenv-data", "verl", "rows")
|
| 47 |
SORTS = ("trending", "downloads", "likes", "tasks", "rollouts", "updated", "new")
|
| 48 |
+
FACETS = ("type", "size", "health", "mode", "tools", "stage", "mcp", "oe", "oe_source", "evidence", "tags")
|
| 49 |
+
RANKING = {"version": 2, "source": "Hugging Face Hub", "order": ["trending", "likes", "downloads"],
|
| 50 |
+
"description": "Hub trending score first; ties use total likes, then dataset downloads over the last month. "
|
| 51 |
+
"Spaces have no download metric. Remaining ties keep catalog order. Featured picks do not affect rank.",
|
| 52 |
+
"scope": "Current search and filters", "quality": "Interest signals, not measured environment quality or live availability."}
|
| 53 |
OLD_KINDS = {"dataset": "harbor", "space": "openenv", "ors": "other-space"} # the page's older links
|
| 54 |
FACET_LIMIT = 100 # values returned per facet (most common first); the rest are counted in facets_more
|
| 55 |
MAX_WORDS = 16
|
|
|
|
| 60 |
@dataclass
|
| 61 |
class Query:
|
| 62 |
q: str = ""
|
| 63 |
+
scope: str = "all"
|
| 64 |
kind: str = "all"
|
| 65 |
+
owner: str | None = None
|
| 66 |
coll: str | None = None
|
| 67 |
sel: dict[str, list[str]] = field(default_factory=dict)
|
| 68 |
sort: str = "trending"
|
|
|
|
| 74 |
return self.q.lower().split()[:MAX_WORDS]
|
| 75 |
|
| 76 |
def public(self) -> dict[str, Any]:
|
| 77 |
+
return {"q": self.q, "scope": self.scope, "owner": self.owner, "kind": self.kind, "collection": self.coll, "f": self.sel, "sort": self.sort,
|
| 78 |
"page": self.page, "size": self.size}
|
| 79 |
|
| 80 |
|
|
|
|
| 86 |
if k not in FACETS or not v:
|
| 87 |
continue
|
| 88 |
vals = [unquote(x)[:200] for x in v.split("|") if x]
|
| 89 |
+
if k == "stage" and "Asleep or stopped" in vals: # saved links from the former broad bucket
|
| 90 |
+
vals = [x for x in vals if x != "Asleep or stopped"] + ["Sleeping", "Paused", "Building", "Starting", "Error", "Deleting"]
|
| 91 |
+
if k == "mcp":
|
| 92 |
+
vals = ["MCP tagged" if x == "Has MCP tools" else x for x in vals]
|
| 93 |
for x in vals:
|
| 94 |
if x not in sel.setdefault(k, []) and len(sel[k]) < MAX_VALUES:
|
| 95 |
sel[k].append(x)
|
|
|
|
| 112 |
coll = g("collection", "c")
|
| 113 |
known = {c["id"] for c in collections} | {"other"} | ({"mine"} if mine else set())
|
| 114 |
sort = g("sort", "s") or "trending"
|
| 115 |
+
return Query(q=(g("q") or "").strip()[:200], scope="ready" if g("scope") == "ready" else "all", owner=(g("owner") or "").strip()[:200] or None, kind=kind if kind in KINDS else "all", coll=coll if coll in known else None,
|
| 116 |
sel=parse_f(g("f") or ""), sort=sort if sort in SORTS else "trending",
|
| 117 |
page=_int(g("page"), 1, 1, 10_000), size=_int(g("size"), 40, 1, 100))
|
| 118 |
|
|
|
|
| 166 |
hidden: list[str]
|
| 167 |
mine: list[str]
|
| 168 |
rolls: dict[str, int]
|
| 169 |
+
checks: dict[str, dict] = field(default_factory=dict)
|
| 170 |
|
| 171 |
def params(self) -> dict[str, str]:
|
| 172 |
"""The admin settings as query parameters: each collection's keys, pins, hidden ones, the visitor's, rollouts.
|
| 173 |
(Lists checked with IN, which SQLite evaluates once per statement: no per-row JSON.)"""
|
| 174 |
out = {"pins": json.dumps(self.pins), "hidden": json.dumps(self.hidden), "mine": json.dumps(self.mine),
|
| 175 |
+
"rolls": json.dumps(self.rolls), "checks": json.dumps([
|
| 176 |
+
{**r, "status": space_checks.status(r), "browseable": space_checks.browseable(r)} for r in self.checks.values()])}
|
| 177 |
for i, g in enumerate(self.collections):
|
| 178 |
out[f"c{i}"] = json.dumps([k for k, c in self.cmap.items() if c == g["id"]])
|
| 179 |
out[f"cid{i}"] = g["id"]
|
|
|
|
| 194 |
for k in g.get("ids") or []:
|
| 195 |
cmap.setdefault(k, g["id"]) # the first collection naming it, as catalog.collection_of
|
| 196 |
return Ctx(collections=[{k: v for k, v in g.items() if k != "ids"} for g in colls], cmap=cmap, pins=list(catalog.pinned()),
|
| 197 |
+
hidden=sorted(catalog.hidden()), mine=list(mine or []), rolls=rollout_counts(), checks=space_checks.inventory())
|
| 198 |
|
| 199 |
|
| 200 |
# ── SQL over the snapshot ────────────────────────────────────────────────────
|
| 201 |
BASE = """
|
| 202 |
+
WITH checks AS MATERIALIZED (
|
| 203 |
+
SELECT CAST(json_extract(value, '$.id') AS TEXT) AS id, value AS check_json FROM json_each(:checks)
|
| 204 |
+
), b0 AS (
|
| 205 |
SELECT e.rowid AS rid, e.ord, e.id, e.key, e.kind, e.framework, e.openenv, e.heading, e.brief, e.downloads, e.likes,
|
| 206 |
e.trending, e.created, e.updated, e.created_ms, e.updated_ms, e.stage, e.mcp, e.openenv_version, e.manifest,
|
| 207 |
e.hardware, e.badges, e.tags, e.tasks, e.indexed, e.size_f, e.stage_f, e.mcp_f, e.oe_f, e.blob,
|
| 208 |
+
c.check_json,
|
| 209 |
{coll} AS coll,
|
| 210 |
(e.key IN (SELECT value FROM json_each(:pins))) AS pinned,
|
| 211 |
(e.key IN (SELECT value FROM json_each(:mine))) AS mine
|
| 212 |
FROM envs e
|
| 213 |
+
LEFT JOIN checks c ON c.id = e.id AND e.kind = 'space'
|
| 214 |
WHERE e.key NOT IN (SELECT value FROM json_each(:hidden))
|
| 215 |
),
|
|
|
|
| 216 |
base AS (
|
| 217 |
+
SELECT b0.*,
|
| 218 |
+
CASE WHEN kind = 'space' THEN CASE WHEN {verified} THEN 'openenv' WHEN framework = 'ors' THEN 'ors' ELSE 'space' END ELSE framework END AS framework_q,
|
| 219 |
+
CASE WHEN kind = 'space' AND {verified} THEN 1 ELSE 0 END AS openenv_q,
|
| 220 |
+
CASE WHEN kind = 'space' AND NOT COALESCE(({verified}), 0) THEN
|
| 221 |
+
(SELECT json_group_array(value) FROM json_each(badges) WHERE value != 'OpenEnv') ELSE badges END AS badges_q,
|
| 222 |
+
CASE WHEN kind = 'space' THEN (CASE WHEN {verified} THEN 'openenv' WHEN framework IS 'openenv' OR openenv THEN 'unverified-space' ELSE 'other-space' END)
|
| 223 |
+
ELSE COALESCE(framework, 'harbor') END AS fw,
|
| 224 |
+
CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv)
|
| 225 |
+
THEN CASE WHEN manifest IS NOT NULL THEN 'Manifest present' ELSE 'Hub tag only' END END AS evidence_f,
|
| 226 |
+
CASE WHEN kind = 'space' THEN {stage} END AS runtime_f,
|
| 227 |
+
CASE WHEN kind = 'space' THEN CASE WHEN mcp THEN 'MCP tagged' ELSE 'No MCP tag' END END AS mcp_tag_f,
|
| 228 |
+
CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv OR ({verified}))
|
| 229 |
+
THEN COALESCE(json_extract(check_json, '$.status'), 'Not checked') END AS health_f,
|
| 230 |
+
CASE WHEN json_extract(check_json, '$.status') = 'API checked' THEN json_extract(check_json, '$.mode') END AS mode_f,
|
| 231 |
+
CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv OR ({verified})) THEN
|
| 232 |
+
CASE WHEN json_extract(check_json, '$.status') IN ('API checked', 'Checks failed') THEN
|
| 233 |
+
CASE WHEN json_extract(check_json, '$.tools') > 0 THEN 'Tools discovered' ELSE 'No tools discovered' END
|
| 234 |
+
ELSE 'Not checked' END END AS tools_f,
|
| 235 |
+
CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv OR ({verified})) THEN
|
| 236 |
+
COALESCE(NULLIF(json_extract(check_json, '$.version.value'), 'Unknown'), openenv_version, 'Unknown') END AS version_f,
|
| 237 |
+
CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv OR ({verified})) THEN
|
| 238 |
+
CASE WHEN json_extract(check_json, '$.version.value') IS NOT NULL AND json_extract(check_json, '$.version.value') != 'Unknown'
|
| 239 |
+
THEN json_extract(check_json, '$.version.source') WHEN openenv_version IS NOT NULL THEN 'Hub tag' ELSE 'Unknown' END END AS version_source_f
|
| 240 |
+
FROM b0
|
| 241 |
)
|
| 242 |
+
""".replace("{stage}", snapshot.stage_sql("stage")).replace("{verified}",
|
| 243 |
+
"json_extract(check_json, '$.status') = 'API checked' AND json_extract(check_json, '$.stage') = 'RUNNING'")
|
| 244 |
ROLLOUTS = "COALESCE((SELECT value FROM json_each(:rolls) WHERE key = b.id), 0)"
|
| 245 |
|
| 246 |
|
|
|
|
| 248 |
FACET_FILTER = {
|
| 249 |
"type": f"EXISTS (SELECT 1 FROM {TYPE_VALUES} j WHERE j.value IN (SELECT value FROM json_each({{p}})))",
|
| 250 |
"size": "b.size_f IN (SELECT value FROM json_each({p}))",
|
| 251 |
+
"stage": "b.runtime_f IN (SELECT value FROM json_each({p}))",
|
| 252 |
+
"health": "b.health_f IN (SELECT value FROM json_each({p}))",
|
| 253 |
+
"mode": "b.mode_f IN (SELECT value FROM json_each({p}))",
|
| 254 |
+
"tools": "b.tools_f IN (SELECT value FROM json_each({p}))",
|
| 255 |
+
"mcp": "b.mcp_tag_f IN (SELECT value FROM json_each({p}))",
|
| 256 |
+
"oe": "b.version_f IN (SELECT value FROM json_each({p}))",
|
| 257 |
+
"oe_source": "b.version_source_f IN (SELECT value FROM json_each({p}))",
|
| 258 |
+
"evidence": "b.evidence_f IN (SELECT value FROM json_each({p}))",
|
| 259 |
"tags": "EXISTS (SELECT 1 FROM json_each(b.tags) j WHERE j.value IN (SELECT value FROM json_each({p})))",
|
| 260 |
}
|
| 261 |
FACET_COUNT = { # FROM, value expression, extra condition
|
| 262 |
"type": (f"base b, {TYPE_VALUES} j", "j.value", "1"),
|
| 263 |
"size": ("base b", "b.size_f", "b.size_f IS NOT NULL"),
|
| 264 |
+
"stage": ("base b", "b.runtime_f", "b.runtime_f IS NOT NULL"),
|
| 265 |
+
"health": ("base b", "b.health_f", "b.health_f IS NOT NULL"),
|
| 266 |
+
"mode": ("base b", "b.mode_f", "b.mode_f IS NOT NULL"),
|
| 267 |
+
"tools": ("base b", "b.tools_f", "b.tools_f IS NOT NULL"),
|
| 268 |
+
"mcp": ("base b", "b.mcp_tag_f", "b.mcp_tag_f IS NOT NULL"),
|
| 269 |
+
"oe": ("base b", "b.version_f", "b.version_f IS NOT NULL"),
|
| 270 |
+
"oe_source": ("base b", "b.version_source_f", "b.version_source_f IS NOT NULL"),
|
| 271 |
+
"evidence": ("base b", "b.evidence_f", "b.evidence_f IS NOT NULL"),
|
| 272 |
"tags": ("base b, json_each(b.tags) j", "j.value", "1"),
|
| 273 |
}
|
| 274 |
+
TRENDING = "b.trending"
|
| 275 |
SORT_SQL = {"trending": TRENDING, "downloads": "b.downloads", "likes": "b.likes", "tasks": "COALESCE(b.tasks, -1)",
|
| 276 |
"rollouts": ROLLOUTS, "updated": "b.updated_ms", "new": "b.created_ms"}
|
| 277 |
|
| 278 |
|
| 279 |
def sort_sql(q: Query) -> str:
|
|
|
|
|
|
|
| 280 |
return SORT_SQL[q.sort]
|
| 281 |
|
| 282 |
|
| 283 |
+
def order_sql(q: Query) -> str:
|
| 284 |
+
return "sk DESC, " + ("b.likes DESC, b.downloads DESC, " if q.sort == "trending" else "") + "b.ord ASC"
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def order_key(q: Query, d: dict[str, Any]) -> tuple:
|
| 288 |
+
return (-d["_sk"],) + ((-(d.get("likes") or 0), -(d.get("downloads") or 0)) if q.sort == "trending" else ()) + (d["_ord"],)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
def where(q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_kind: bool = False) -> tuple[str, dict[str, Any]]:
|
| 292 |
parts: list[str] = []
|
| 293 |
params: dict[str, Any] = {}
|
| 294 |
+
if q.scope == "ready":
|
| 295 |
+
parts.append("(b.kind = 'dataset' OR json_extract(b.check_json, '$.browseable') = 1)")
|
| 296 |
if not ignore_kind and q.kind != "all":
|
| 297 |
parts.append("b.fw = :kind")
|
| 298 |
params["kind"] = q.kind
|
|
|
|
| 302 |
else:
|
| 303 |
parts.append("COALESCE(b.coll, 'other') = :coll")
|
| 304 |
params["coll"] = q.coll
|
| 305 |
+
if q.owner:
|
| 306 |
+
parts.append("substr(b.id, 1, instr(b.id, '/') - 1) = :owner COLLATE NOCASE")
|
| 307 |
+
params["owner"] = q.owner
|
| 308 |
words = q.words
|
| 309 |
if words:
|
| 310 |
fts = envs_fts_query(words)
|
|
|
|
| 329 |
"downloads": r["downloads"], "likes": r["likes"], "trending": r["trending"], "updated": r["updated"],
|
| 330 |
"created": r["created"], "stage": r["stage"], "mcp": bool(r["mcp"]), "openenv_version": r["openenv_version"],
|
| 331 |
"manifest": r["manifest"], "hardware": r["hardware"], "badges": json.loads(r["badges_q"]), "tags": json.loads(r["tags"]),
|
| 332 |
+
"evidence": r["evidence_f"], "stage_label": r["runtime_f"],
|
| 333 |
+
"api_status": r["health_f"], "api_check": json.loads(r["check_json"]) if r["check_json"] else None,
|
| 334 |
+
"declared_version": r["version_f"], "version_source": r["version_source_f"], "api_mode": r["mode_f"], "tools_status": r["tools_f"],
|
| 335 |
"pinned": bool(r["pinned"]), "mine": bool(r["mine"]), "indexed": json.loads(r["indexed"]) if r["indexed"] else None,
|
| 336 |
"rollouts": rolls.get(r["id"], 0), "private": False, "_sk": r["sk"], "_ord": r["ord"]}
|
| 337 |
|
|
|
|
| 347 |
w, p = where(q)
|
| 348 |
lim = q.size if limit is None else limit
|
| 349 |
off = (q.page - 1) * q.size if offset is None else offset
|
| 350 |
+
rows = conn.execute(f"{B} SELECT b.*, {sort_sql(q)} AS sk FROM base b WHERE {w} ORDER BY {order_sql(q)} LIMIT :lim OFFSET :off",
|
| 351 |
{**base, **p, "lim": lim, "off": off}).fetchall()
|
| 352 |
total = conn.execute(f"{B} SELECT COUNT(*) FROM base b WHERE {w}", {**base, **p}).fetchone()[0]
|
| 353 |
out: dict[str, Any] = {"rows": [card(r, ctx.rolls) for r in rows], "total": total}
|
|
|
|
| 375 |
return out
|
| 376 |
|
| 377 |
|
| 378 |
+
def sql_extra(conn, ctx: Ctx, trending: int, q: Query | None = None) -> dict[str, Any]:
|
| 379 |
+
"""Counts, task coverage and editorial picks all follow the current search."""
|
| 380 |
base, B = ctx.params(), ctx.base()
|
| 381 |
+
w, p = where(q or Query())
|
| 382 |
+
params = {**base, **p}
|
| 383 |
+
ds, sp = conn.execute(f"{B} SELECT SUM(b.kind = 'dataset'), SUM(b.kind = 'space') FROM base b WHERE {w}", params).fetchone()
|
| 384 |
+
# Count actual searchable records, including adapters (MiMo) with no Harbor
|
| 385 |
+
# summary. Health discovery must never imply that tasks were downloaded.
|
| 386 |
+
tasks = conn.execute(f"{B} SELECT COUNT(*) FROM tasks t JOIN base b ON b.key=t.env WHERE b.kind='dataset' AND {w}", params).fetchone()[0]
|
| 387 |
+
from . import space_tasks
|
| 388 |
+
visible = [r[0] for r in conn.execute(f"{B} SELECT b.id FROM base b WHERE b.kind='space' AND {w}", params)]
|
| 389 |
+
space_counts = space_tasks.census(ctx.checks, visible)
|
| 390 |
+
out: dict[str, Any] = {"stats": {"datasets": ds or 0, "spaces": sp or 0, "tasks": tasks,
|
| 391 |
+
"dataset_tasks": tasks, "space_tasks": space_counts["tasks"],
|
| 392 |
+
"space_task_coverage": space_counts}}
|
| 393 |
+
out["stats"]["openenv"] = dict(conn.execute(f"{B} SELECT b.evidence_f, COUNT(*) FROM base b WHERE b.evidence_f IS NOT NULL AND {w} GROUP BY 1", params))
|
| 394 |
+
out["stats"]["openenv_running"] = conn.execute(f"{B} SELECT COUNT(*) FROM base b WHERE b.evidence_f IS NOT NULL AND b.stage = 'RUNNING' AND {w}", params).fetchone()[0]
|
| 395 |
if trending:
|
| 396 |
+
tq = replace(q or Query(), sort="trending", page=1, size=trending)
|
| 397 |
+
out["trending"] = sql_search(conn, tq, ctx, facets=False)["rows"]
|
| 398 |
+
w, p = where(tq)
|
| 399 |
+
rows = conn.execute(f"{B} SELECT b.*, 0 AS sk FROM base b WHERE b.pinned AND {w} "
|
| 400 |
+
"ORDER BY (SELECT CAST(key AS INTEGER) FROM json_each(:pins) WHERE value = b.key) LIMIT 12", {**base, **p})
|
| 401 |
+
out["featured"] = [card(r, ctx.rolls) for r in rows]
|
| 402 |
+
# FineEnvs is an explicit editorial section, independent of admin pins
|
| 403 |
+
# and interest rank. Owner/search filters still apply to it.
|
| 404 |
+
promoted = conn.execute(f"{B} SELECT b.*, {TRENDING} AS sk FROM base b WHERE {w} "
|
| 405 |
+
"AND substr(b.id, 1, instr(b.id, '/') - 1) = 'FineEnvs' COLLATE NOCASE "
|
| 406 |
+
"AND (b.kind = 'dataset' OR json_extract(b.check_json, '$.browseable') = 1) "
|
| 407 |
+
"ORDER BY (b.kind = 'space') DESC, b.pinned DESC, " + order_sql(tq) + " LIMIT 4", {**base, **p})
|
| 408 |
+
out["promoted"] = [card(r, ctx.rolls) for r in promoted]
|
| 409 |
return out
|
| 410 |
|
| 411 |
|
| 412 |
# ── the same, in Python: the visitor's own datasets that aren't in the snapshot, and the tests' reference ────────
|
| 413 |
+
def py_fw(d: dict[str, Any], check=None) -> str:
|
| 414 |
if d.get("kind") == "space":
|
| 415 |
+
if space_checks.verified(check):
|
| 416 |
+
return "openenv"
|
| 417 |
+
return "unverified-space" if d.get("framework") == "openenv" or d.get("openenv") else "other-space"
|
| 418 |
return d.get("framework") or "harbor"
|
| 419 |
|
| 420 |
|
|
|
|
| 423 |
"size": lambda d: [snapshot.size_bucket((d.get("indexed") or {}).get("tasks"))]
|
| 424 |
if d.get("kind") == "dataset" and (d.get("framework") or "harbor") == "harbor" else [],
|
| 425 |
"stage": lambda d: [snapshot.stage_value(d.get("stage"))] if d.get("kind") == "space" else [],
|
| 426 |
+
"mcp": lambda d: (["MCP tagged" if d.get("mcp") else "No MCP tag"]) if d.get("kind") == "space" else [],
|
| 427 |
+
"health": lambda d: [d["api_status"]] if d.get("api_status") else [],
|
| 428 |
+
"mode": lambda d: [d["api_mode"]] if d.get("api_mode") else [],
|
| 429 |
+
"tools": lambda d: [d["tools_status"]] if d.get("tools_status") else [],
|
| 430 |
+
"oe": lambda d: [d["declared_version"]] if d.get("declared_version") else [],
|
| 431 |
+
"oe_source": lambda d: [d["version_source"]] if d.get("version_source") else [],
|
| 432 |
+
"evidence": lambda d: [d["evidence"]] if d.get("evidence") else [],
|
| 433 |
"tags": lambda d: d.get("tags") or [],
|
| 434 |
}
|
| 435 |
|
| 436 |
|
| 437 |
def py_cards(rows: list[dict[str, Any]], ctx: Ctx, ord0: int = 0) -> list[dict[str, Any]]:
|
| 438 |
+
"""Apply visibility and editorial metadata without changing framework evidence or rank."""
|
|
|
|
| 439 |
hidden, pins, mine = set(ctx.hidden), set(ctx.pins), set(ctx.mine)
|
| 440 |
out = []
|
| 441 |
for i, r in enumerate(rows):
|
|
|
|
| 446 |
d = {**r, "key": key, "collection": coll, "pinned": key in pins, "mine": key in mine or bool(r.get("mine")),
|
| 447 |
"rollouts": ctx.rolls.get(str(r["id"]), 0), "_ord": ord0 + i, "_blob": snapshot.blob_of(r),
|
| 448 |
"badges": list(r.get("badges") or []), "tags": list(r.get("tags") or [])}
|
| 449 |
+
check = ctx.checks.get(d["id"], {}) if d.get("kind") == "space" else {}
|
| 450 |
+
declared = d.get("kind") == "space" and (d.get("framework") == "openenv" or d.get("openenv"))
|
| 451 |
+
d["fw"] = py_fw(d, check)
|
| 452 |
+
d["evidence"] = ("Manifest present" if d.get("manifest") else "Hub tag only") if declared else None
|
| 453 |
+
d["stage_label"] = snapshot.stage_value(d.get("stage")) if d.get("kind") == "space" else None
|
| 454 |
+
health = space_checks.status(check)
|
| 455 |
+
candidate = declared or d["fw"] == "openenv"
|
| 456 |
+
ver = check.get("version") or {}
|
| 457 |
+
known_ver = ver.get("value") and ver["value"] != "Unknown"
|
| 458 |
+
d.update(api_check={**check, "status": health} if check else None, api_status=health if candidate else None,
|
| 459 |
+
api_mode=check.get("mode") if health == space_checks.PASS else None,
|
| 460 |
+
tools_status=("Tools discovered" if check.get("tools", 0) > 0 else "No tools discovered")
|
| 461 |
+
if candidate and health in (space_checks.PASS, "Checks failed") else "Not checked" if candidate else None,
|
| 462 |
+
declared_version=(ver["value"] if known_ver else d.get("openenv_version") or "Unknown") if candidate else None,
|
| 463 |
+
version_source=(ver.get("source") if known_ver else "Hub tag" if d.get("openenv_version") else "Unknown") if candidate else None)
|
| 464 |
+
if d.get("kind") == "space":
|
| 465 |
+
d["openenv"] = d["fw"] == "openenv"
|
| 466 |
+
d["framework"] = "openenv" if d["openenv"] else "ors" if d.get("framework") == "ors" else "space"
|
| 467 |
+
if not d["openenv"]:
|
| 468 |
+
d["badges"] = [b for b in d["badges"] if b != "OpenEnv"]
|
| 469 |
out.append(d)
|
| 470 |
return out
|
| 471 |
|
|
|
|
| 475 |
|
| 476 |
|
| 477 |
def py_key(q: Query):
|
| 478 |
+
keys = {"trending": lambda d: d.get("trending") or 0, "downloads": lambda d: d.get("downloads") or 0,
|
|
|
|
| 479 |
"likes": lambda d: d.get("likes") or 0, "tasks": lambda d: ((d.get("indexed") or {}).get("tasks") if (d.get("indexed") or {}).get("tasks") is not None else -1),
|
| 480 |
"rollouts": lambda d: d["rollouts"], "updated": lambda d: _time(d.get("updated")), "new": lambda d: _time(d.get("created"))}
|
| 481 |
+
return keys[q.sort]
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
|
| 484 |
def py_passes(d: dict[str, Any], q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_kind: bool = False) -> bool:
|
| 485 |
"""The page's own `passes()`, line for line."""
|
| 486 |
+
if q.scope == "ready" and d.get("kind") == "space" and not space_checks.browseable(d.get("api_check")):
|
| 487 |
+
return False
|
| 488 |
if not ignore_kind and q.kind != "all" and d["fw"] != q.kind:
|
| 489 |
return False
|
| 490 |
if not ignore_coll and q.coll and not (d["mine"] if q.coll == "mine" else (d.get("collection") or "other") == q.coll):
|
| 491 |
return False
|
| 492 |
+
if q.owner and d["id"].split("/", 1)[0].lower() != q.owner.lower():
|
| 493 |
+
return False
|
| 494 |
if q.words and not all(w in d["_blob"] for w in q.words):
|
| 495 |
return False
|
| 496 |
for k, vals in q.sel.items():
|
|
|
|
| 504 |
def py_search(cards: list[dict[str, Any]], q: Query, *, with_mine: bool = False) -> dict[str, Any]:
|
| 505 |
"""`cards` (from py_cards) filtered, sorted and counted as the page did it: every match (sorted) and raw facets."""
|
| 506 |
key = py_key(q)
|
| 507 |
+
matches = [d for d in cards if py_passes(d, q)]
|
| 508 |
for d in matches:
|
| 509 |
d["_sk"] = key(d)
|
| 510 |
+
matches.sort(key=lambda d: order_key(q, d))
|
| 511 |
fc: dict[str, dict[Any, int]] = {"kind": {}, "collection": {}}
|
| 512 |
for d in cards:
|
| 513 |
if py_passes(d, q, ignore_kind=True):
|
|
|
|
| 532 |
def _public_card(d: dict[str, Any]) -> dict[str, Any]:
|
| 533 |
keep = ("id", "key", "kind", "framework", "fw", "openenv", "collection", "heading", "brief", "downloads", "likes",
|
| 534 |
"trending", "updated", "created", "stage", "mcp", "openenv_version", "manifest", "hardware", "badges", "tags",
|
| 535 |
+
"pinned", "mine", "indexed", "rollouts", "private", "evidence", "stage_label", "api_status", "api_check",
|
| 536 |
+
"declared_version", "version_source", "api_mode", "tools_status")
|
| 537 |
out = {k: d.get(k) for k in keep}
|
| 538 |
out["openenv"], out["mcp"], out["private"] = bool(out["openenv"]), bool(out["mcp"]), bool(out["private"])
|
| 539 |
ix = d.get("indexed")
|
|
|
|
| 662 |
res = sql_search(conn, q, ctx, facets=facets, limit=off + q.size, offset=0)
|
| 663 |
more = py_search(py_cards([{**r, "key": r.get("key") or r["id"], "kind": "dataset", "mine": True} for r in extras],
|
| 664 |
ctx, ord0=10**9), q, with_mine=True)
|
| 665 |
+
merged = sorted(res["rows"] + more["matches"], key=lambda d: order_key(q, d))
|
| 666 |
res["rows"], res["total"] = merged[off:off + q.size], res["total"] + more["total"]
|
| 667 |
if facets:
|
| 668 |
for k, counts in more["facets"].items():
|
|
|
|
| 670 |
res["facets"][k][v] = res["facets"][k].get(v, 0) + n
|
| 671 |
else:
|
| 672 |
res = sql_search(conn, q, ctx, facets=facets)
|
| 673 |
+
head = sql_extra(conn, ctx, trending, q) if trending or facets else {}
|
| 674 |
if extras and head:
|
| 675 |
+
head["stats"]["datasets"] += more["total"]
|
| 676 |
+
private_tasks = sum((r.get("indexed") or {}).get("tasks") or 0 for r in more["matches"])
|
| 677 |
+
for metric in ("tasks", "dataset_tasks"):
|
| 678 |
+
head["stats"][metric] += private_tasks
|
| 679 |
if trending:
|
| 680 |
+
tq = replace(q, sort="trending")
|
| 681 |
more = py_search(py_cards([{**r, "key": r.get("key") or r["id"], "kind": "dataset", "mine": True} for r in extras],
|
| 682 |
ctx, ord0=10**9), tq)["matches"]
|
| 683 |
+
head["trending"] = sorted(head["trending"] + more, key=lambda d: order_key(tq, d))[:trending]
|
| 684 |
info = {"built_at": snap.built_at, "source": snap.source}
|
| 685 |
except snapshot.SnapshotError as e:
|
| 686 |
raise HTTPException(503, f"the catalog isn't ready: {e}")
|
|
|
|
| 700 |
out["stats"] = head["stats"]
|
| 701 |
if trending:
|
| 702 |
out["trending"] = [_public_card(d) for d in head.get("trending", [])]
|
| 703 |
+
out["featured"] = [_public_card(d) for d in head.get("featured", [])]
|
| 704 |
+
out["promoted"] = [_public_card(d) for d in head.get("promoted", [])]
|
| 705 |
+
out["ranking"] = RANKING
|
| 706 |
+
out["api_checks"] = {"fresh_seconds": space_checks.FRESH, "description": "Running on the Hub and OpenEnv API checks passed within the last hour. "
|
| 707 |
+
"No episode or reward validation. Versions come from repository dependencies or Hub tags, not the installed runtime."}
|
| 708 |
out["snapshot"] = info
|
| 709 |
if key is not None:
|
| 710 |
with _answers_lock:
|
|
|
|
| 748 |
|
| 749 |
|
| 750 |
@router.get("/api/search/rank")
|
| 751 |
+
def search_rank(key: str, request: Request, scope: str = "all"):
|
| 752 |
+
"""Where one environment stands among its kind (Spaces, or datasets) by the page's trending order:
|
| 753 |
{n, of}. One query, so a page can say "#599 of 6,818 Spaces" without loading the whole listing."""
|
| 754 |
ctx = context()
|
| 755 |
+
w, params = where(Query(scope="ready" if scope == "ready" else "all"))
|
| 756 |
+
sql = ctx.base() + f""", ranked AS (
|
| 757 |
+
SELECT key, ROW_NUMBER() OVER (PARTITION BY kind ORDER BY trending DESC, likes DESC, downloads DESC, ord) AS n,
|
| 758 |
+
COUNT(*) OVER (PARTITION BY kind) AS total FROM base b WHERE {w})
|
| 759 |
+
SELECT n, total FROM ranked WHERE key = :key"""
|
|
|
|
| 760 |
try:
|
| 761 |
with snapshot.use() as (_snap, conn):
|
| 762 |
+
row = conn.execute(sql, {**ctx.params(), **params, "key": key}).fetchone()
|
| 763 |
except snapshot.SnapshotError as e:
|
| 764 |
raise HTTPException(503, f"the catalog isn't ready: {e}")
|
| 765 |
if not row:
|
app/seo.py
CHANGED
|
@@ -133,6 +133,8 @@ def kind_of(row: dict[str, Any] | None, spec: str = "") -> str:
|
|
| 133 |
if not row:
|
| 134 |
return "RL environment"
|
| 135 |
if row.get("kind") == "space":
|
|
|
|
|
|
|
| 136 |
return {"openenv": KIND["openenv"], "ors": "ORS Space"}.get(row.get("framework") or ("openenv" if row.get("openenv") else ""), KIND["space"])
|
| 137 |
return KIND.get(row.get("framework") or "", "RL dataset")
|
| 138 |
|
|
@@ -283,7 +285,7 @@ def space(request: Request, spec: str) -> HTMLResponse:
|
|
| 283 |
"sameAs": f"https://huggingface.co/spaces/{spec}", "applicationCategory": "DeveloperApplication", "operatingSystem": "Web",
|
| 284 |
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
|
| 285 |
"author": {"@type": "Organization", "name": spec.split("/")[0], "url": f"https://huggingface.co/{spec.split('/')[0]}"},
|
| 286 |
-
"keywords": ["reinforcement learning", "RL environment", "OpenEnv", *((r or {}).get("tags") or [])[:10]]}, cld]
|
| 287 |
return page(request, title=f"{name} · {kind}", description=desc, path=path, body=body, jsonld=ld, image=f"/og{path}.png" if r else None, noindex=not r)
|
| 288 |
|
| 289 |
|
|
@@ -291,7 +293,7 @@ def simple(request: Request, path: str) -> HTMLResponse:
|
|
| 291 |
"""The app's other pages: community (indexed), a rollout, your rollouts, a comparison (not indexed: they change, or are personal)."""
|
| 292 |
if path.startswith("/community"):
|
| 293 |
return page(request, title="Community rollouts", path="/community",
|
| 294 |
-
description="Public
|
| 295 |
body="<header class=\"tp-head\"><h1>Community rollouts</h1><p class=\"lede\">Public rollouts from everyone, shown without who ran them.</p></header>")
|
| 296 |
titles = {"/runs": "My rollouts"}
|
| 297 |
return page(request, title=titles.get(path, "Rollout" if path.startswith("/run/") else "Compare rollouts"), path=path,
|
|
|
|
| 133 |
if not row:
|
| 134 |
return "RL environment"
|
| 135 |
if row.get("kind") == "space":
|
| 136 |
+
if row.get("fw") == "unverified-space":
|
| 137 |
+
return "Unverified Space"
|
| 138 |
return {"openenv": KIND["openenv"], "ors": "ORS Space"}.get(row.get("framework") or ("openenv" if row.get("openenv") else ""), KIND["space"])
|
| 139 |
return KIND.get(row.get("framework") or "", "RL dataset")
|
| 140 |
|
|
|
|
| 285 |
"sameAs": f"https://huggingface.co/spaces/{spec}", "applicationCategory": "DeveloperApplication", "operatingSystem": "Web",
|
| 286 |
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
|
| 287 |
"author": {"@type": "Organization", "name": spec.split("/")[0], "url": f"https://huggingface.co/{spec.split('/')[0]}"},
|
| 288 |
+
"keywords": ["reinforcement learning", "RL environment", *(["OpenEnv"] if (r or {}).get("openenv") else []), *((r or {}).get("tags") or [])[:10]]}, cld]
|
| 289 |
return page(request, title=f"{name} · {kind}", description=desc, path=path, body=body, jsonld=ld, image=f"/og{path}.png" if r else None, noindex=not r)
|
| 290 |
|
| 291 |
|
|
|
|
| 293 |
"""The app's other pages: community (indexed), a rollout, your rollouts, a comparison (not indexed: they change, or are personal)."""
|
| 294 |
if path.startswith("/community"):
|
| 295 |
return page(request, title="Community rollouts", path="/community",
|
| 296 |
+
description="Public, graded RL rollouts across Harbor, MiMo, NeMo Gym and other supported runners. Explore traces and model results grouped by environment and scoring metric.",
|
| 297 |
body="<header class=\"tp-head\"><h1>Community rollouts</h1><p class=\"lede\">Public rollouts from everyone, shown without who ran them.</p></header>")
|
| 298 |
titles = {"/runs": "My rollouts"}
|
| 299 |
return page(request, title=titles.get(path, "Rollout" if path.startswith("/run/") else "Compare rollouts"), path=path,
|
app/snapshot.py
CHANGED
|
@@ -331,8 +331,21 @@ def size_bucket(n: int | None) -> str:
|
|
| 331 |
return "Not indexed yet" if n is None else "Under 100" if n < 100 else "100 to 1,000" if n < 1000 else "1,000 or more"
|
| 332 |
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
def stage_value(stage: Any) -> str:
|
| 335 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
|
| 338 |
def blob_of(r: dict[str, Any]) -> str:
|
|
|
|
| 331 |
return "Not indexed yet" if n is None else "Under 100" if n < 100 else "100 to 1,000" if n < 1000 else "1,000 or more"
|
| 332 |
|
| 333 |
|
| 334 |
+
SPACE_STAGES = {
|
| 335 |
+
"RUNNING": "Running", "SLEEPING": "Sleeping", "PAUSED": "Paused", "STOPPED": "Paused",
|
| 336 |
+
"BUILDING": "Building", "APP_STARTING": "Starting", "RUNNING_APP_STARTING": "Starting",
|
| 337 |
+
"RUNNING_BUILDING": "Building", "BUILD_ERROR": "Error", "RUNTIME_ERROR": "Error",
|
| 338 |
+
"CONFIG_ERROR": "Error", "NO_APP_FILE": "Error", "DELETING": "Deleting",
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
def stage_value(stage: Any) -> str:
|
| 343 |
+
return SPACE_STAGES.get(stage, "Unknown")
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def stage_sql(column: str) -> str:
|
| 347 |
+
"""Also classify old snapshots from their raw runtime, without trusting the old broad stage_f bucket."""
|
| 348 |
+
return "CASE " + column + " " + " ".join(f"WHEN '{k}' THEN '{v}'" for k, v in SPACE_STAGES.items()) + " ELSE 'Unknown' END"
|
| 349 |
|
| 350 |
|
| 351 |
def blob_of(r: dict[str, Any]) -> str:
|
app/space_checks.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recent, non-executing OpenEnv API evidence, separate from the immutable catalog.
|
| 2 |
+
|
| 3 |
+
No wake, reset, step or tool invocation. Only public running Spaces are probed.
|
| 4 |
+
Per-Space atomic files work on both local storage and the mounted bucket. Search
|
| 5 |
+
reads a bounded cached inventory; it never waits for the network.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
import threading
|
| 14 |
+
import time
|
| 15 |
+
import tomllib
|
| 16 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 17 |
+
|
| 18 |
+
import httpx
|
| 19 |
+
from packaging.requirements import InvalidRequirement, Requirement
|
| 20 |
+
from packaging.version import InvalidVersion, Version
|
| 21 |
+
|
| 22 |
+
from . import catalog, config
|
| 23 |
+
|
| 24 |
+
FRESH = 3600
|
| 25 |
+
REFRESH = 1800
|
| 26 |
+
PASS = "API checked"
|
| 27 |
+
UNKNOWN = "Not checked"
|
| 28 |
+
_cache = (None, 0.0, {})
|
| 29 |
+
_lock = threading.Lock()
|
| 30 |
+
_stop = threading.Event()
|
| 31 |
+
_worker = None
|
| 32 |
+
log = logging.getLogger("rlx")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _path(spec):
|
| 36 |
+
return config.STORAGE_DIR / "openenv-checks" / f"{catalog._slug(catalog.check_spec(spec))}.json"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def save(rec):
|
| 40 |
+
global _cache
|
| 41 |
+
path = _path(rec["id"])
|
| 42 |
+
with _lock:
|
| 43 |
+
try:
|
| 44 |
+
old = json.loads(path.read_bytes())
|
| 45 |
+
if old.get("checked_at", 0) > rec["checked_at"]:
|
| 46 |
+
return
|
| 47 |
+
except (OSError, ValueError):
|
| 48 |
+
pass
|
| 49 |
+
catalog.atomic_write(path, json.dumps(rec, separators=(",", ":")).encode())
|
| 50 |
+
_cache = (None, 0.0, {})
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def inventory():
|
| 54 |
+
"""Public evidence only; invalid or oversized files are ignored, never trusted."""
|
| 55 |
+
global _cache
|
| 56 |
+
directory = config.STORAGE_DIR / "openenv-checks"
|
| 57 |
+
with _lock:
|
| 58 |
+
if _cache[0] == directory and time.time() - _cache[1] < 15:
|
| 59 |
+
return _cache[2]
|
| 60 |
+
records = {}
|
| 61 |
+
for path in sorted(directory.glob("*.json"))[:20000]:
|
| 62 |
+
try:
|
| 63 |
+
if path.stat().st_size > 32 * 1024:
|
| 64 |
+
continue
|
| 65 |
+
r = json.loads(path.read_bytes())
|
| 66 |
+
spec = catalog.check_spec(r["id"])
|
| 67 |
+
if r.get("schema") == 1 and isinstance(r.get("checked_at"), (float, int)):
|
| 68 |
+
records[spec] = r
|
| 69 |
+
except (OSError, ValueError, KeyError, TypeError):
|
| 70 |
+
continue
|
| 71 |
+
_cache = (directory, time.time(), records)
|
| 72 |
+
return records
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def status(rec, now=None):
|
| 76 |
+
if not rec:
|
| 77 |
+
return UNKNOWN
|
| 78 |
+
age = (time.time() if now is None else now) - rec.get("checked_at", 0)
|
| 79 |
+
if age < 0 or age >= FRESH:
|
| 80 |
+
return "Check expired"
|
| 81 |
+
return rec.get("status") if rec.get("status") in (PASS, "Checks failed", "Not running", "Check unavailable") else UNKNOWN
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def verified(rec):
|
| 85 |
+
"""Only a fresh successful check of a running server establishes API support."""
|
| 86 |
+
return bool(rec and rec.get("stage") == "RUNNING" and status(rec) == PASS)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def browseable(rec):
|
| 90 |
+
"""A live, recognized environment API; this does not certify reward quality."""
|
| 91 |
+
return verified(rec) or bool(rec and rec.get("stage") == "RUNNING"
|
| 92 |
+
and status(rec) == "Checks failed"
|
| 93 |
+
and rec.get("interface") in ("nemo-gym", "ors"))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def environment_interface(api):
|
| 97 |
+
"""Other supported server contracts, kept distinct from OpenEnv evidence."""
|
| 98 |
+
if not isinstance(api, dict) or not isinstance(api.get("info"), dict) or not api["info"].get("version"):
|
| 99 |
+
return None
|
| 100 |
+
paths = api.get("paths")
|
| 101 |
+
if not isinstance(paths, dict):
|
| 102 |
+
return None
|
| 103 |
+
for name, routes in (("nemo-gym", ("/seed_session", "/verify")), ("ors", ("/create_session", "/{env_name}/call"))):
|
| 104 |
+
if all(isinstance(paths.get(p), dict) and isinstance(paths[p].get("post"), dict) for p in routes):
|
| 105 |
+
return name
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def tag_version(tags):
|
| 110 |
+
for tag in tags or []:
|
| 111 |
+
match = re.fullmatch(r"openenv-(\d[^\s]{0,70})", str(tag), re.I)
|
| 112 |
+
if match:
|
| 113 |
+
try:
|
| 114 |
+
return {"value": str(Version(match[1])), "source": "Hub tag"}
|
| 115 |
+
except InvalidVersion:
|
| 116 |
+
pass
|
| 117 |
+
return {"value": "Unknown", "source": "Unknown"}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def requirement_version(text):
|
| 121 |
+
try:
|
| 122 |
+
req = Requirement(text)
|
| 123 |
+
except InvalidRequirement:
|
| 124 |
+
return None
|
| 125 |
+
if req.name.lower().replace("_", "-") not in ("openenv", "openenv-core"):
|
| 126 |
+
return None # current OpenEnv and the earlier openenv-core distribution
|
| 127 |
+
if req.marker:
|
| 128 |
+
return {"value": "Conditional", "source": "Dependency constraint", "requirement": str(req)[:240]}
|
| 129 |
+
if req.url:
|
| 130 |
+
ref = re.search(r"@([^/#?\s]+)(?:#.*)?$", req.url)
|
| 131 |
+
return {"value": "git:" + ref[1][:64] if ref else "Unpinned source", "source": "Source reference"}
|
| 132 |
+
specs = list(req.specifier)
|
| 133 |
+
if len(specs) == 1 and specs[0].operator in ("==", "===") and "*" not in specs[0].version:
|
| 134 |
+
try:
|
| 135 |
+
return {"value": str(Version(specs[0].version)), "source": "Dependency pin"}
|
| 136 |
+
except InvalidVersion:
|
| 137 |
+
pass
|
| 138 |
+
return {"value": str(req.specifier) or "Unpinned", "source": "Dependency constraint"}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def parse_version(text, path):
|
| 142 |
+
"""Repository evidence, never an assertion about the installed runtime."""
|
| 143 |
+
if path.endswith(".toml") or path.endswith("uv.lock"):
|
| 144 |
+
try:
|
| 145 |
+
doc = tomllib.loads(text)
|
| 146 |
+
except (ValueError, RecursionError):
|
| 147 |
+
return None
|
| 148 |
+
if path.endswith("uv.lock"):
|
| 149 |
+
pins = []
|
| 150 |
+
for pkg in doc.get("package", []):
|
| 151 |
+
if not isinstance(pkg, dict) or pkg.get("name", "").replace("_", "-") not in ("openenv", "openenv-core"):
|
| 152 |
+
continue
|
| 153 |
+
source = pkg.get("source") or {}
|
| 154 |
+
if source.get("git"):
|
| 155 |
+
ref = source["git"].rsplit("#", 1)
|
| 156 |
+
pins.append({"value": "git:" + ref[-1][:64] if len(ref) == 2 else "Unpinned source", "source": "Lockfile"})
|
| 157 |
+
elif pkg.get("version"):
|
| 158 |
+
pins.append(requirement_version(f"openenv-core=={pkg['version']}"))
|
| 159 |
+
pins = [p for p in pins if p]
|
| 160 |
+
if len({p["value"] for p in pins}) == 1:
|
| 161 |
+
return {**pins[0], "source": "Lockfile"}
|
| 162 |
+
return {"value": "Multiple locked versions", "source": "Lockfile"} if pins else None
|
| 163 |
+
sources = doc.get("tool", {}).get("uv", {}).get("sources", {})
|
| 164 |
+
source = sources.get("openenv") or sources.get("openenv-core") or sources.get("openenv_core")
|
| 165 |
+
if isinstance(source, dict) and source.get("git"):
|
| 166 |
+
ref = source.get("rev") or source.get("tag") or source.get("branch")
|
| 167 |
+
return {"value": "git:" + str(ref)[:64] if ref else "Unpinned source", "source": "Source reference"}
|
| 168 |
+
if source:
|
| 169 |
+
return {"value": "Custom source", "source": "Source reference"}
|
| 170 |
+
deps = doc.get("project", {}).get("dependencies", [])
|
| 171 |
+
else:
|
| 172 |
+
deps = [line.split(" #", 1)[0].strip() for line in text.splitlines()]
|
| 173 |
+
for dep in deps:
|
| 174 |
+
if isinstance(dep, str) and (v := requirement_version(dep)):
|
| 175 |
+
return v
|
| 176 |
+
return None
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def declared_version(spec, tags):
|
| 180 |
+
from . import space_files as files
|
| 181 |
+
fallback = tag_version(tags)
|
| 182 |
+
try:
|
| 183 |
+
info = files._info(spec)
|
| 184 |
+
listing, _, _ = files.listing(info)
|
| 185 |
+
paths = [f["path"] for f in listing]
|
| 186 |
+
manifest = files.manifest_path(paths, "Dockerfile" if "Dockerfile" in paths else None, spec)
|
| 187 |
+
root = manifest.rsplit("/", 1)[0] if manifest and "/" in manifest else ""
|
| 188 |
+
keys = files.key_files(paths, root, manifest)
|
| 189 |
+
sizes = {f["path"]: f.get("size", 0) for f in listing}
|
| 190 |
+
reqs = [f"{root}/requirements.txt" if root else "requirements.txt", "requirements.txt"]
|
| 191 |
+
for path in dict.fromkeys([keys.get("lock"), keys.get("pyproject"), *reqs]):
|
| 192 |
+
if not path or path not in sizes or sizes[path] > 400 * 1024:
|
| 193 |
+
continue
|
| 194 |
+
text = files._fetch_text(spec, info["sha"], path, sizes[path])
|
| 195 |
+
if text and (v := parse_version(text, path)):
|
| 196 |
+
return {**v, "path": path, "revision": info["sha"]}
|
| 197 |
+
except Exception: # incomplete version evidence must not change API health
|
| 198 |
+
pass
|
| 199 |
+
return fallback
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def assess(api, health, metadata, schema, tools):
|
| 203 |
+
api = api if isinstance(api, dict) else {}
|
| 204 |
+
paths = api.get("paths") if isinstance(api.get("paths"), dict) else {}
|
| 205 |
+
def method(path, verb):
|
| 206 |
+
return isinstance(paths.get(path), dict) and isinstance(paths[path].get(verb), dict)
|
| 207 |
+
checks = {
|
| 208 |
+
"OpenAPI": isinstance(api.get("info"), dict) and isinstance(api["info"].get("version"), str),
|
| 209 |
+
"Health": isinstance(health, dict) and health.get("status") == "healthy",
|
| 210 |
+
"Metadata": isinstance(metadata, dict) and all(isinstance(metadata.get(k), str) for k in ("name", "description")),
|
| 211 |
+
"Schemas": isinstance(schema, dict) and all(isinstance(schema.get(k), dict) for k in ("action", "observation", "state")),
|
| 212 |
+
}
|
| 213 |
+
simulation = any(p in paths for p in ("/reset", "/step", "/state"))
|
| 214 |
+
mode = "Simulation" if simulation else "MCP service"
|
| 215 |
+
checks["Control routes" if simulation else "MCP tools"] = (
|
| 216 |
+
all(method(p, v) for p, v in (("/reset", "post"), ("/step", "post"), ("/state", "get")))
|
| 217 |
+
if simulation else method("/mcp", "post") and bool(tools))
|
| 218 |
+
return {"status": PASS if all(checks.values()) else "Checks failed", "mode": mode if all(checks.values()) else None,
|
| 219 |
+
"failed": [k for k, ok in checks.items() if not ok], "tools": len(tools or [])}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def check(spec):
|
| 223 |
+
from . import spaces_live as live
|
| 224 |
+
rec = {"schema": 1, "id": catalog.check_spec(spec), "checked_at": time.time(), "status": "Check unavailable"}
|
| 225 |
+
previous = inventory().get(spec, {})
|
| 226 |
+
rec["version"] = previous.get("version") or {"value": "Unknown", "source": "Unknown"}
|
| 227 |
+
try:
|
| 228 |
+
hub = live.record(spec, fresh=True)
|
| 229 |
+
rec["stage"] = hub["stage"]
|
| 230 |
+
if hub["stage"] != "RUNNING":
|
| 231 |
+
rec["status"] = "Not running"
|
| 232 |
+
elif hub.get("host"):
|
| 233 |
+
urls = ("/openapi.json", "/health", "/metadata", "/schema")
|
| 234 |
+
def get(path):
|
| 235 |
+
try:
|
| 236 |
+
with live.SpaceClient(timeout=httpx.Timeout(6, connect=4), follow_redirects=False) as client:
|
| 237 |
+
response = client.get(live._url(hub, path))
|
| 238 |
+
return response.json() if response.status_code == 200 and "json" in response.headers.get("content-type", "") else None
|
| 239 |
+
except (httpx.HTTPError, ValueError, live.SpaceError):
|
| 240 |
+
return None
|
| 241 |
+
with ThreadPoolExecutor(4) as pool:
|
| 242 |
+
api, health, meta, schema = list(pool.map(get, urls))
|
| 243 |
+
# MCP discovery is separate from typed-action OpenEnv compatibility.
|
| 244 |
+
paths = api.get("paths", {}) if isinstance(api, dict) else {}
|
| 245 |
+
typed = isinstance(schema, dict) and all(isinstance(schema.get(k), dict) for k in ("action", "observation", "state"))
|
| 246 |
+
tools, _, _ = live._tools({**hub, "mcp_standard": not typed}) if isinstance(paths, dict) and "/mcp" in paths else (None, None, None)
|
| 247 |
+
rec.update(assess(api, health, meta, schema, tools))
|
| 248 |
+
rec["interface"] = environment_interface(api)
|
| 249 |
+
from . import space_tasks
|
| 250 |
+
task_api = space_tasks.discover(hub, paths)
|
| 251 |
+
rec["task_catalog"] = space_tasks.summary(task_api)
|
| 252 |
+
if rec["status"] == PASS:
|
| 253 |
+
rec["version"] = declared_version(spec, hub.get("tags"))
|
| 254 |
+
elif rec["version"]["value"] == "Unknown":
|
| 255 |
+
rec["version"] = tag_version(hub.get("tags"))
|
| 256 |
+
except Exception as exc:
|
| 257 |
+
rec["error"] = type(exc).__name__ # no upstream response/secret in public inventory
|
| 258 |
+
save(rec)
|
| 259 |
+
return rec
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def observe(spec, info):
|
| 263 |
+
"""Opening/rechecking a Space also updates discovery, including negative results."""
|
| 264 |
+
try:
|
| 265 |
+
old = inventory().get(spec, {})
|
| 266 |
+
rec = {"schema": 1, "id": spec, "checked_at": info.get("checked") or time.time(), "stage": info.get("stage"),
|
| 267 |
+
"version": old.get("version") or {"value": "Unknown", "source": "Unknown"}}
|
| 268 |
+
if not info.get("running"):
|
| 269 |
+
rec["status"] = "Not running"
|
| 270 |
+
else:
|
| 271 |
+
rec.update(assess({"info": {"version": info.get("openapi_version")}, "paths": info.get("endpoint_methods")},
|
| 272 |
+
info.get("health"), info.get("metadata"), info.get("schema"), info.get("mcp")))
|
| 273 |
+
rec["interface"] = environment_interface({"info": {"version": info.get("openapi_version")}, "paths": info.get("endpoint_methods")})
|
| 274 |
+
from . import space_tasks
|
| 275 |
+
rec["task_catalog"] = space_tasks.summary(info.get("task_api"))
|
| 276 |
+
info["openenv_verified"] = verified(rec)
|
| 277 |
+
save(rec)
|
| 278 |
+
except Exception:
|
| 279 |
+
log.warning("Could not save OpenEnv check for %s", spec)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def scan():
|
| 283 |
+
# App workers and an operator's CLI refresh share one bounded probe queue.
|
| 284 |
+
# Use local cache storage for this lock, never a mounted object-store bucket.
|
| 285 |
+
try:
|
| 286 |
+
import fcntl
|
| 287 |
+
except ImportError: # Windows development: the app still has one worker thread
|
| 288 |
+
return _scan()
|
| 289 |
+
config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 290 |
+
with (config.CACHE_DIR / "openenv-checks.lock").open("a") as lease:
|
| 291 |
+
try:
|
| 292 |
+
fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 293 |
+
except BlockingIOError:
|
| 294 |
+
return []
|
| 295 |
+
return _scan()
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _scan():
|
| 299 |
+
from . import snapshot
|
| 300 |
+
old = inventory()
|
| 301 |
+
with snapshot.use() as (_, conn):
|
| 302 |
+
candidates = list(conn.execute("SELECT id, stage FROM envs WHERE kind = 'space' ORDER BY trending DESC, likes DESC"))
|
| 303 |
+
due = pending(candidates, old)
|
| 304 |
+
def visit(spec):
|
| 305 |
+
if not _stop.is_set():
|
| 306 |
+
return check(spec)
|
| 307 |
+
with ThreadPoolExecutor(4) as pool:
|
| 308 |
+
return [r for r in pool.map(visit, due) if r]
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def pending(candidates, old, now=None):
|
| 312 |
+
"""Bound the queue and reserve capacity for inactive candidates too.
|
| 313 |
+
|
| 314 |
+
Never probing sleeping hosts, but refreshing their Hub metadata lets a
|
| 315 |
+
newly started Space enter the catalog without someone opening its page.
|
| 316 |
+
"""
|
| 317 |
+
now = time.time() if now is None else now
|
| 318 |
+
active, inactive = [], []
|
| 319 |
+
for spec, stage in candidates:
|
| 320 |
+
rec = old.get(spec, {})
|
| 321 |
+
running = rec.get("stage", stage) == "RUNNING"
|
| 322 |
+
at = rec.get("checked_at", 0)
|
| 323 |
+
interval = REFRESH if running else 6 * 3600
|
| 324 |
+
if not rec or now - at >= interval or (running and status(rec, now) == PASS and "task_catalog" not in rec):
|
| 325 |
+
(active if running else inactive).append((spec, at))
|
| 326 |
+
# Once checked, older entries take precedence so a failing popular Space
|
| 327 |
+
# cannot starve the long tail. FineEnvs goes first within each generation.
|
| 328 |
+
key = lambda item: (int(item[1] // REFRESH), item[0].split("/", 1)[0].lower() != "fineenvs", item[1])
|
| 329 |
+
active.sort(key=key)
|
| 330 |
+
inactive.sort(key=key)
|
| 331 |
+
return [s for s, _ in active[:96] + inactive[:32]]
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def start():
|
| 335 |
+
global _worker
|
| 336 |
+
if os.environ.get("RLX_TEST_TMP") or os.environ.get("RLX_SPACE_CHECKS", "1") != "1" or (_worker and _worker.is_alive()):
|
| 337 |
+
return
|
| 338 |
+
_stop.clear()
|
| 339 |
+
def watch():
|
| 340 |
+
while not _stop.is_set():
|
| 341 |
+
try:
|
| 342 |
+
scan()
|
| 343 |
+
except Exception:
|
| 344 |
+
log.exception("OpenEnv API inventory refresh failed")
|
| 345 |
+
_stop.wait(60)
|
| 346 |
+
_worker = threading.Thread(target=watch, daemon=True, name="openenv-checks")
|
| 347 |
+
_worker.start()
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def stop():
|
| 351 |
+
_stop.set()
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
if __name__ == "__main__":
|
| 355 |
+
from collections import Counter
|
| 356 |
+
from . import runtime
|
| 357 |
+
runtime.hub_timeouts()
|
| 358 |
+
print(dict(Counter(r["status"] for r in scan())), flush=True)
|
app/space_tasks.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounded discovery of the public OpenEnv Task API, without starting episodes.
|
| 2 |
+
|
| 3 |
+
These are server-reported catalog sizes, not downloaded task records or proof that
|
| 4 |
+
an episode works. Keep their own timestamp so a health check cannot freshen them.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
import time
|
| 10 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 11 |
+
|
| 12 |
+
MAX_COUNT = 1_000_000_000
|
| 13 |
+
FRESH = 3600
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def count(value):
|
| 17 |
+
return value if type(value) is int and 0 <= value <= MAX_COUNT else None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def discover(rec, paths, envs=None):
|
| 21 |
+
from . import spaces_live as live
|
| 22 |
+
|
| 23 |
+
if "/{env_name}/splits" not in paths:
|
| 24 |
+
return None
|
| 25 |
+
if envs is None:
|
| 26 |
+
envs = live._get_json(rec, "/list_environments")
|
| 27 |
+
if not isinstance(envs, list):
|
| 28 |
+
return None
|
| 29 |
+
names = list(dict.fromkeys(e for e in envs if isinstance(e, str) and re.fullmatch(r"[\w.-]{1,80}", e) and e not in (".", "..")))
|
| 30 |
+
|
| 31 |
+
def read(name):
|
| 32 |
+
raw = live._get_json(rec, f"/{name}/splits")
|
| 33 |
+
splits, seen = [], set()
|
| 34 |
+
for s in raw[:40] if isinstance(raw, list) else []:
|
| 35 |
+
if not isinstance(s, dict) or not isinstance(s.get("name"), str) or len(s["name"]) > 200 or s["name"] in seen:
|
| 36 |
+
continue
|
| 37 |
+
seen.add(s["name"])
|
| 38 |
+
n = count(s.get("num_tasks"))
|
| 39 |
+
if n is None and "/{env_name}/num_tasks" in paths and len(splits) < 12:
|
| 40 |
+
try:
|
| 41 |
+
r = live._client.post(live._url(rec, f"/{name}/num_tasks"), json={"split": s["name"]}, timeout=6)
|
| 42 |
+
doc = live._json(r) if r.status_code == 200 else None
|
| 43 |
+
n = count(doc.get("num_tasks")) if isinstance(doc, dict) else None
|
| 44 |
+
except Exception:
|
| 45 |
+
pass
|
| 46 |
+
splits.append({"name": s["name"], "type": s.get("type"), "num_tasks": n, "default": s.get("default") is True})
|
| 47 |
+
return {"env": name, "splits": splits, "complete": isinstance(raw, list) and len(raw) <= 40 and len(seen) == len(raw)}
|
| 48 |
+
|
| 49 |
+
with ThreadPoolExecutor(4) as pool:
|
| 50 |
+
environments = list(pool.map(read, names[:16]))
|
| 51 |
+
if not environments:
|
| 52 |
+
return None
|
| 53 |
+
return {"env": environments[0]["env"], "splits": environments[0]["splits"], "environments": environments,
|
| 54 |
+
"complete": len(names) <= 16 and all(e["complete"] for e in environments), "checked_at": time.time()}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def summary(task_api):
|
| 58 |
+
if not isinstance(task_api, dict):
|
| 59 |
+
return None
|
| 60 |
+
envs = task_api.get("environments") or [task_api]
|
| 61 |
+
entries, unknown, seen = [], 0, set()
|
| 62 |
+
for env in envs[:16]:
|
| 63 |
+
for split in env.get("splits", [])[:40]:
|
| 64 |
+
key = (env.get("env"), split.get("name"))
|
| 65 |
+
if key in seen:
|
| 66 |
+
continue
|
| 67 |
+
seen.add(key)
|
| 68 |
+
n = count(split.get("num_tasks"))
|
| 69 |
+
if n is None:
|
| 70 |
+
unknown += 1
|
| 71 |
+
else:
|
| 72 |
+
entries.append(n)
|
| 73 |
+
return {"tasks": sum(entries), "counted_splits": len(entries), "unknown_splits": unknown,
|
| 74 |
+
"complete": bool(task_api.get("complete", True)) and not unknown,
|
| 75 |
+
"checked_at": task_api.get("checked_at") or time.time()}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def census(records, visible, now=None):
|
| 79 |
+
now = time.time() if now is None else now
|
| 80 |
+
out = {"tasks": 0, "spaces": 0, "partial_spaces": 0, "oldest_check": None}
|
| 81 |
+
for spec in visible:
|
| 82 |
+
rec = records.get(spec, {})
|
| 83 |
+
s = rec.get("task_catalog") or {}
|
| 84 |
+
at = s.get("checked_at", 0)
|
| 85 |
+
if rec.get("stage") != "RUNNING" or not isinstance(at, (int, float)) or not 0 <= now - at < FRESH or not s.get("counted_splits"):
|
| 86 |
+
continue
|
| 87 |
+
n = s.get("tasks")
|
| 88 |
+
if type(n) is not int or not 0 <= n <= MAX_COUNT * 640:
|
| 89 |
+
continue
|
| 90 |
+
out["tasks"] += n
|
| 91 |
+
out["spaces"] += 1
|
| 92 |
+
out["partial_spaces"] += not s.get("complete")
|
| 93 |
+
out["oldest_check"] = min(out["oldest_check"] or at, at)
|
| 94 |
+
return out
|
app/spaces_live.py
CHANGED
|
@@ -20,6 +20,8 @@ import re
|
|
| 20 |
import secrets
|
| 21 |
import threading
|
| 22 |
import time
|
|
|
|
|
|
|
| 23 |
from concurrent.futures import ThreadPoolExecutor
|
| 24 |
from typing import Any
|
| 25 |
|
|
@@ -38,9 +40,6 @@ STANDARD_ROUTES = {"/reset", "/step", "/state", "/metadata", "/health", "/health
|
|
| 38 |
"/{env_name}/splits", "/{env_name}/tasks", "/{env_name}/num_tasks", "/{env_name}/task", "/{env_name}/task_range",
|
| 39 |
"/web/metadata", "/web/reset", "/web/step", "/web/state", "/docs", "/redoc", "/openapi.json"}
|
| 40 |
MAX_REPLY = 12 * 2**20 # bytes from a Space for one reply (observations can carry images)
|
| 41 |
-
_client = httpx.Client(timeout=httpx.Timeout(12, connect=6), follow_redirects=False,
|
| 42 |
-
headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"},
|
| 43 |
-
limits=httpx.Limits(max_connections=64, max_keepalive_connections=16))
|
| 44 |
_lock = threading.Lock()
|
| 45 |
|
| 46 |
|
|
@@ -52,6 +51,48 @@ class SpaceError(Exception):
|
|
| 52 |
self.status = status
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# ── the Space's record ───────────────────────────────────────────────────────
|
| 56 |
def record(spec: str, fresh: bool = False) -> dict[str, Any]:
|
| 57 |
"""Host, stage and declared UI path of a public Space (cached a few seconds: the page polls it while it wakes)."""
|
|
@@ -99,7 +140,7 @@ def _get_here(rec: dict, path: str, hops: int = 3) -> httpx.Response | None:
|
|
| 99 |
if r.status_code not in (301, 302, 303, 307, 308):
|
| 100 |
return r
|
| 101 |
nxt = r.url.join(r.headers.get("location", ""))
|
| 102 |
-
if nxt.scheme != "https" or nxt.host != httpx.URL(rec["host"]).host:
|
| 103 |
return None
|
| 104 |
url = str(nxt)
|
| 105 |
return None
|
|
@@ -133,6 +174,13 @@ def _get_json(rec: dict, path: str) -> Any:
|
|
| 133 |
def mcp_call(rec: dict, method: str, params: dict | None = None, timeout: float = 30, raw: bool = False) -> Any:
|
| 134 |
"""One JSON-RPC call to the Space's /mcp. Returns `result`; raises SpaceError with the server's message on error.
|
| 135 |
`raw`: the whole JSON-RPC reply, an error reply included (HTTP failures still raise)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
try:
|
| 137 |
r = _client.post(_url(rec, "/mcp"), timeout=httpx.Timeout(timeout, connect=6),
|
| 138 |
headers={"Accept": "application/json, text/event-stream", "Content-Type": "application/json"},
|
|
@@ -157,6 +205,100 @@ def mcp_call(rec: dict, method: str, params: dict | None = None, timeout: float
|
|
| 157 |
return doc.get("result")
|
| 158 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
def _tools(rec: dict) -> tuple[list[dict] | None, str | None, dict]:
|
| 161 |
"""Its MCP tools (or why there are none), and how /mcp answered: a JSON-RPC 2.0 reply, as `openenv validate`
|
| 162 |
asks of it, or the failure."""
|
|
@@ -182,8 +324,6 @@ def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]:
|
|
| 182 |
own plumbing (geoguesser's /geoguesser/task/{i} returns the answer's coordinates). GET and POST only."""
|
| 183 |
if openenv or not isinstance(api, dict) or not isinstance(api.get("paths"), dict):
|
| 184 |
return []
|
| 185 |
-
comps = ((api.get("components") or {}).get("schemas") or {}) if isinstance(api.get("components"), dict) else {}
|
| 186 |
-
|
| 187 |
def resolve(x: Any, depth: int = 0) -> Any:
|
| 188 |
if not isinstance(x, (dict, list)):
|
| 189 |
return x
|
|
@@ -191,7 +331,12 @@ def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]:
|
|
| 191 |
return {} if isinstance(x, dict) else []
|
| 192 |
if isinstance(x, dict):
|
| 193 |
if "$ref" in x and isinstance(x["$ref"], str):
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return {k: resolve(v, depth + 1) for k, v in x.items() if k != "$defs"}
|
| 196 |
if isinstance(x, list):
|
| 197 |
return [resolve(v, depth + 1) for v in x[:50]]
|
|
@@ -203,7 +348,9 @@ def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]:
|
|
| 203 |
if (not isinstance(ops, dict) or not isinstance(path, str) or not re.match(r"^/(?!/)[\w\-.~{}/]{0,199}$", path)
|
| 204 |
or ".." in path):
|
| 205 |
continue
|
| 206 |
-
|
|
|
|
|
|
|
| 207 |
continue
|
| 208 |
if openenv and path in ("/healthz",):
|
| 209 |
continue
|
|
@@ -213,15 +360,24 @@ def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]:
|
|
| 213 |
continue
|
| 214 |
body = (((op.get("requestBody") or {}).get("content") or {}).get("application/json") or {}).get("schema")
|
| 215 |
schema = resolve(body) if body else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
out.append({"method": method.upper(), "path": path, "summary": str(op.get("summary") or op.get("description") or "")[:200],
|
| 217 |
"params": re.findall(r"\{([A-Za-z_][\w-]{0,40})\}", path),
|
| 218 |
-
"schema": schema if isinstance(schema, dict)
|
|
|
|
| 219 |
if len(out) >= 40:
|
| 220 |
break
|
| 221 |
return out
|
| 222 |
|
| 223 |
|
| 224 |
-
def route_url(info: dict, method: str, path: str, params: dict[str, Any]) -> str:
|
| 225 |
"""A route the server published, its path parameters filled in safely; anything else is refused."""
|
| 226 |
route = next((r for r in info.get("api") or [] if r["method"] == method and r["path"] == path), None)
|
| 227 |
if route is None:
|
|
@@ -229,9 +385,29 @@ def route_url(info: dict, method: str, path: str, params: dict[str, Any]) -> str
|
|
| 229 |
filled = path
|
| 230 |
for name in route["params"]:
|
| 231 |
v = str((params or {}).get(name, ""))
|
| 232 |
-
if not re.
|
| 233 |
raise SpaceError(f"{name}: letters, digits, . - _ ~ only", 400)
|
| 234 |
filled = filled.replace("{" + name + "}", v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
return filled
|
| 236 |
|
| 237 |
|
|
@@ -239,8 +415,12 @@ def probe(spec: str, fresh: bool = False) -> dict[str, Any]:
|
|
| 239 |
"""Everything a running server says about itself, in one record. Asleep or broken: just the stage."""
|
| 240 |
rec = record(spec, fresh=fresh)
|
| 241 |
if rec["stage"] != UP or not rec["host"]:
|
| 242 |
-
|
|
|
|
| 243 |
"last_seen": last_seen(rec["id"])}
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
def fetch():
|
| 246 |
with ThreadPoolExecutor(6) as pool:
|
|
@@ -253,23 +433,11 @@ def probe(spec: str, fresh: bool = False) -> dict[str, Any]:
|
|
| 253 |
api, meta, schema, health, envs, ui = (f.result() for f in (f_api, f_meta, f_schema, f_health, f_envs, f_ui))
|
| 254 |
routes = sorted((api or {}).get("paths", {}).keys()) if isinstance(api, dict) else []
|
| 255 |
has = set(routes)
|
| 256 |
-
openenv = isinstance(schema, dict) and (
|
| 257 |
-
tools, tools_error, rpc = _tools(rec) if ("/mcp" in has or openenv) else (None, None, None)
|
| 258 |
env_names = [e for e in envs if isinstance(e, str) and re.match(r"^[\w.-]{1,80}$", e)] if isinstance(envs, list) else []
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
if task_api:
|
| 262 |
-
raw = _get_json(rec, f"/{env_names[0]}/splits")
|
| 263 |
-
for s in (raw or [])[:40] if isinstance(raw, list) else []:
|
| 264 |
-
if isinstance(s, dict) and isinstance(s.get("name"), str):
|
| 265 |
-
splits.append({"name": s["name"], "type": s.get("type"), "num_tasks": s.get("num_tasks"), "default": s.get("default")})
|
| 266 |
-
missing = [s for s in splits if not isinstance(s["num_tasks"], int)][:12]
|
| 267 |
-
for s in missing:
|
| 268 |
-
try:
|
| 269 |
-
r = _client.post(_url(rec, f"/{env_names[0]}/num_tasks"), json={"split": s["name"]}, timeout=8)
|
| 270 |
-
s["num_tasks"] = int(_json(r).get("num_tasks")) if r.status_code == 200 else None
|
| 271 |
-
except Exception: # noqa: BLE001
|
| 272 |
-
s["num_tasks"] = None
|
| 273 |
names = {t["name"] for t in tools or []}
|
| 274 |
harbor = {"run_rollout", "capabilities"} <= names or "harbor_env" in env_names
|
| 275 |
caps = _harbor_caps(rec) if "capabilities" in names and harbor else None
|
|
@@ -277,30 +445,37 @@ def probe(spec: str, fresh: bool = False) -> dict[str, Any]:
|
|
| 277 |
version = (api.get("info") or {}).get("version") if isinstance(api, dict) and isinstance(api.get("info"), dict) else None
|
| 278 |
info = {
|
| 279 |
"running": True, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"],
|
| 280 |
-
"ui": ui, "openenv": openenv
|
| 281 |
"metadata": meta if isinstance(meta, dict) else None, "health": health if isinstance(health, dict) else None,
|
| 282 |
"schema": schema if isinstance(schema, dict) else None,
|
| 283 |
-
"step_api": "/reset" in has and "/step" in has, "docs": "/openapi.json" in has or api is not None,
|
| 284 |
"mcp": tools, "mcp_error": None if tools else tools_error,
|
| 285 |
-
"task_api":
|
| 286 |
"harbor": harbor, "harbor_caps": caps,
|
| 287 |
"rewards": bool(isinstance(obs, dict) and "reward" in (obs.get("properties") or {})),
|
| 288 |
"graders": sorted(n for n in names if re.search(r"(grade|score|submit|verify|reward|judge)", n, re.I)),
|
| 289 |
"routes": [r for r in routes if r not in STANDARD_ROUTES][:40],
|
| 290 |
-
"api": _api_routes(api, openenv
|
| 291 |
# which framework the server speaks, from what it serves
|
| 292 |
-
"framework": "harbor" if harbor else "openenv" if
|
| 293 |
"nemo-gym" if {"/seed_session", "/verify"} <= has else
|
| 294 |
-
"ors" if {"/create_session", "/{env_name}/call"} <= has else
|
|
|
|
| 295 |
"openapi_version": version if isinstance(version, str) else None,
|
|
|
|
|
|
|
| 296 |
# OpenEnv's two modes: simulation serves /reset, /step and /state; production serves only MCP and /ws
|
| 297 |
-
"mode": ("simulation" if "/reset" in has else "production") if
|
| 298 |
"conformance": conformance({"openapi": api, "health": health, "metadata": meta, "schema": schema, "mcp": rpc,
|
| 299 |
"mcp_tried": "/mcp" in has or openenv, "paths": routes}),
|
| 300 |
"checked": time.time(),
|
| 301 |
}
|
| 302 |
if api is not None or isinstance(schema, dict) or isinstance(meta, dict) or tools: # it answered: keep it
|
| 303 |
remember(rec["id"], info)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
return info
|
| 305 |
|
| 306 |
if fresh:
|
|
@@ -518,17 +693,20 @@ def _withhold(v: Any, depth: int = 0) -> tuple[Any, list[str]]:
|
|
| 518 |
return v, gone
|
| 519 |
|
| 520 |
|
| 521 |
-
def _task_api(spec: str) -> tuple[dict, str]:
|
| 522 |
info = probe(spec)
|
| 523 |
if not info.get("running"):
|
| 524 |
raise SpaceError("this Space isn't running: wake it first", 409)
|
| 525 |
if not info.get("task_api"):
|
| 526 |
raise SpaceError("this server has no Task API", 404)
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
|
| 530 |
-
def tasks(spec: str, split: str, start: int, stop: int) -> dict[str, Any]:
|
| 531 |
-
rec, env = _task_api(spec)
|
| 532 |
start, stop = max(0, int(start)), max(0, int(stop))
|
| 533 |
stop = min(stop, start + 50)
|
| 534 |
try:
|
|
@@ -542,8 +720,8 @@ def tasks(spec: str, split: str, start: int, stop: int) -> dict[str, Any]:
|
|
| 542 |
return {"tasks": rows, "start": start, "stop": stop, "withheld": sorted(set(gone))}
|
| 543 |
|
| 544 |
|
| 545 |
-
def task(spec: str, split: str, index: int) -> dict[str, Any]:
|
| 546 |
-
rec, env = _task_api(spec)
|
| 547 |
try:
|
| 548 |
r = _client.post(_url(rec, f"/{env}/task"), json={"split": split, "index": int(index)}, timeout=20)
|
| 549 |
except httpx.HTTPError as e:
|
|
@@ -570,12 +748,18 @@ class Session:
|
|
| 570 |
self.created = self.used = time.time()
|
| 571 |
self.ws = None
|
| 572 |
self.ws_failed = False
|
|
|
|
| 573 |
self.http: httpx.Client | None = None # the session's own cookie jar for the server's routes (NeMo Gym keeps state in a cookie)
|
|
|
|
|
|
|
| 574 |
self.lock = threading.Lock()
|
| 575 |
self.steps = 0
|
| 576 |
self.rpc = 0
|
| 577 |
|
| 578 |
def close(self) -> None:
|
|
|
|
|
|
|
|
|
|
| 579 |
if self.http is not None:
|
| 580 |
try:
|
| 581 |
self.http.close()
|
|
@@ -625,6 +809,8 @@ def start(spec: str, owner: str) -> dict[str, Any]:
|
|
| 625 |
if len(_sessions) >= TOTAL:
|
| 626 |
raise SpaceError("too many live sessions on this explorer right now: try again in a few minutes", 429)
|
| 627 |
s = Session(catalog.check_spec(spec), owner)
|
|
|
|
|
|
|
| 628 |
_sessions[s.id] = s
|
| 629 |
return {"session": s.id}
|
| 630 |
|
|
@@ -660,6 +846,10 @@ def _open(s: Session) -> None:
|
|
| 660 |
|
| 661 |
|
| 662 |
def _ws_send(s: Session, msg: dict, timeout: float) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
if s.ws is None:
|
| 664 |
_open(s)
|
| 665 |
try:
|
|
@@ -667,9 +857,11 @@ def _ws_send(s: Session, msg: dict, timeout: float) -> dict:
|
|
| 667 |
reply = json.loads(s.ws.recv(timeout=timeout))
|
| 668 |
except TimeoutError:
|
| 669 |
s.close()
|
|
|
|
| 670 |
raise SpaceError(f"the Space didn't answer within {int(timeout)} s; the session was closed", 504) from None
|
| 671 |
except Exception as e: # noqa: BLE001 - the socket dropped: the episode is gone
|
| 672 |
s.close()
|
|
|
|
| 673 |
raise SpaceError(f"the session dropped ({type(e).__name__}): start a new episode", 502) from None
|
| 674 |
if not isinstance(reply, dict):
|
| 675 |
raise SpaceError("the Space answered something unexpected", 502)
|
|
@@ -680,7 +872,7 @@ def _ws_send(s: Session, msg: dict, timeout: float) -> dict:
|
|
| 680 |
|
| 681 |
|
| 682 |
def _rpc(s: Session, method: str, params: dict, timeout: float) -> Any:
|
| 683 |
-
"""
|
| 684 |
if not s.ws_failed:
|
| 685 |
try:
|
| 686 |
s.rpc += 1
|
|
@@ -696,6 +888,12 @@ def _rpc(s: Session, method: str, params: dict, timeout: float) -> Any:
|
|
| 696 |
err = data["error"] if isinstance(data["error"], dict) else {"message": str(data["error"])}
|
| 697 |
raise SpaceError(str(err.get("message") or "error")[:600], 422)
|
| 698 |
return data.get("result") if isinstance(data, dict) else data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
return mcp_call(record(s.spec), method, params, timeout=timeout)
|
| 700 |
|
| 701 |
|
|
@@ -705,14 +903,15 @@ def _http(s: Session, data: dict) -> dict[str, Any]:
|
|
| 705 |
if method not in ("GET", "POST"):
|
| 706 |
raise SpaceError("GET or POST only", 400)
|
| 707 |
info = probe(s.spec)
|
| 708 |
-
path = route_url(info, method, str(data.get("path") or ""), data.get("params") if isinstance(data.get("params"), dict) else {}
|
|
|
|
| 709 |
rec = record(s.spec)
|
| 710 |
if s.http is None:
|
| 711 |
-
s.http =
|
| 712 |
headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"})
|
| 713 |
body = data.get("body")
|
| 714 |
try:
|
| 715 |
-
r = s.http.request(method, path, json=body if method == "POST" else None)
|
| 716 |
except httpx.TimeoutException:
|
| 717 |
raise SpaceError("the Space didn't answer within 120 s", 504) from None
|
| 718 |
except httpx.HTTPError as e:
|
|
@@ -773,7 +972,8 @@ def act(sid: str, owner: str, op: str, data: dict | None = None) -> dict[str, An
|
|
| 773 |
s.steps += 1
|
| 774 |
else:
|
| 775 |
raise SpaceError(f"unknown operation {op!r}", 400)
|
| 776 |
-
return {"result": out, "ms": int((time.time() - t0) * 1000), "steps": s.steps,
|
|
|
|
| 777 |
|
| 778 |
|
| 779 |
def live_sessions() -> int:
|
|
|
|
| 20 |
import secrets
|
| 21 |
import threading
|
| 22 |
import time
|
| 23 |
+
import zlib
|
| 24 |
+
from urllib.parse import urlencode
|
| 25 |
from concurrent.futures import ThreadPoolExecutor
|
| 26 |
from typing import Any
|
| 27 |
|
|
|
|
| 40 |
"/{env_name}/splits", "/{env_name}/tasks", "/{env_name}/num_tasks", "/{env_name}/task", "/{env_name}/task_range",
|
| 41 |
"/web/metadata", "/web/reset", "/web/step", "/web/state", "/docs", "/redoc", "/openapi.json"}
|
| 42 |
MAX_REPLY = 12 * 2**20 # bytes from a Space for one reply (observations can carry images)
|
|
|
|
|
|
|
|
|
|
| 43 |
_lock = threading.Lock()
|
| 44 |
|
| 45 |
|
|
|
|
| 51 |
self.status = status
|
| 52 |
|
| 53 |
|
| 54 |
+
class SpaceClient(httpx.Client):
|
| 55 |
+
"""Bound decompressed replies while receiving them, including chunked bodies.
|
| 56 |
+
|
| 57 |
+
Space operators control their responses. Checking Response.content after a
|
| 58 |
+
normal get() is too late: httpx has already buffered the complete response.
|
| 59 |
+
Keep httpx's cookie jar and connection pooling, but never buffer without a cap.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def send(self, request, *, stream=False, **kwargs):
|
| 63 |
+
# Negotiate only a codec with a bounded decompression API.
|
| 64 |
+
request.headers["Accept-Encoding"] = "gzip, identity"
|
| 65 |
+
response = super().send(request, stream=True, **kwargs)
|
| 66 |
+
try:
|
| 67 |
+
body = bytearray()
|
| 68 |
+
codec = response.headers.get("content-encoding", "identity").lower()
|
| 69 |
+
decoded = response.is_stream_consumed
|
| 70 |
+
if not decoded and codec not in {"identity", "gzip"}:
|
| 71 |
+
raise SpaceError("the Space used an unsupported response encoding", 502)
|
| 72 |
+
decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) if not decoded and codec == "gzip" else None
|
| 73 |
+
chunks = (response.content,) if decoded else response.iter_raw(chunk_size=64 * 1024)
|
| 74 |
+
for chunk in chunks:
|
| 75 |
+
if decoder:
|
| 76 |
+
chunk = decoder.decompress(chunk, MAX_REPLY - len(body) + 1)
|
| 77 |
+
if len(body) + len(chunk) > MAX_REPLY:
|
| 78 |
+
raise SpaceError("the Space's reply is too large to show", 502)
|
| 79 |
+
body.extend(chunk)
|
| 80 |
+
if decoder and (not decoder.eof or decoder.unused_data):
|
| 81 |
+
raise SpaceError("the Space returned an invalid compressed response", 502)
|
| 82 |
+
headers = [(k, v) for k, v in response.headers.multi_items()
|
| 83 |
+
if k.lower() not in ("content-encoding", "content-length", "transfer-encoding")]
|
| 84 |
+
return httpx.Response(response.status_code, headers=headers, content=bytes(body), request=request)
|
| 85 |
+
except zlib.error:
|
| 86 |
+
raise SpaceError("the Space returned an invalid compressed response", 502) from None
|
| 87 |
+
finally:
|
| 88 |
+
response.close()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
_client = SpaceClient(timeout=httpx.Timeout(12, connect=6), follow_redirects=False,
|
| 92 |
+
headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"},
|
| 93 |
+
limits=httpx.Limits(max_connections=64, max_keepalive_connections=16))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
# ── the Space's record ───────────────────────────────────────────────────────
|
| 97 |
def record(spec: str, fresh: bool = False) -> dict[str, Any]:
|
| 98 |
"""Host, stage and declared UI path of a public Space (cached a few seconds: the page polls it while it wakes)."""
|
|
|
|
| 140 |
if r.status_code not in (301, 302, 303, 307, 308):
|
| 141 |
return r
|
| 142 |
nxt = r.url.join(r.headers.get("location", ""))
|
| 143 |
+
if nxt.scheme != "https" or nxt.host != httpx.URL(rec["host"]).host or nxt.port not in (None, 443) or nxt.userinfo:
|
| 144 |
return None
|
| 145 |
url = str(nxt)
|
| 146 |
return None
|
|
|
|
| 174 |
def mcp_call(rec: dict, method: str, params: dict | None = None, timeout: float = 30, raw: bool = False) -> Any:
|
| 175 |
"""One JSON-RPC call to the Space's /mcp. Returns `result`; raises SpaceError with the server's message on error.
|
| 176 |
`raw`: the whole JSON-RPC reply, an error reply included (HTTP failures still raise)."""
|
| 177 |
+
if rec.get("mcp_standard"):
|
| 178 |
+
with SpaceClient(timeout=httpx.Timeout(timeout, connect=6), follow_redirects=False) as client:
|
| 179 |
+
remote = RemoteMCP(rec, client)
|
| 180 |
+
try:
|
| 181 |
+
return remote.call(method, params or {}, timeout, raw=raw)
|
| 182 |
+
finally:
|
| 183 |
+
remote.close()
|
| 184 |
try:
|
| 185 |
r = _client.post(_url(rec, "/mcp"), timeout=httpx.Timeout(timeout, connect=6),
|
| 186 |
headers={"Accept": "application/json, text/event-stream", "Content-Type": "application/json"},
|
|
|
|
| 205 |
return doc.get("result")
|
| 206 |
|
| 207 |
|
| 208 |
+
class RemoteMCP:
|
| 209 |
+
"""A Streamable HTTP MCP session, isolated along with the visitor's cookies.
|
| 210 |
+
|
| 211 |
+
No credentials from the explorer are forwarded. Expiry never replays an action:
|
| 212 |
+
callers must explicitly open a new episode after a 410 response.
|
| 213 |
+
"""
|
| 214 |
+
def __init__(self, rec: dict, client: httpx.Client):
|
| 215 |
+
self.url = _url(rec, "/mcp")
|
| 216 |
+
self.client = client
|
| 217 |
+
self.session = None
|
| 218 |
+
self.version = None
|
| 219 |
+
self.initialized = False
|
| 220 |
+
self.expired = False
|
| 221 |
+
|
| 222 |
+
def headers(self):
|
| 223 |
+
headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
|
| 224 |
+
if self.session:
|
| 225 |
+
headers["Mcp-Session-Id"] = self.session
|
| 226 |
+
if self.version:
|
| 227 |
+
headers["MCP-Protocol-Version"] = self.version
|
| 228 |
+
return headers
|
| 229 |
+
|
| 230 |
+
def request(self, method, params, timeout, *, notification=False):
|
| 231 |
+
rid = secrets.randbelow(10**9)
|
| 232 |
+
message = {"jsonrpc": "2.0", "method": method, "params": params}
|
| 233 |
+
if not notification:
|
| 234 |
+
message["id"] = rid
|
| 235 |
+
try:
|
| 236 |
+
r = self.client.post(self.url, headers=self.headers(), json=message, timeout=httpx.Timeout(timeout, connect=6))
|
| 237 |
+
except httpx.TimeoutException:
|
| 238 |
+
raise SpaceError("the MCP server timed out; the action was not retried", 504) from None
|
| 239 |
+
except httpx.HTTPError as e:
|
| 240 |
+
raise SpaceError(f"couldn't reach the MCP server: {type(e).__name__}", 502) from None
|
| 241 |
+
if self.session and r.status_code == 404:
|
| 242 |
+
self.expired = True
|
| 243 |
+
raise SpaceError("the MCP session has expired: start a new session", 410)
|
| 244 |
+
if notification and r.status_code in (200, 202, 204):
|
| 245 |
+
return {}
|
| 246 |
+
if r.status_code != 200:
|
| 247 |
+
raise SpaceError(f"the Space's /mcp answered HTTP {r.status_code}", 502)
|
| 248 |
+
try:
|
| 249 |
+
if "text/event-stream" in r.headers.get("content-type", ""):
|
| 250 |
+
# Notifications can precede the response; SSE data may span lines.
|
| 251 |
+
docs = [json.loads("\n".join(line[5:].lstrip() for line in event.splitlines() if line.startswith("data:")))
|
| 252 |
+
for event in r.text.replace("\r\n", "\n").split("\n\n") if any(line.startswith("data:") for line in event.splitlines())]
|
| 253 |
+
doc = next((d for d in docs if isinstance(d, dict) and d.get("id") == rid), None)
|
| 254 |
+
else:
|
| 255 |
+
doc = r.json()
|
| 256 |
+
except ValueError:
|
| 257 |
+
raise SpaceError("the MCP server returned invalid JSON", 502) from None
|
| 258 |
+
if not isinstance(doc, dict) or doc.get("jsonrpc") != "2.0" or doc.get("id") != rid:
|
| 259 |
+
raise SpaceError("the MCP server returned no matching JSON-RPC response", 502)
|
| 260 |
+
if method == "initialize" and not doc.get("error"):
|
| 261 |
+
sid = r.headers.get("mcp-session-id")
|
| 262 |
+
if sid and (len(sid) > 2048 or not all(0x21 <= ord(c) <= 0x7e for c in sid)):
|
| 263 |
+
raise SpaceError("the MCP server returned an invalid session ID", 502)
|
| 264 |
+
self.session = sid
|
| 265 |
+
return doc
|
| 266 |
+
|
| 267 |
+
def call(self, method, params, timeout, *, raw=False):
|
| 268 |
+
if self.expired:
|
| 269 |
+
raise SpaceError("the MCP session has expired: start a new session", 410)
|
| 270 |
+
if not self.initialized:
|
| 271 |
+
init = self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
| 272 |
+
"clientInfo": {"name": "hf-rl-explorer", "version": "0.1.0"}}, timeout)
|
| 273 |
+
error = init.get("error") or {}
|
| 274 |
+
if not isinstance(error, dict):
|
| 275 |
+
raise SpaceError("the MCP server returned an invalid initialization error", 502)
|
| 276 |
+
if error.get("code") != -32601: # compatibility with simple JSON-RPC tool servers
|
| 277 |
+
result = init.get("result") or {}
|
| 278 |
+
if not isinstance(result, dict):
|
| 279 |
+
raise SpaceError("the MCP server returned an invalid initialization result", 502)
|
| 280 |
+
self.version = result.get("protocolVersion")
|
| 281 |
+
if error or self.version not in {"2025-03-26", "2025-06-18", "2025-11-25"}:
|
| 282 |
+
raise SpaceError("the MCP server could not negotiate a supported protocol", 502)
|
| 283 |
+
self.request("notifications/initialized", {}, timeout, notification=True)
|
| 284 |
+
self.initialized = True
|
| 285 |
+
doc = self.request(method, params, timeout)
|
| 286 |
+
if raw:
|
| 287 |
+
return doc
|
| 288 |
+
if doc.get("error"):
|
| 289 |
+
error = doc["error"]
|
| 290 |
+
raise SpaceError(str(error.get("message", "MCP error") if isinstance(error, dict) else error)[:500], 422)
|
| 291 |
+
return doc.get("result")
|
| 292 |
+
|
| 293 |
+
def close(self):
|
| 294 |
+
if self.session and not self.expired:
|
| 295 |
+
try:
|
| 296 |
+
self.client.delete(self.url, headers=self.headers(), timeout=3)
|
| 297 |
+
except Exception: # best-effort cleanup of an untrusted remote server
|
| 298 |
+
pass
|
| 299 |
+
self.expired = True
|
| 300 |
+
|
| 301 |
+
|
| 302 |
def _tools(rec: dict) -> tuple[list[dict] | None, str | None, dict]:
|
| 303 |
"""Its MCP tools (or why there are none), and how /mcp answered: a JSON-RPC 2.0 reply, as `openenv validate`
|
| 304 |
asks of it, or the failure."""
|
|
|
|
| 324 |
own plumbing (geoguesser's /geoguesser/task/{i} returns the answer's coordinates). GET and POST only."""
|
| 325 |
if openenv or not isinstance(api, dict) or not isinstance(api.get("paths"), dict):
|
| 326 |
return []
|
|
|
|
|
|
|
| 327 |
def resolve(x: Any, depth: int = 0) -> Any:
|
| 328 |
if not isinstance(x, (dict, list)):
|
| 329 |
return x
|
|
|
|
| 331 |
return {} if isinstance(x, dict) else []
|
| 332 |
if isinstance(x, dict):
|
| 333 |
if "$ref" in x and isinstance(x["$ref"], str):
|
| 334 |
+
target = api
|
| 335 |
+
if not x["$ref"].startswith("#/"):
|
| 336 |
+
return {}
|
| 337 |
+
for key in x["$ref"][2:].split("/"):
|
| 338 |
+
target = target.get(key.replace("~1", "/").replace("~0", "~"), {}) if isinstance(target, dict) else {}
|
| 339 |
+
return resolve(target, depth + 1)
|
| 340 |
return {k: resolve(v, depth + 1) for k, v in x.items() if k != "$defs"}
|
| 341 |
if isinstance(x, list):
|
| 342 |
return [resolve(v, depth + 1) for v in x[:50]]
|
|
|
|
| 348 |
if (not isinstance(ops, dict) or not isinstance(path, str) or not re.match(r"^/(?!/)[\w\-.~{}/]{0,199}$", path)
|
| 349 |
or ".." in path):
|
| 350 |
continue
|
| 351 |
+
# A generic Gymnasium server's reset/step are ordinary HTTP routes, not
|
| 352 |
+
# evidence that it implements OpenEnv's WebSocket envelope protocol.
|
| 353 |
+
if path in STANDARD_ROUTES - {"/reset", "/step", "/state"} or path.startswith(("/web", "/login", "/logout", "/docs", "/redoc", "/static", "/gradio", "/assets")):
|
| 354 |
continue
|
| 355 |
if openenv and path in ("/healthz",):
|
| 356 |
continue
|
|
|
|
| 360 |
continue
|
| 361 |
body = (((op.get("requestBody") or {}).get("content") or {}).get("application/json") or {}).get("schema")
|
| 362 |
schema = resolve(body) if body else None
|
| 363 |
+
parameters = {}
|
| 364 |
+
for p in [*(ops.get("parameters") or []), *(op.get("parameters") or [])]:
|
| 365 |
+
p = resolve(p)
|
| 366 |
+
if isinstance(p, dict) and p.get("in") == "query" and isinstance(p.get("name"), str):
|
| 367 |
+
parameters[p["name"]] = p
|
| 368 |
+
query = {"type": "object", "properties": {k: {**(p.get("schema") or {}), "description": p.get("description") or "Query parameter"}
|
| 369 |
+
for k, p in parameters.items()},
|
| 370 |
+
"required": [k for k, p in parameters.items() if p.get("required")], "additionalProperties": False}
|
| 371 |
out.append({"method": method.upper(), "path": path, "summary": str(op.get("summary") or op.get("description") or "")[:200],
|
| 372 |
"params": re.findall(r"\{([A-Za-z_][\w-]{0,40})\}", path),
|
| 373 |
+
"schema": schema if isinstance(schema, dict) else None,
|
| 374 |
+
"body_required": bool((op.get("requestBody") or {}).get("required")), "query_schema": query})
|
| 375 |
if len(out) >= 40:
|
| 376 |
break
|
| 377 |
return out
|
| 378 |
|
| 379 |
|
| 380 |
+
def route_url(info: dict, method: str, path: str, params: dict[str, Any], query: dict[str, Any] | None = None) -> str:
|
| 381 |
"""A route the server published, its path parameters filled in safely; anything else is refused."""
|
| 382 |
route = next((r for r in info.get("api") or [] if r["method"] == method and r["path"] == path), None)
|
| 383 |
if route is None:
|
|
|
|
| 385 |
filled = path
|
| 386 |
for name in route["params"]:
|
| 387 |
v = str((params or {}).get(name, ""))
|
| 388 |
+
if not re.fullmatch(r"[\w.\-~]{1,200}", v) or v in (".", ".."):
|
| 389 |
raise SpaceError(f"{name}: letters, digits, . - _ ~ only", 400)
|
| 390 |
filled = filled.replace("{" + name + "}", v)
|
| 391 |
+
query = query or {}
|
| 392 |
+
schema = route.get("query_schema") or {}
|
| 393 |
+
if set(query) - set(schema.get("properties") or {}):
|
| 394 |
+
raise SpaceError("only this route's published query parameters are allowed", 400)
|
| 395 |
+
for key in schema.get("required") or []:
|
| 396 |
+
if key not in query:
|
| 397 |
+
raise SpaceError(f"query parameter {key} is required", 400)
|
| 398 |
+
pairs = []
|
| 399 |
+
for key, value in query.items():
|
| 400 |
+
for v in value if isinstance(value, list) else [value]:
|
| 401 |
+
if v is None:
|
| 402 |
+
continue
|
| 403 |
+
if not isinstance(v, (str, int, float, bool)):
|
| 404 |
+
raise SpaceError(f"query parameter {key} must be a scalar or array of scalars", 400)
|
| 405 |
+
pairs.append((key, str(v).lower() if isinstance(v, bool) else str(v)))
|
| 406 |
+
encoded = urlencode(pairs)
|
| 407 |
+
if len(encoded) > 8000:
|
| 408 |
+
raise SpaceError("query parameters are too large", 400)
|
| 409 |
+
if encoded:
|
| 410 |
+
filled += "?" + encoded
|
| 411 |
return filled
|
| 412 |
|
| 413 |
|
|
|
|
| 415 |
"""Everything a running server says about itself, in one record. Asleep or broken: just the stage."""
|
| 416 |
rec = record(spec, fresh=fresh)
|
| 417 |
if rec["stage"] != UP or not rec["host"]:
|
| 418 |
+
from .envs.capabilities import space_support
|
| 419 |
+
info = {"running": False, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"],
|
| 420 |
"last_seen": last_seen(rec["id"])}
|
| 421 |
+
from . import space_checks
|
| 422 |
+
space_checks.observe(spec, info)
|
| 423 |
+
return {**info, "support": space_support(info)}
|
| 424 |
|
| 425 |
def fetch():
|
| 426 |
with ThreadPoolExecutor(6) as pool:
|
|
|
|
| 433 |
api, meta, schema, health, envs, ui = (f.result() for f in (f_api, f_meta, f_schema, f_health, f_envs, f_ui))
|
| 434 |
routes = sorted((api or {}).get("paths", {}).keys()) if isinstance(api, dict) else []
|
| 435 |
has = set(routes)
|
| 436 |
+
openenv = isinstance(schema, dict) and all(isinstance(schema.get(k), dict) for k in ("action", "observation", "state"))
|
| 437 |
+
tools, tools_error, rpc = _tools({**rec, "mcp_standard": not openenv}) if ("/mcp" in has or openenv) else (None, None, None)
|
| 438 |
env_names = [e for e in envs if isinstance(e, str) and re.match(r"^[\w.-]{1,80}$", e)] if isinstance(envs, list) else []
|
| 439 |
+
from . import space_tasks
|
| 440 |
+
task_api = space_tasks.discover(rec, has, env_names)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
names = {t["name"] for t in tools or []}
|
| 442 |
harbor = {"run_rollout", "capabilities"} <= names or "harbor_env" in env_names
|
| 443 |
caps = _harbor_caps(rec) if "capabilities" in names and harbor else None
|
|
|
|
| 445 |
version = (api.get("info") or {}).get("version") if isinstance(api, dict) and isinstance(api.get("info"), dict) else None
|
| 446 |
info = {
|
| 447 |
"running": True, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"],
|
| 448 |
+
"ui": ui, "openenv": openenv,
|
| 449 |
"metadata": meta if isinstance(meta, dict) else None, "health": health if isinstance(health, dict) else None,
|
| 450 |
"schema": schema if isinstance(schema, dict) else None,
|
| 451 |
+
"step_api": openenv and "/reset" in has and "/step" in has, "docs": "/openapi.json" in has or api is not None,
|
| 452 |
"mcp": tools, "mcp_error": None if tools else tools_error,
|
| 453 |
+
"task_api": task_api,
|
| 454 |
"harbor": harbor, "harbor_caps": caps,
|
| 455 |
"rewards": bool(isinstance(obs, dict) and "reward" in (obs.get("properties") or {})),
|
| 456 |
"graders": sorted(n for n in names if re.search(r"(grade|score|submit|verify|reward|judge)", n, re.I)),
|
| 457 |
"routes": [r for r in routes if r not in STANDARD_ROUTES][:40],
|
| 458 |
+
"api": _api_routes(api, openenv),
|
| 459 |
# which framework the server speaks, from what it serves
|
| 460 |
+
"framework": "harbor" if harbor else "openenv" if openenv else
|
| 461 |
"nemo-gym" if {"/seed_session", "/verify"} <= has else
|
| 462 |
+
"ors" if {"/create_session", "/{env_name}/call"} <= has else
|
| 463 |
+
"gymnasium" if {"/reset", "/step"} <= has else "mcp" if tools else "api" if api else None,
|
| 464 |
"openapi_version": version if isinstance(version, str) else None,
|
| 465 |
+
"endpoint_methods": {p: {m: {} for m in methods if m in ("get", "post")} for p, methods in (api or {}).get("paths", {}).items()
|
| 466 |
+
if p in ("/reset", "/step", "/state", "/mcp") and isinstance(methods, dict)} if isinstance(api, dict) else {},
|
| 467 |
# OpenEnv's two modes: simulation serves /reset, /step and /state; production serves only MCP and /ws
|
| 468 |
+
"mode": ("simulation" if "/reset" in has else "production") if openenv else None,
|
| 469 |
"conformance": conformance({"openapi": api, "health": health, "metadata": meta, "schema": schema, "mcp": rpc,
|
| 470 |
"mcp_tried": "/mcp" in has or openenv, "paths": routes}),
|
| 471 |
"checked": time.time(),
|
| 472 |
}
|
| 473 |
if api is not None or isinstance(schema, dict) or isinstance(meta, dict) or tools: # it answered: keep it
|
| 474 |
remember(rec["id"], info)
|
| 475 |
+
from .envs.capabilities import space_support
|
| 476 |
+
info["support"] = space_support(info)
|
| 477 |
+
from . import space_checks
|
| 478 |
+
space_checks.observe(spec, info)
|
| 479 |
return info
|
| 480 |
|
| 481 |
if fresh:
|
|
|
|
| 693 |
return v, gone
|
| 694 |
|
| 695 |
|
| 696 |
+
def _task_api(spec: str, env: str = "") -> tuple[dict, str]:
|
| 697 |
info = probe(spec)
|
| 698 |
if not info.get("running"):
|
| 699 |
raise SpaceError("this Space isn't running: wake it first", 409)
|
| 700 |
if not info.get("task_api"):
|
| 701 |
raise SpaceError("this server has no Task API", 404)
|
| 702 |
+
api = info["task_api"]
|
| 703 |
+
if env and env not in [e["env"] for e in api.get("environments", [api])]:
|
| 704 |
+
raise SpaceError("this environment is not in the Space's Task API", 400)
|
| 705 |
+
return record(spec), env or api["env"]
|
| 706 |
|
| 707 |
|
| 708 |
+
def tasks(spec: str, split: str, start: int, stop: int, env: str = "") -> dict[str, Any]:
|
| 709 |
+
rec, env = _task_api(spec, env)
|
| 710 |
start, stop = max(0, int(start)), max(0, int(stop))
|
| 711 |
stop = min(stop, start + 50)
|
| 712 |
try:
|
|
|
|
| 720 |
return {"tasks": rows, "start": start, "stop": stop, "withheld": sorted(set(gone))}
|
| 721 |
|
| 722 |
|
| 723 |
+
def task(spec: str, split: str, index: int, env: str = "") -> dict[str, Any]:
|
| 724 |
+
rec, env = _task_api(spec, env)
|
| 725 |
try:
|
| 726 |
r = _client.post(_url(rec, f"/{env}/task"), json={"split": split, "index": int(index)}, timeout=20)
|
| 727 |
except httpx.HTTPError as e:
|
|
|
|
| 748 |
self.created = self.used = time.time()
|
| 749 |
self.ws = None
|
| 750 |
self.ws_failed = False
|
| 751 |
+
self.broken = False # a dropped episode may only be recovered by an explicit reset
|
| 752 |
self.http: httpx.Client | None = None # the session's own cookie jar for the server's routes (NeMo Gym keeps state in a cookie)
|
| 753 |
+
self.remote_mcp: RemoteMCP | None = None
|
| 754 |
+
self.standard_mcp = False
|
| 755 |
self.lock = threading.Lock()
|
| 756 |
self.steps = 0
|
| 757 |
self.rpc = 0
|
| 758 |
|
| 759 |
def close(self) -> None:
|
| 760 |
+
if self.remote_mcp is not None:
|
| 761 |
+
self.remote_mcp.close()
|
| 762 |
+
self.remote_mcp = None
|
| 763 |
if self.http is not None:
|
| 764 |
try:
|
| 765 |
self.http.close()
|
|
|
|
| 809 |
if len(_sessions) >= TOTAL:
|
| 810 |
raise SpaceError("too many live sessions on this explorer right now: try again in a few minutes", 429)
|
| 811 |
s = Session(catalog.check_spec(spec), owner)
|
| 812 |
+
s.ws_failed = not bool(info.get("openenv"))
|
| 813 |
+
s.standard_mcp = not bool(info.get("openenv"))
|
| 814 |
_sessions[s.id] = s
|
| 815 |
return {"session": s.id}
|
| 816 |
|
|
|
|
| 846 |
|
| 847 |
|
| 848 |
def _ws_send(s: Session, msg: dict, timeout: float) -> dict:
|
| 849 |
+
if s.broken and msg.get("type") != "reset":
|
| 850 |
+
raise SpaceError("the episode was disconnected: reset to start a new episode", 410)
|
| 851 |
+
if msg.get("type") == "reset":
|
| 852 |
+
s.broken = False
|
| 853 |
if s.ws is None:
|
| 854 |
_open(s)
|
| 855 |
try:
|
|
|
|
| 857 |
reply = json.loads(s.ws.recv(timeout=timeout))
|
| 858 |
except TimeoutError:
|
| 859 |
s.close()
|
| 860 |
+
s.broken = True
|
| 861 |
raise SpaceError(f"the Space didn't answer within {int(timeout)} s; the session was closed", 504) from None
|
| 862 |
except Exception as e: # noqa: BLE001 - the socket dropped: the episode is gone
|
| 863 |
s.close()
|
| 864 |
+
s.broken = True
|
| 865 |
raise SpaceError(f"the session dropped ({type(e).__name__}): start a new episode", 502) from None
|
| 866 |
if not isinstance(reply, dict):
|
| 867 |
raise SpaceError("the Space answered something unexpected", 502)
|
|
|
|
| 872 |
|
| 873 |
|
| 874 |
def _rpc(s: Session, method: str, params: dict, timeout: float) -> Any:
|
| 875 |
+
"""Use OpenEnv's episode socket, standard HTTP MCP, or legacy OpenEnv HTTP fallback."""
|
| 876 |
if not s.ws_failed:
|
| 877 |
try:
|
| 878 |
s.rpc += 1
|
|
|
|
| 888 |
err = data["error"] if isinstance(data["error"], dict) else {"message": str(data["error"])}
|
| 889 |
raise SpaceError(str(err.get("message") or "error")[:600], 422)
|
| 890 |
return data.get("result") if isinstance(data, dict) else data
|
| 891 |
+
if s.standard_mcp:
|
| 892 |
+
if s.http is None:
|
| 893 |
+
s.http = SpaceClient(timeout=httpx.Timeout(120, connect=8), follow_redirects=False)
|
| 894 |
+
if s.remote_mcp is None:
|
| 895 |
+
s.remote_mcp = RemoteMCP(record(s.spec), s.http)
|
| 896 |
+
return s.remote_mcp.call(method, params, timeout)
|
| 897 |
return mcp_call(record(s.spec), method, params, timeout=timeout)
|
| 898 |
|
| 899 |
|
|
|
|
| 903 |
if method not in ("GET", "POST"):
|
| 904 |
raise SpaceError("GET or POST only", 400)
|
| 905 |
info = probe(s.spec)
|
| 906 |
+
path = route_url(info, method, str(data.get("path") or ""), data.get("params") if isinstance(data.get("params"), dict) else {},
|
| 907 |
+
data.get("query") if isinstance(data.get("query"), dict) else {})
|
| 908 |
rec = record(s.spec)
|
| 909 |
if s.http is None:
|
| 910 |
+
s.http = SpaceClient(base_url=rec["host"], timeout=httpx.Timeout(120, connect=8), follow_redirects=False,
|
| 911 |
headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"})
|
| 912 |
body = data.get("body")
|
| 913 |
try:
|
| 914 |
+
r = s.http.request(method, _url(rec, path), json=body if method == "POST" else None)
|
| 915 |
except httpx.TimeoutException:
|
| 916 |
raise SpaceError("the Space didn't answer within 120 s", 504) from None
|
| 917 |
except httpx.HTTPError as e:
|
|
|
|
| 972 |
s.steps += 1
|
| 973 |
else:
|
| 974 |
raise SpaceError(f"unknown operation {op!r}", 400)
|
| 975 |
+
return {"result": out, "ms": int((time.time() - t0) * 1000), "steps": s.steps,
|
| 976 |
+
"stateful": op == "http" or s.standard_mcp or not s.ws_failed}
|
| 977 |
|
| 978 |
|
| 979 |
def live_sessions() -> int:
|
app/vendor/nemo_gym/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright [yyyy] [name of copyright owner]
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
app/vendor/nemo_gym/NOTICE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
NeMo Gym verification_utils.py from NVIDIA-NeMo/Gym
|
| 2 |
+
Commit: 3ef478df1ee163134d32a3f291f0a9e5981d0e52
|
| 3 |
+
Path: resources_servers/single_step_tool_use_with_argument_comparison/common/verification_utils.py
|
| 4 |
+
SHA256: 41d82a49130adbf69fa9fca86c5f3eed28992ea6909e9dc05131b2830c0a1f89
|
| 5 |
+
Unmodified. Copyright NVIDIA CORPORATION & AFFILIATES. Apache-2.0.
|
app/vendor/nemo_gym/verification_utils.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
#
|
| 4 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 5 |
+
# you may not use this file except in compliance with the License.
|
| 6 |
+
# You may obtain a copy of the License at
|
| 7 |
+
#
|
| 8 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 9 |
+
#
|
| 10 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 11 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 12 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 13 |
+
# See the License for the specific language governing permissions and
|
| 14 |
+
# limitations under the License.
|
| 15 |
+
import json
|
| 16 |
+
from collections import Counter
|
| 17 |
+
from enum import StrEnum
|
| 18 |
+
from json import JSONDecodeError
|
| 19 |
+
from typing import Annotated, Any, Literal, Optional, TypeAlias, Union
|
| 20 |
+
|
| 21 |
+
from pydantic import BaseModel, Field
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MessageAction(BaseModel):
|
| 25 |
+
type: Literal["message"]
|
| 26 |
+
content: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class FunctionCallAction(BaseModel):
|
| 30 |
+
type: Literal["function_call"]
|
| 31 |
+
name: str
|
| 32 |
+
arguments: str
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class FunctionCallBatchAction(BaseModel):
|
| 36 |
+
type: Literal["function_call_batch"]
|
| 37 |
+
calls: list[FunctionCallAction] = Field(min_length=1)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# Actions are canonical on both sides of a comparison: dataset rows deserialize into them, and model
|
| 41 |
+
# responses are normalized into them by `response_utils.extract_action`. The alias keeps the name
|
| 42 |
+
# that dataset validation tooling imports.
|
| 43 |
+
ExpectedAction: TypeAlias = Annotated[
|
| 44 |
+
Union[MessageAction, FunctionCallAction, FunctionCallBatchAction],
|
| 45 |
+
Field(discriminator="type"),
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_tool_calls(action: ExpectedAction) -> list[FunctionCallAction]:
|
| 50 |
+
"""Flatten an action into the tool calls it represents, so single and parallel calls share a path."""
|
| 51 |
+
if isinstance(action, FunctionCallBatchAction):
|
| 52 |
+
return list(action.calls)
|
| 53 |
+
|
| 54 |
+
if isinstance(action, FunctionCallAction):
|
| 55 |
+
return [action]
|
| 56 |
+
|
| 57 |
+
return []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class StepRewardCategory(StrEnum):
|
| 61 |
+
NO_ACTION_FOUND = "No tool call or chat message was found in the response"
|
| 62 |
+
NO_EXPECTED_TOOL_CALL = "No tool call was found when one was expected"
|
| 63 |
+
EXPECTED_CHAT_MESSAGE_FOUND = "A chat message was found as expected"
|
| 64 |
+
NO_EXPECTED_CHAT_MESSAGE = "A tool call was executed when a chat message was expected"
|
| 65 |
+
UNEXPECTED_TOOL = "The tool in a tool call is not the expected tool"
|
| 66 |
+
ARGUMENTS_DECODE_ERROR = "An error occurred when decoding the arguments string in a tool call as a JSON object"
|
| 67 |
+
ARGUMENT_VALUE_TYPE_DIFFERENT = "The type of an argument value in a tool call is different than the expected type"
|
| 68 |
+
ARGUMENT_OBJECT_KEYS_DIFFERENT = (
|
| 69 |
+
"The keys in an object in an argument value in a tool call are different than the keys in the expected object"
|
| 70 |
+
)
|
| 71 |
+
ARGUMENT_LIST_LENGTH_DIFFERENT = (
|
| 72 |
+
"A list in an argument value in a tool call has a different length than the expected list"
|
| 73 |
+
)
|
| 74 |
+
ARGUMENT_VALUE_DIFFERENT = "An argument value in a tool call is different than the expected value"
|
| 75 |
+
EXPECTED_TOOL_CALL = "A tool call that matches the expected tool call was found"
|
| 76 |
+
FUNCTION_CALL_BATCH_LENGTH_DIFFERENT = "The number of tool calls in a batch is different than expected"
|
| 77 |
+
EXPECTED_TOOL_CALL_BATCH = "A tool-call batch that matches the expected tool calls was found"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class ParallelToolCallRewardMode(StrEnum):
|
| 81 |
+
"""How an admissible parallel tool-call response converts its matched-call count into a reward."""
|
| 82 |
+
|
| 83 |
+
BINARY_STRICT = "binary_strict"
|
| 84 |
+
FRACTIONAL = "fractional"
|
| 85 |
+
F1 = "f1"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class ActionComparisonResult(BaseModel):
|
| 89 |
+
reward: float
|
| 90 |
+
category: StepRewardCategory
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class ToolCallComparatorConfig(BaseModel):
|
| 94 |
+
word_count_similarity_threshold: float
|
| 95 |
+
floating_point_comparison_threshold: float = 1e-6
|
| 96 |
+
|
| 97 |
+
# Master switch: does the NUMBER of tool calls the model made affect its reward?
|
| 98 |
+
#
|
| 99 |
+
# False (default) turns parallel tool-call rewarding off entirely. The question asked of a
|
| 100 |
+
# response is only "did it make the expected call(s)?", so surplus calls are never penalized.
|
| 101 |
+
# This reproduces the behaviour that predates parallel tool-call support, which is why it is the
|
| 102 |
+
# default: existing datasets score exactly as they did before. It is also the honest default,
|
| 103 |
+
# because chat templates do not render differently for `parallel_tool_calls` (the Nemotron
|
| 104 |
+
# template never references the flag), so a model is never told how many calls it may make.
|
| 105 |
+
#
|
| 106 |
+
# True turns it on: the call count becomes part of the verdict, and the three settings below
|
| 107 |
+
# decide which counts are admissible and how partial matches are scored. Turn this on for
|
| 108 |
+
# datasets that use `expected_action.type: function_call_batch`.
|
| 109 |
+
parallel_tool_call_rewarding: bool = False
|
| 110 |
+
|
| 111 |
+
# The three settings below are only consulted when `parallel_tool_call_rewarding` is True.
|
| 112 |
+
|
| 113 |
+
# Cardinality gate: whether a response that makes fewer / more tool calls than expected is
|
| 114 |
+
# admissible at all. Both default to False, so the call count must match exactly.
|
| 115 |
+
allow_subset: bool = False
|
| 116 |
+
allow_superset: bool = False
|
| 117 |
+
|
| 118 |
+
# Scoring for responses that clear the gate. See `ActionComparator.compare_tool_calls`.
|
| 119 |
+
parallel_tool_call_reward_mode: ParallelToolCallRewardMode = ParallelToolCallRewardMode.BINARY_STRICT
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def find_maximum_matching(candidates: list[list[int]]) -> dict[int, int]:
|
| 123 |
+
"""Maximum bipartite matching between expected and actual tool calls (Kuhn's algorithm).
|
| 124 |
+
|
| 125 |
+
`candidates[expected_index]` lists the actual-call indices that expected call could match, and the
|
| 126 |
+
returned mapping goes from actual index to the expected index it was matched with. Greedy pairing
|
| 127 |
+
is not enough here: argument matching is a fuzzy relation, so one actual call can satisfy several
|
| 128 |
+
expected calls and an early arbitrary pairing can strand a later expected call that had no
|
| 129 |
+
alternative. Augmenting paths undo those pairings and recover the true maximum.
|
| 130 |
+
"""
|
| 131 |
+
matching: dict[int, int] = {}
|
| 132 |
+
|
| 133 |
+
# Matching the most constrained expected calls first keeps the number of augmenting paths small.
|
| 134 |
+
for expected_index in sorted(range(len(candidates)), key=lambda index: len(candidates[index])):
|
| 135 |
+
_augment_matching(expected_index, candidates, matching, set())
|
| 136 |
+
|
| 137 |
+
return matching
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _augment_matching(
|
| 141 |
+
expected_index: int,
|
| 142 |
+
candidates: list[list[int]],
|
| 143 |
+
matching: dict[int, int],
|
| 144 |
+
visited_actual_indices: set[int],
|
| 145 |
+
) -> bool:
|
| 146 |
+
for actual_index in candidates[expected_index]:
|
| 147 |
+
if actual_index in visited_actual_indices:
|
| 148 |
+
continue
|
| 149 |
+
|
| 150 |
+
visited_actual_indices.add(actual_index)
|
| 151 |
+
is_unmatched = actual_index not in matching
|
| 152 |
+
if is_unmatched or _augment_matching(matching[actual_index], candidates, matching, visited_actual_indices):
|
| 153 |
+
matching[actual_index] = expected_index
|
| 154 |
+
return True
|
| 155 |
+
|
| 156 |
+
return False
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
class ActionComparator(BaseModel):
|
| 160 |
+
config: ToolCallComparatorConfig
|
| 161 |
+
|
| 162 |
+
def compare_action(self, expected_action: ExpectedAction, actual_action: ExpectedAction) -> ActionComparisonResult:
|
| 163 |
+
match expected_action:
|
| 164 |
+
case MessageAction():
|
| 165 |
+
# Currently, any chat message is assigned a reward of one.
|
| 166 |
+
if isinstance(actual_action, MessageAction):
|
| 167 |
+
return ActionComparisonResult(reward=1.0, category=StepRewardCategory.EXPECTED_CHAT_MESSAGE_FOUND)
|
| 168 |
+
|
| 169 |
+
return ActionComparisonResult(reward=0.0, category=StepRewardCategory.NO_EXPECTED_CHAT_MESSAGE)
|
| 170 |
+
|
| 171 |
+
case FunctionCallAction() | FunctionCallBatchAction():
|
| 172 |
+
if isinstance(actual_action, MessageAction):
|
| 173 |
+
return ActionComparisonResult(reward=0.0, category=StepRewardCategory.NO_EXPECTED_TOOL_CALL)
|
| 174 |
+
|
| 175 |
+
return self.compare_tool_calls(get_tool_calls(expected_action), get_tool_calls(actual_action))
|
| 176 |
+
|
| 177 |
+
case _:
|
| 178 |
+
raise NotImplementedError(f"Unsupported expected action: {expected_action!r}")
|
| 179 |
+
|
| 180 |
+
def compare_tool_calls(
|
| 181 |
+
self, expected_calls: list[FunctionCallAction], actual_calls: list[FunctionCallAction]
|
| 182 |
+
) -> ActionComparisonResult:
|
| 183 |
+
"""Score a set of tool calls against the expected set, ignoring the order they were emitted in.
|
| 184 |
+
|
| 185 |
+
`parallel_tool_call_rewarding` is the master switch. While it is False (the default) the number
|
| 186 |
+
of calls is simply not part of the verdict: the response is asked only whether it made the
|
| 187 |
+
expected call(s), and surplus calls cost nothing. That reproduces the behaviour that predates
|
| 188 |
+
parallel tool-call support.
|
| 189 |
+
|
| 190 |
+
Turning it on makes the call count matter, in two independent stages. The cardinality gate
|
| 191 |
+
(`allow_subset` / `allow_superset`) decides whether a response that under- or over-calls is
|
| 192 |
+
admissible at all; anything it rejects scores zero. `parallel_tool_call_reward_mode` then decides
|
| 193 |
+
how much credit an admissible response earns:
|
| 194 |
+
|
| 195 |
+
- `binary_strict` — 1.0 only if every required call matched, else 0.0.
|
| 196 |
+
- `fractional` — the matched fraction of the required calls. Surplus calls permitted by
|
| 197 |
+
`allow_superset` are free, so this rewards recall but not precision.
|
| 198 |
+
- `f1` — the harmonic mean of precision and recall, `2 * matched / (expected + actual)`. Missing
|
| 199 |
+
and surplus calls are penalized symmetrically, so a response only reaches 1.0 by matching the
|
| 200 |
+
expected calls exactly.
|
| 201 |
+
"""
|
| 202 |
+
expected_count = len(expected_calls)
|
| 203 |
+
actual_count = len(actual_calls)
|
| 204 |
+
|
| 205 |
+
if expected_count == 1 and actual_count == 1:
|
| 206 |
+
# Preserve the single-call categories that predate parallel tool-call support.
|
| 207 |
+
return self.compare_tool_call(expected_calls[0], actual_calls[0])
|
| 208 |
+
|
| 209 |
+
if not self.is_call_count_admissible(expected_count, actual_count):
|
| 210 |
+
return ActionComparisonResult(reward=0.0, category=StepRewardCategory.FUNCTION_CALL_BATCH_LENGTH_DIFFERENT)
|
| 211 |
+
|
| 212 |
+
candidates, failure_categories = self.build_match_candidates(expected_calls, actual_calls)
|
| 213 |
+
matching = find_maximum_matching(candidates)
|
| 214 |
+
reward = self.score_matched_calls(len(matching), expected_count, actual_count)
|
| 215 |
+
if reward == 1.0:
|
| 216 |
+
# A single expected call keeps the category it had before parallel support existed.
|
| 217 |
+
category = (
|
| 218 |
+
StepRewardCategory.EXPECTED_TOOL_CALL
|
| 219 |
+
if expected_count == 1
|
| 220 |
+
else StepRewardCategory.EXPECTED_TOOL_CALL_BATCH
|
| 221 |
+
)
|
| 222 |
+
return ActionComparisonResult(reward=reward, category=category)
|
| 223 |
+
|
| 224 |
+
category = self.resolve_failure_category(
|
| 225 |
+
matched_expected_indices=set(matching.values()),
|
| 226 |
+
failure_categories=failure_categories,
|
| 227 |
+
expected_count=expected_count,
|
| 228 |
+
actual_count=actual_count,
|
| 229 |
+
)
|
| 230 |
+
return ActionComparisonResult(reward=reward, category=category)
|
| 231 |
+
|
| 232 |
+
def is_call_count_admissible(self, expected_count: int, actual_count: int) -> bool:
|
| 233 |
+
if actual_count == expected_count:
|
| 234 |
+
return True
|
| 235 |
+
|
| 236 |
+
# With parallel tool-call rewarding off, the call count is not part of the verdict at all.
|
| 237 |
+
if not self.config.parallel_tool_call_rewarding:
|
| 238 |
+
return True
|
| 239 |
+
|
| 240 |
+
if actual_count < expected_count:
|
| 241 |
+
return self.config.allow_subset
|
| 242 |
+
|
| 243 |
+
return self.config.allow_superset
|
| 244 |
+
|
| 245 |
+
def required_match_count(self, expected_count: int, actual_count: int) -> int:
|
| 246 |
+
"""How many expected calls must match for full credit under `binary_strict` and `fractional`."""
|
| 247 |
+
if self.config.allow_subset:
|
| 248 |
+
return min(expected_count, actual_count)
|
| 249 |
+
|
| 250 |
+
return expected_count
|
| 251 |
+
|
| 252 |
+
def score_matched_calls(self, matched_count: int, expected_count: int, actual_count: int) -> float:
|
| 253 |
+
if not self.config.parallel_tool_call_rewarding:
|
| 254 |
+
# The call count is not part of the verdict, so neither the gate nor the reward mode
|
| 255 |
+
# applies: full credit exactly when every expected call was matched.
|
| 256 |
+
return 1.0 if matched_count == expected_count else 0.0
|
| 257 |
+
|
| 258 |
+
if self.config.parallel_tool_call_reward_mode == ParallelToolCallRewardMode.F1:
|
| 259 |
+
total_count = expected_count + actual_count
|
| 260 |
+
return 2 * matched_count / total_count if total_count else 0.0
|
| 261 |
+
|
| 262 |
+
required_count = self.required_match_count(expected_count, actual_count)
|
| 263 |
+
if self.config.parallel_tool_call_reward_mode == ParallelToolCallRewardMode.FRACTIONAL:
|
| 264 |
+
return matched_count / required_count if required_count else 0.0
|
| 265 |
+
|
| 266 |
+
return 1.0 if matched_count == required_count else 0.0
|
| 267 |
+
|
| 268 |
+
def build_match_candidates(
|
| 269 |
+
self, expected_calls: list[FunctionCallAction], actual_calls: list[FunctionCallAction]
|
| 270 |
+
) -> tuple[list[list[int]], list[StepRewardCategory]]:
|
| 271 |
+
"""Pair every expected call with the actual calls it matches, keeping why the others missed."""
|
| 272 |
+
candidates: list[list[int]] = []
|
| 273 |
+
failure_categories: list[StepRewardCategory] = []
|
| 274 |
+
|
| 275 |
+
for expected_call in expected_calls:
|
| 276 |
+
matching_actual_indices: list[int] = []
|
| 277 |
+
failure_category = StepRewardCategory.UNEXPECTED_TOOL
|
| 278 |
+
|
| 279 |
+
for actual_index, actual_call in enumerate(actual_calls):
|
| 280 |
+
result = self.compare_tool_call(expected_call, actual_call)
|
| 281 |
+
if result.reward == 1.0:
|
| 282 |
+
matching_actual_indices.append(actual_index)
|
| 283 |
+
|
| 284 |
+
elif failure_category == StepRewardCategory.UNEXPECTED_TOOL:
|
| 285 |
+
# Keep the first reason that got past the tool name; it explains the closest near miss.
|
| 286 |
+
failure_category = result.category
|
| 287 |
+
|
| 288 |
+
candidates.append(matching_actual_indices)
|
| 289 |
+
failure_categories.append(failure_category)
|
| 290 |
+
|
| 291 |
+
return candidates, failure_categories
|
| 292 |
+
|
| 293 |
+
def resolve_failure_category(
|
| 294 |
+
self,
|
| 295 |
+
matched_expected_indices: set[int],
|
| 296 |
+
failure_categories: list[StepRewardCategory],
|
| 297 |
+
expected_count: int,
|
| 298 |
+
actual_count: int,
|
| 299 |
+
) -> StepRewardCategory:
|
| 300 |
+
unmatched_expected_indices = [
|
| 301 |
+
expected_index
|
| 302 |
+
for expected_index in range(expected_count)
|
| 303 |
+
if expected_index not in matched_expected_indices
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
# Either every expected call was matched and the only defect left is surplus calls, or every
|
| 307 |
+
# actual call was consumed and the only defect left is missing calls. Reporting an argument
|
| 308 |
+
# mismatch in those cases would point at a comparison that is not why the reward was docked.
|
| 309 |
+
if not unmatched_expected_indices or len(matched_expected_indices) == actual_count:
|
| 310 |
+
return StepRewardCategory.FUNCTION_CALL_BATCH_LENGTH_DIFFERENT
|
| 311 |
+
|
| 312 |
+
return failure_categories[unmatched_expected_indices[0]]
|
| 313 |
+
|
| 314 |
+
def compare_tool_call(
|
| 315 |
+
self, expected_tool_call: FunctionCallAction, actual_tool_call: FunctionCallAction
|
| 316 |
+
) -> ActionComparisonResult:
|
| 317 |
+
if expected_tool_call.name != actual_tool_call.name:
|
| 318 |
+
return ActionComparisonResult(reward=0.0, category=StepRewardCategory.UNEXPECTED_TOOL)
|
| 319 |
+
|
| 320 |
+
# It is assumed that the expected arguments string is a string representation of a JSON object.
|
| 321 |
+
expected_arguments = json.loads(expected_tool_call.arguments)
|
| 322 |
+
|
| 323 |
+
try:
|
| 324 |
+
actual_arguments = json.loads(actual_tool_call.arguments)
|
| 325 |
+
except (JSONDecodeError, UnicodeDecodeError):
|
| 326 |
+
return ActionComparisonResult(reward=0.0, category=StepRewardCategory.ARGUMENTS_DECODE_ERROR)
|
| 327 |
+
|
| 328 |
+
arguments_match, category = self.compare_tool_call_arguments(expected_arguments, actual_arguments)
|
| 329 |
+
if arguments_match:
|
| 330 |
+
return ActionComparisonResult(reward=1.0, category=StepRewardCategory.EXPECTED_TOOL_CALL)
|
| 331 |
+
|
| 332 |
+
return ActionComparisonResult(reward=0.0, category=category)
|
| 333 |
+
|
| 334 |
+
def compare_tool_call_arguments(
|
| 335 |
+
self, expected_value: Any, actual_value: Any
|
| 336 |
+
) -> tuple[bool, Optional[StepRewardCategory]]:
|
| 337 |
+
if not isinstance(actual_value, type(expected_value)):
|
| 338 |
+
return False, StepRewardCategory.ARGUMENT_VALUE_TYPE_DIFFERENT
|
| 339 |
+
|
| 340 |
+
if isinstance(expected_value, dict):
|
| 341 |
+
if set(expected_value.keys()) != set(actual_value.keys()):
|
| 342 |
+
return False, StepRewardCategory.ARGUMENT_OBJECT_KEYS_DIFFERENT
|
| 343 |
+
|
| 344 |
+
for expected_dict_key, expected_dict_value in expected_value.items():
|
| 345 |
+
actual_dict_value = actual_value[expected_dict_key]
|
| 346 |
+
dict_value_match, dict_value_category = self.compare_tool_call_arguments(
|
| 347 |
+
expected_dict_value, actual_dict_value
|
| 348 |
+
)
|
| 349 |
+
if not dict_value_match:
|
| 350 |
+
return dict_value_match, dict_value_category
|
| 351 |
+
|
| 352 |
+
return True, None
|
| 353 |
+
|
| 354 |
+
elif isinstance(expected_value, list):
|
| 355 |
+
if len(expected_value) != len(actual_value):
|
| 356 |
+
return False, StepRewardCategory.ARGUMENT_LIST_LENGTH_DIFFERENT
|
| 357 |
+
|
| 358 |
+
for expected_list_element, actual_list_element in zip(expected_value, actual_value):
|
| 359 |
+
list_element_match, list_element_category = self.compare_tool_call_arguments(
|
| 360 |
+
expected_list_element, actual_list_element
|
| 361 |
+
)
|
| 362 |
+
if not list_element_match:
|
| 363 |
+
return list_element_match, list_element_category
|
| 364 |
+
|
| 365 |
+
return True, None
|
| 366 |
+
|
| 367 |
+
elif isinstance(expected_value, float):
|
| 368 |
+
if abs(actual_value - expected_value) < self.config.floating_point_comparison_threshold:
|
| 369 |
+
return True, None
|
| 370 |
+
else:
|
| 371 |
+
return False, StepRewardCategory.ARGUMENT_VALUE_DIFFERENT
|
| 372 |
+
|
| 373 |
+
elif isinstance(expected_value, str):
|
| 374 |
+
# For now, strings are compared by using whitespace to split them into lower-case
|
| 375 |
+
# words, counting the words, and comparing the word counts using Jaccard similarity.
|
| 376 |
+
expected_word_counts = Counter(expected_value.strip().lower().split())
|
| 377 |
+
actual_word_counts = Counter(actual_value.strip().lower().split())
|
| 378 |
+
expected_word_total = expected_word_counts.total()
|
| 379 |
+
actual_word_total = actual_word_counts.total()
|
| 380 |
+
|
| 381 |
+
if expected_word_total < 2 or actual_word_total < 2:
|
| 382 |
+
if expected_value != actual_value:
|
| 383 |
+
return False, StepRewardCategory.ARGUMENT_VALUE_DIFFERENT
|
| 384 |
+
|
| 385 |
+
else:
|
| 386 |
+
intersection_word_counts = expected_word_counts & actual_word_counts
|
| 387 |
+
|
| 388 |
+
word_count_similarity = intersection_word_counts.total() / (expected_word_total + actual_word_total)
|
| 389 |
+
if word_count_similarity < self.config.word_count_similarity_threshold:
|
| 390 |
+
return False, StepRewardCategory.ARGUMENT_VALUE_DIFFERENT
|
| 391 |
+
|
| 392 |
+
return True, None
|
| 393 |
+
|
| 394 |
+
elif expected_value == actual_value:
|
| 395 |
+
return True, None
|
| 396 |
+
|
| 397 |
+
else:
|
| 398 |
+
return False, StepRewardCategory.ARGUMENT_VALUE_DIFFERENT
|
app/version.py
CHANGED
|
@@ -26,9 +26,9 @@ def app_version() -> str:
|
|
| 26 |
@lru_cache(maxsize=1)
|
| 27 |
def source_hash() -> str:
|
| 28 |
h = hashlib.sha256()
|
| 29 |
-
for base in (ROOT / "app", ROOT / "web" / "
|
| 30 |
for p in sorted(base.rglob("*")):
|
| 31 |
-
if p.is_file() and p.suffix in (".py", ".js", ".txt", ".json") and "__pycache__" not in p.parts:
|
| 32 |
h.update(p.relative_to(ROOT).as_posix().encode())
|
| 33 |
h.update(p.read_bytes())
|
| 34 |
return h.hexdigest()[:12]
|
|
|
|
| 26 |
@lru_cache(maxsize=1)
|
| 27 |
def source_hash() -> str:
|
| 28 |
h = hashlib.sha256()
|
| 29 |
+
for base in (ROOT / "app", ROOT / "web", ROOT / "web-admin"):
|
| 30 |
for p in sorted(base.rglob("*")):
|
| 31 |
+
if p.is_file() and p.suffix in (".py", ".js", ".txt", ".json", ".css", ".html") and "__pycache__" not in p.parts:
|
| 32 |
h.update(p.relative_to(ROOT).as_posix().encode())
|
| 33 |
h.update(p.read_bytes())
|
| 34 |
return h.hexdigest()[:12]
|
pyproject.toml
CHANGED
|
@@ -9,6 +9,7 @@ dependencies = [
|
|
| 9 |
"httpx>=0.27",
|
| 10 |
"huggingface_hub>=1.29",
|
| 11 |
"pyyaml>=6",
|
|
|
|
| 12 |
"cryptography>=44",
|
| 13 |
"openenv",
|
| 14 |
"harbor>=0.23",
|
|
|
|
| 9 |
"httpx>=0.27",
|
| 10 |
"huggingface_hub>=1.29",
|
| 11 |
"pyyaml>=6",
|
| 12 |
+
"packaging>=24.2",
|
| 13 |
"cryptography>=44",
|
| 14 |
"openenv",
|
| 15 |
"harbor>=0.23",
|
scripts/deploy_spaces.py
CHANGED
|
@@ -42,8 +42,8 @@ SPACES = {
|
|
| 42 |
}
|
| 43 |
# never uploaded: local data and caches, the READMEs (each Space gets its own as README.md), editor and OS files
|
| 44 |
IGNORE = [".venv/*", ".local-data/*", "*/__pycache__/*", "__pycache__/*", "*.pyc", ".pytest_cache/*", ".ruff_cache/*",
|
| 45 |
-
".gradio/*", "node_modules/*", ".DS_Store", "*/.DS_Store", "README.md", "SPACE_README.md", "ADMIN_SPACE_README.md",
|
| 46 |
-
".env", "*.env", ".git/*"]
|
| 47 |
|
| 48 |
|
| 49 |
SKIP_DIRS = {".venv", ".local-data", "__pycache__", ".pytest_cache", ".ruff_cache", ".gradio", "node_modules", ".git"}
|
|
@@ -53,10 +53,10 @@ def files() -> list[str]:
|
|
| 53 |
"""What goes into the Space: walked without descending into local data, caches or the virtualenv."""
|
| 54 |
out = []
|
| 55 |
for root, dirs, names in os.walk(ROOT):
|
| 56 |
-
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
| 57 |
for n in sorted(names):
|
| 58 |
rel = (Path(root) / n).relative_to(ROOT).as_posix()
|
| 59 |
-
if not any(fnmatch.fnmatch(rel, pat) for pat in IGNORE):
|
| 60 |
out.append(rel)
|
| 61 |
return out
|
| 62 |
|
|
|
|
| 42 |
}
|
| 43 |
# never uploaded: local data and caches, the READMEs (each Space gets its own as README.md), editor and OS files
|
| 44 |
IGNORE = [".venv/*", ".local-data/*", "*/__pycache__/*", "__pycache__/*", "*.pyc", ".pytest_cache/*", ".ruff_cache/*",
|
| 45 |
+
".gradio/*", "node_modules/*", ".DS_Store", "*/.DS_Store", "README.md", "SPACE_README.md", "ADMIN_SPACE_README.md", "EXPLORER_AUDIT.md",
|
| 46 |
+
".env", ".env.*", "*/.env", "*/.env.*", "*.env", "*.pem", "*.key", ".git/*"]
|
| 47 |
|
| 48 |
|
| 49 |
SKIP_DIRS = {".venv", ".local-data", "__pycache__", ".pytest_cache", ".ruff_cache", ".gradio", "node_modules", ".git"}
|
|
|
|
| 53 |
"""What goes into the Space: walked without descending into local data, caches or the virtualenv."""
|
| 54 |
out = []
|
| 55 |
for root, dirs, names in os.walk(ROOT):
|
| 56 |
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not (Path(root) / d).is_symlink())
|
| 57 |
for n in sorted(names):
|
| 58 |
rel = (Path(root) / n).relative_to(ROOT).as_posix()
|
| 59 |
+
if not (Path(root) / n).is_symlink() and not any(fnmatch.fnmatch(rel, pat) for pat in IGNORE):
|
| 60 |
out.append(rel)
|
| 61 |
return out
|
| 62 |
|
tests/reward-stats.test.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import test from 'node:test';
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import { rewardDistribution } from '../web/js/reward-stats.js';
|
| 4 |
+
|
| 5 |
+
test('negative and unbounded rewards remain in the histogram', () => {
|
| 6 |
+
const d = rewardDistribution([-100, -5, 0, 0.5, 1, 500]);
|
| 7 |
+
assert.equal(d.min, -100);
|
| 8 |
+
assert.equal(d.max, 500);
|
| 9 |
+
assert.equal(d.hist.reduce((a, b) => a+b), 6);
|
| 10 |
+
assert.equal(d.hist[0], 1);
|
| 11 |
+
assert.equal(d.hist[9], 1);
|
| 12 |
+
});
|
| 13 |
+
test('empty and normalized rewards keep a useful finite range', () => {
|
| 14 |
+
for (const xs of [[], [0, 0.5, 1], [NaN, Infinity]]) {
|
| 15 |
+
const d = rewardDistribution(xs);
|
| 16 |
+
assert.equal(d.min, 0);
|
| 17 |
+
assert.equal(d.max, 1);
|
| 18 |
+
assert.ok(d.hist.every(Number.isFinite));
|
| 19 |
+
}
|
| 20 |
+
});
|
tests/schemaform.test.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import test from 'node:test';
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import { schemaForm, readForm, resolveSchema, validateValue } from '../web/js/schemaform.js';
|
| 4 |
+
|
| 5 |
+
const form = (fields = [], root = null) => ({
|
| 6 |
+
querySelector: () => root,
|
| 7 |
+
querySelectorAll: () => fields.map(([k, t, value]) => ({ dataset: { k, t }, value })),
|
| 8 |
+
});
|
| 9 |
+
const plain = (v) => JSON.parse(JSON.stringify(v));
|
| 10 |
+
|
| 11 |
+
test('optional booleans can be omitted, and explicit false remains false', () => {
|
| 12 |
+
const schema = { type: 'object', properties: { enabled: { type: 'boolean', default: true } } };
|
| 13 |
+
assert.deepEqual(plain(readForm(form([['enabled', 'bool', '']]), schema)), {});
|
| 14 |
+
assert.deepEqual(plain(readForm(form([['enabled', 'bool', 'false']]), schema)), { enabled: false });
|
| 15 |
+
assert.match(schemaForm(schema), /Use server default/);
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
test('integer inputs reject fractions rather than truncating actions', () => {
|
| 19 |
+
const s = { type: 'object', properties: { action: { type: 'integer', minimum: 0, maximum: 3 } }, required: ['action'] };
|
| 20 |
+
for (const value of ['1.5', '4', '-1', 'Infinity', '9007199254740992', '']) {
|
| 21 |
+
assert.throws(() => readForm(form([['action', 'int', value]]), s));
|
| 22 |
+
}
|
| 23 |
+
assert.equal(readForm(form([['action', 'int', '0']]), s).action, 0);
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
test('enum values retain their JSON type and never silently pick the first option', () => {
|
| 27 |
+
const s = { type: 'object', properties: { action: { enum: [0, 1, 'stop', null] } }, required: ['action'] };
|
| 28 |
+
assert.match(schemaForm(s), /Choose a value/);
|
| 29 |
+
assert.equal(readForm(form([['action', 'enum', '0']]), s).action, 0);
|
| 30 |
+
assert.equal(readForm(form([['action', 'enum', 'null']]), s).action, null);
|
| 31 |
+
assert.throws(() => readForm(form([['action', 'enum', '"bad"']]), s));
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
test('local references retain defaults and referenced discriminators', () => {
|
| 35 |
+
const s = { type: 'object', $defs: { Mode: { const: 'move' }, Count: { type: 'integer', default: 7 } },
|
| 36 |
+
properties: { type: { $ref: '#/$defs/Mode' }, count: { $ref: '#/$defs/Count' } } };
|
| 37 |
+
assert.match(schemaForm(s), /value="7"/);
|
| 38 |
+
assert.equal(readForm(form(), s).type, 'move');
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
test('recursive and missing references are bounded', () => {
|
| 42 |
+
const s = { $ref: '#/$defs/Loop', $defs: { Loop: { $ref: '#/$defs/Loop' } } };
|
| 43 |
+
assert.doesNotThrow(() => schemaForm(s));
|
| 44 |
+
assert.deepEqual(resolveSchema({ $ref: '#/$defs/missing' }, s), {});
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
test('root action unions use JSON without losing nested arguments', () => {
|
| 48 |
+
const s = { oneOf: [
|
| 49 |
+
{ type: 'object', properties: { type: { const: 'move' }, position: { type: 'array', items: { type: 'number' }, minItems: 2 } }, required: ['type', 'position'] },
|
| 50 |
+
{ type: 'object', properties: { type: { const: 'stop' } }, required: ['type'] },
|
| 51 |
+
] };
|
| 52 |
+
assert.match(schemaForm(s), /data-root-json/);
|
| 53 |
+
assert.deepEqual(readForm(form([], { value: '{"type":"move","position":[1,2]}' }), s), { type: 'move', position: [1, 2] });
|
| 54 |
+
assert.throws(() => readForm(form([], { value: '{"type":"move"}' }), s));
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
test('open objects have a JSON editor rather than claiming no inputs', () => {
|
| 58 |
+
assert.match(schemaForm({ type: 'object' }), /data-root-json/);
|
| 59 |
+
assert.match(schemaForm({ type: 'object', additionalProperties: false }), /No inputs/);
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
test('all interpolated upstream schema values are escaped', () => {
|
| 63 |
+
const payload = '\"><img src=https://evil.test/>';
|
| 64 |
+
const s = { type: 'object', properties: { [payload]: { type: 'number', default: payload, minimum: payload } } };
|
| 65 |
+
const html = schemaForm(s, { prefix: payload });
|
| 66 |
+
assert.ok(!html.includes('<img'));
|
| 67 |
+
assert.ok(!html.includes('min="' + payload));
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
test('sanitized property names cannot create duplicate input ids', () => {
|
| 71 |
+
const html = schemaForm({ properties: { 'a.b': { type: 'string' }, 'a/b': { type: 'string' } } });
|
| 72 |
+
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((x) => x[1]);
|
| 73 |
+
assert.equal(ids.length, new Set(ids).size);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
test('prototype-like field names remain ordinary JSON properties', () => {
|
| 77 |
+
const schema = JSON.parse('{"type":"object","properties":{"__proto__":{"type":"object"}}}');
|
| 78 |
+
const result = readForm(form([['__proto__', 'json', '{"polluted":true}']]), schema);
|
| 79 |
+
assert.equal(Object.getPrototypeOf(result), null);
|
| 80 |
+
assert.equal({}.polluted, undefined);
|
| 81 |
+
assert.equal(JSON.parse(JSON.stringify(result)).__proto__.polluted, true);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
test('nested required, size and additional-properties constraints report actionable errors', () => {
|
| 85 |
+
const s = { type: 'object', properties: { args: { type: 'object', properties: { text: { type: 'string', minLength: 2 } }, required: ['text'], additionalProperties: false } } };
|
| 86 |
+
assert.throws(() => validateValue({ args: {} }, s), /args.text is required/);
|
| 87 |
+
assert.throws(() => validateValue({ args: { text: '' } }, s), /at least 2/);
|
| 88 |
+
assert.throws(() => validateValue({ args: { text: 'ok', answer: 'secret' } }, s), /not an allowed field/);
|
| 89 |
+
});
|
tests/space-media.test.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import test from 'node:test';
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import { spaceMedia } from '../web/js/space-media.js';
|
| 4 |
+
import { taskContent } from '../web/js/space-task-view.js';
|
| 5 |
+
|
| 6 |
+
test('lazy Space media resolves against the serving Space without mutating observations', () => {
|
| 7 |
+
const raw = { observation: { asset_path: '/assets/hash?task_id=a', mime: 'audio/wav' } };
|
| 8 |
+
const out = spaceMedia(raw, 'https://owner-env.hf.space');
|
| 9 |
+
assert.equal(out.observation.media_url, 'https://owner-env.hf.space/assets/hash?task_id=a');
|
| 10 |
+
assert.equal(raw.observation.media_url, undefined);
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
test('media paths cannot change origin or use active URL schemes', () => {
|
| 14 |
+
for (const path of ['//evil.example/assets/a', 'javascript:alert(1)', 'https://evil.example/a', '/other/a'])
|
| 15 |
+
assert.equal(spaceMedia({ asset_path: path, mime: 'audio/wav' }, 'https://owner-env.hf.space').media_url, undefined);
|
| 16 |
+
assert.equal(spaceMedia({ asset_path: '/assets/a', mime: 'audio/wav' }, 'http://localhost').media_url, undefined);
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
test('task inputs are escaped and lazy media remains an available episode', () => {
|
| 20 |
+
const html = taskContent({ prompt: '<img src=x onerror=alert(1)>', task_id: 'a'.repeat(200), media_ready: false });
|
| 21 |
+
assert.ok(!html.includes('<img src=x'));
|
| 22 |
+
assert.ok(html.includes('Media loads when the episode starts'));
|
| 23 |
+
assert.ok(html.includes('Task record'));
|
| 24 |
+
});
|
tests/test_admin_dashboard.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from contextlib import contextmanager
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from fastapi.testclient import TestClient
|
| 6 |
+
|
| 7 |
+
from app import admin, admin_app, auth, auto_index, catalog, community, search_api, settings, snapshot, space_checks, store
|
| 8 |
+
|
| 9 |
+
client = TestClient(admin_app.app)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def data(monkeypatch):
|
| 14 |
+
now = time.time()
|
| 15 |
+
checks = {name: {'id': name, 'stage': 'RUNNING', 'status': status, 'checked_at': at, 'interface': interface,
|
| 16 |
+
'task_catalog': {'tasks': 50, 'counted_splits': 1, 'complete': True, 'checked_at': now}}
|
| 17 |
+
for name, status, at, interface in [('org/openenv', space_checks.PASS, now, None),
|
| 18 |
+
('org/stale', space_checks.PASS, now-7200, None), ('org/nemo', 'Checks failed', now, 'nemo-gym')]}
|
| 19 |
+
envs = [{'id':'org/harbor','key':'org/harbor','kind':'dataset','framework':'harbor','indexed':{'tasks':8}}]
|
| 20 |
+
envs += [{'id':name, 'key':'space:'+name, 'kind':'space', 'api_check':rec, 'declared_version':'0.3.0'} for name,rec in checks.items()]
|
| 21 |
+
base = {'user':'alice','dataset':'org/harbor','path':'task','title':'Example','status':'done','reward':1,
|
| 22 |
+
'visibility':'public','created_at':now,'updated_at':now,'avatar':'https://example.com/avatar.png'}
|
| 23 |
+
runs = [{**base,'id':'harbor','runner':'harbor','reward':-2}, {**base,'id':'mimo','runner':'mimo','domain':'code','visibility':'private'},
|
| 24 |
+
{**base,'id':'nemo','runner':'nemo_gym'}, {**base,'id':'hidden','runner':'harbor'},
|
| 25 |
+
{**base,'id':'restricted','runner':'harbor','restricted':True}, {**base,'id':'ungraded','reward':None},
|
| 26 |
+
{**base,'id':'live','runner':'mimo','status':'running','reward':None}]
|
| 27 |
+
monkeypatch.setattr(auth,'current_user',lambda request:{'name':'alice'})
|
| 28 |
+
monkeypatch.setattr(settings,'is_admin',lambda u:True)
|
| 29 |
+
monkeypatch.setattr(settings,'get',lambda k,d=None: ['hidden'] if k=='hidden_runs' else d)
|
| 30 |
+
monkeypatch.setattr(admin,'_runs',lambda:runs)
|
| 31 |
+
monkeypatch.setattr(admin,'avatar',lambda u:None)
|
| 32 |
+
monkeypatch.setattr(search_api,'environments',lambda **kw:envs)
|
| 33 |
+
monkeypatch.setattr(space_checks,'inventory',lambda:checks)
|
| 34 |
+
monkeypatch.setattr(catalog,'hidden',lambda:set())
|
| 35 |
+
monkeypatch.setattr(auto_index,'status',lambda:{'state':'idle','last_success':now-20,'next_at':now+600})
|
| 36 |
+
monkeypatch.setattr(snapshot,'status',lambda:{'built_at':'2026-10-04T00:00:00+00:00'})
|
| 37 |
+
@contextmanager
|
| 38 |
+
def use():
|
| 39 |
+
yield None,None
|
| 40 |
+
monkeypatch.setattr(snapshot,'use',use)
|
| 41 |
+
monkeypatch.setattr(search_api,'sql_extra',lambda conn,ctx,n,q:{'stats':{'datasets':1,'spaces':2,'tasks':8}})
|
| 42 |
+
return envs,runs,checks
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_overview_matches_community_eligibility_and_checked_spaces(data):
|
| 46 |
+
d=client.get('/api/admin/overview').json()
|
| 47 |
+
assert d['environments']['spaces']==3
|
| 48 |
+
assert d['environments']['checked_spaces']==2
|
| 49 |
+
assert d['environments']['openenv']==1
|
| 50 |
+
assert d['environments']['public']=={'datasets':1,'spaces':2,'tasks':8}
|
| 51 |
+
assert d['rollouts']['public']==2
|
| 52 |
+
assert d['rollouts']['private']==1
|
| 53 |
+
assert len(d['live'])==1
|
| 54 |
+
assert dict(d['rollouts']['by_runner'])=={'harbor':4,'mimo':2,'nemo-gym':1}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_operations_show_expired_checks_without_counting_them_as_ready(data,monkeypatch):
|
| 58 |
+
monkeypatch.setattr(auto_index,'status',lambda:{'state':'indexing','next_at':time.time()-5000})
|
| 59 |
+
monkeypatch.setattr(catalog,'hidden',lambda:{'space:org/nemo'})
|
| 60 |
+
d=admin._operations(data[0])
|
| 61 |
+
assert d['indexer']['stale']
|
| 62 |
+
assert d['checks']['ready']==2
|
| 63 |
+
assert d['checks']['statuses']['Check expired']==1
|
| 64 |
+
assert d['task_catalog']['tasks']==50 # hidden and expired Spaces excluded
|
| 65 |
+
assert d['checks']['versions']==[('0.3.0',1)]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_admin_runner_visibility_filters_and_safe_pagination(data):
|
| 69 |
+
d=client.get('/api/admin/rollouts?runner=nemo-gym&visibility=community').json()
|
| 70 |
+
assert d['total']==1 and d['runs'][0]['runner']=='nemo-gym'
|
| 71 |
+
assert d['runs'][0]['community']
|
| 72 |
+
assert d['runners']==['harbor','mimo','nemo-gym']
|
| 73 |
+
assert client.get('/api/admin/rollouts?visibility=community').json()['total']==2
|
| 74 |
+
assert client.get('/api/admin/rollouts?visibility=moderated').json()['total']==1
|
| 75 |
+
assert client.get('/api/admin/rollouts?visibility=private').json()['total']==1
|
| 76 |
+
assert client.get('/api/admin/rollouts?offset=-8&limit=1').json()['runs'][0]['id']=='harbor'
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_people_do_not_treat_reward_above_one_as_universal_success(data):
|
| 80 |
+
d=client.get('/api/admin/people').json()['people'][0]
|
| 81 |
+
assert 'solved' not in d
|
| 82 |
+
assert d['scored']==5 and d['public']==2
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_environment_admin_keeps_unverified_candidates_for_review(data):
|
| 86 |
+
rows=client.get('/api/admin/environments').json()['environments']
|
| 87 |
+
assert len(rows)==4
|
| 88 |
+
assert {r['id'] for r in rows if r['ready']}=={'org/harbor','org/openenv','org/nemo'}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_public_eligibility_reused_for_admin_and_community(data):
|
| 92 |
+
hidden={'hidden'}
|
| 93 |
+
for r in data[1]:
|
| 94 |
+
assert admin._admin_view(r)['community']==community.is_public(r,hidden)
|
tests/test_audit_regressions.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Security and lifecycle regressions found during PR #33's explorer audit."""
|
| 2 |
+
import gzip
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
import pytest
|
| 6 |
+
from fastapi.testclient import TestClient
|
| 7 |
+
|
| 8 |
+
from app import config, main, runner, spaces_live as live
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_forwarded_addresses_require_a_trusted_proxy(monkeypatch):
|
| 12 |
+
from starlette.requests import Request
|
| 13 |
+
from app.http import client_ip
|
| 14 |
+
request = Request({"type": "http", "headers": [(b"x-forwarded-for", b"forged, proxy-appended")], "client": ("peer", 123)})
|
| 15 |
+
monkeypatch.setattr(config, "TRUST_PROXY", False)
|
| 16 |
+
assert client_ip(request) == "peer"
|
| 17 |
+
monkeypatch.setattr(config, "TRUST_PROXY", True)
|
| 18 |
+
assert client_ip(request) == "proxy-appended"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_streamed_gzip_has_a_decompression_output_cap(monkeypatch):
|
| 22 |
+
monkeypatch.setattr(live, "MAX_REPLY", 1024)
|
| 23 |
+
class Stream(httpx.SyncByteStream):
|
| 24 |
+
def __iter__(self):
|
| 25 |
+
yield gzip.compress(b"x" * 10_000_000)
|
| 26 |
+
with live.SpaceClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, headers={"content-encoding": "gzip"}, stream=Stream()))) as c:
|
| 27 |
+
with pytest.raises(live.SpaceError, match="too large"):
|
| 28 |
+
c.get("https://fixture.hf.space/")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.mark.parametrize("headers", [
|
| 32 |
+
{"Origin": "https://evil.example", "X-Forwarded-Host": "evil.example"},
|
| 33 |
+
{"Origin": "http://testserver"},
|
| 34 |
+
{"Origin": "null"},
|
| 35 |
+
{"Origin": "https://testserver/path"},
|
| 36 |
+
{"Origin": "https://testserver@evil.example"},
|
| 37 |
+
])
|
| 38 |
+
def test_origin_must_match_scheme_and_authority(monkeypatch, headers):
|
| 39 |
+
monkeypatch.setattr(config, "PUBLIC_URL", "")
|
| 40 |
+
with TestClient(main.app, base_url="https://testserver") as c:
|
| 41 |
+
assert c.post("/api/logout", headers=headers).status_code == 403
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_valid_origin_and_explicit_public_origin(monkeypatch):
|
| 45 |
+
with TestClient(main.app, base_url="https://testserver") as c:
|
| 46 |
+
monkeypatch.setattr(config, "PUBLIC_URL", "")
|
| 47 |
+
assert c.post("/api/logout", headers={"Origin": "https://testserver:443"}).status_code == 200
|
| 48 |
+
monkeypatch.setattr(config, "PUBLIC_URL", "https://public.hf.space")
|
| 49 |
+
assert c.post("/api/logout", headers={"Origin": "https://public.hf.space"}).status_code == 200
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_personal_api_responses_are_not_cached():
|
| 53 |
+
with TestClient(main.app, base_url="https://testserver") as c:
|
| 54 |
+
assert c.get("/api/runs").headers["cache-control"] == "private, no-store"
|
| 55 |
+
assert c.get("/api/me").headers["cache-control"] == "private, no-store"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@pytest.mark.parametrize("bad", [".", "..", "x\n", "x/y", "%2e%2e", "//elsewhere"])
|
| 59 |
+
def test_path_parameters_cannot_change_the_published_route(bad):
|
| 60 |
+
info = {"api": [{"method": "GET", "path": "/tasks/{id}", "params": ["id"]}]}
|
| 61 |
+
with pytest.raises(live.SpaceError):
|
| 62 |
+
live.route_url(info, "GET", "/tasks/{id}", {"id": bad})
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_upstream_body_is_stopped_before_it_is_fully_buffered(monkeypatch):
|
| 66 |
+
monkeypatch.setattr(live, "MAX_REPLY", 64 * 1024)
|
| 67 |
+
reads = []
|
| 68 |
+
|
| 69 |
+
class Stream(httpx.SyncByteStream):
|
| 70 |
+
def __iter__(self):
|
| 71 |
+
for i in range(100):
|
| 72 |
+
reads.append(i)
|
| 73 |
+
yield b"x" * 64 * 1024
|
| 74 |
+
|
| 75 |
+
with live.SpaceClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, stream=Stream()))) as c:
|
| 76 |
+
with pytest.raises(live.SpaceError, match="too large"):
|
| 77 |
+
c.get("https://fixture.hf.space/")
|
| 78 |
+
assert len(reads) <= 3
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_gzip_reply_is_decoded_once_and_limited(monkeypatch):
|
| 82 |
+
monkeypatch.setattr(live, "MAX_REPLY", 1024)
|
| 83 |
+
def response(r):
|
| 84 |
+
return httpx.Response(200, content=gzip.compress(b'{"ok":true}'), headers={"Content-Encoding": "gzip"})
|
| 85 |
+
with live.SpaceClient(transport=httpx.MockTransport(response)) as c:
|
| 86 |
+
assert c.get("https://fixture.hf.space/").json() == {"ok": True}
|
| 87 |
+
with live.SpaceClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, content=gzip.compress(b"x" * 2048), headers={"Content-Encoding": "gzip"}))) as c:
|
| 88 |
+
with pytest.raises(live.SpaceError, match="too large"):
|
| 89 |
+
c.get("https://fixture.hf.space/")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_disconnected_episode_is_not_silently_replaced(monkeypatch):
|
| 93 |
+
s = live.Session("fixture/env", "owner")
|
| 94 |
+
class Socket:
|
| 95 |
+
def send(self, message): pass
|
| 96 |
+
def recv(self, timeout): raise TimeoutError
|
| 97 |
+
def close(self): pass
|
| 98 |
+
s.ws = Socket()
|
| 99 |
+
with pytest.raises(live.SpaceError) as error:
|
| 100 |
+
live._ws_send(s, {"type": "step", "data": {}}, 1)
|
| 101 |
+
assert error.value.status == 504
|
| 102 |
+
monkeypatch.setattr(live, "_open", lambda s: pytest.fail("must not create a replacement episode"))
|
| 103 |
+
with pytest.raises(live.SpaceError) as error:
|
| 104 |
+
live._ws_send(s, {"type": "step", "data": {}}, 1)
|
| 105 |
+
assert error.value.status == 410
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf"), 1.5])
|
| 109 |
+
def test_rollout_limits_reject_non_finite_and_fractional_values(value):
|
| 110 |
+
with pytest.raises(ValueError):
|
| 111 |
+
runner._check_fields([{"key": "steps", "type": "number", "step": 1, "min": 1, "max": 100}], {"steps": value})
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_raw_file_names_are_unicode_safe_and_cannot_inject_headers(monkeypatch):
|
| 115 |
+
monkeypatch.setattr(main.registry, "raw", lambda *a: (b"hello", "text/plain"))
|
| 116 |
+
c = TestClient(main.app, base_url="https://testserver")
|
| 117 |
+
for name in ['文件.txt', 'x\r\nInjected: true.txt']:
|
| 118 |
+
r = c.get('/api/env/org/ds/raw', params={"ref": "train/0", "f": name, "download": "true"})
|
| 119 |
+
assert r.status_code == 200
|
| 120 |
+
assert r.headers['content-disposition'].startswith("attachment; filename*=UTF-8''")
|
| 121 |
+
assert '\r' not in r.headers['content-disposition'] and '\n' not in r.headers['content-disposition']
|
| 122 |
+
assert 'Injected' not in r.headers
|
| 123 |
+
assert r.headers['content-security-policy'] == 'sandbox'
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_query_parameters_survive_route_discovery_and_are_encoded():
|
| 127 |
+
api = {"paths": {"/tasks/{id}": {"parameters": [{"name": "split", "in": "query", "required": True, "schema": {"type": "string"}}],
|
| 128 |
+
"get": {"parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "default": 10}}]}}}}
|
| 129 |
+
info = {"api": live._api_routes(api, False)}
|
| 130 |
+
schema = info["api"][0]["query_schema"]
|
| 131 |
+
assert schema["required"] == ["split"] and schema["properties"]["limit"]["default"] == 10
|
| 132 |
+
assert live.route_url(info, "GET", "/tasks/{id}", {"id": "a"}, {"split": "x&admin=true"}) == '/tasks/a?split=x%26admin%3Dtrue'
|
| 133 |
+
with pytest.raises(live.SpaceError, match='required'):
|
| 134 |
+
live.route_url(info, "GET", "/tasks/{id}", {"id": "a"})
|
| 135 |
+
with pytest.raises(live.SpaceError, match='published'):
|
| 136 |
+
live.route_url(info, "GET", "/tasks/{id}", {"id": "a"}, {"url": "https://evil.example"})
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_mcp_routes_separate_body_path_and_query_and_keep_grading_routes(monkeypatch):
|
| 140 |
+
from app import mcp_bridge
|
| 141 |
+
api = {"paths": {"/verify/{id}": {"post": {
|
| 142 |
+
"parameters": [{"name": "id", "in": "query", "schema": {"type": "string"}}],
|
| 143 |
+
"requestBody": {"required": True, "content": {"application/json": {"schema": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}}},
|
| 144 |
+
}}}}
|
| 145 |
+
info = {"api": live._api_routes(api, False), "mcp": [{"name": "look", "inputSchema": {"type": "object"}}]}
|
| 146 |
+
tools = {t['name']: t for t in mcp_bridge._tools(info)}
|
| 147 |
+
assert {'look', 'verify_id'} == set(tools)
|
| 148 |
+
assert set(tools['verify_id']['inputSchema']['properties']) == {'path_params', 'query', 'body'}
|
| 149 |
+
captured = []
|
| 150 |
+
monkeypatch.setattr(mcp_bridge, '_play', lambda *a: 'session')
|
| 151 |
+
monkeypatch.setattr(live, 'act', lambda *args: captured.append(args[-1]) or {'result': {'json': {'reward': 1}, 'status': 200}})
|
| 152 |
+
args = {"path_params": {"id": "path-id"}, "query": {"id": "query-id"}, "body": {"id": 42}}
|
| 153 |
+
mcp_bridge._call('o/s', info, {"owner": "owner"}, 'verify_id', args)
|
| 154 |
+
assert captured[0]['params'] == args['path_params'] and captured[0]['body'] == args['body'] and captured[0]['query'] == args['query']
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_mcp_rate_limiter_does_not_accumulate_rejected_requests():
|
| 158 |
+
from app import mcp_bridge
|
| 159 |
+
owner = 'rate-regression'
|
| 160 |
+
for _ in range(mcp_bridge.RATE[0] * 3):
|
| 161 |
+
mcp_bridge._limited(owner)
|
| 162 |
+
assert len(mcp_bridge._hits[owner]) == mcp_bridge.RATE[0]
|
| 163 |
+
mcp_bridge._hits.pop(owner)
|
tests/test_auto_index.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gzip
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app import auto_index, config, indexer, snapshot
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture(autouse=True)
|
| 11 |
+
def storage(tmp_path, monkeypatch):
|
| 12 |
+
monkeypatch.setattr(config, "STORAGE_DIR", tmp_path)
|
| 13 |
+
monkeypatch.setattr(config, "CACHE_DIR", tmp_path / "cache")
|
| 14 |
+
monkeypatch.setenv("RLX_SNAPSHOT_STORE", str(tmp_path))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_recent_full_listing_is_reused_without_extending_discovery_age(monkeypatch):
|
| 18 |
+
at = time.time() - 300
|
| 19 |
+
rows = [{"id": "owner/env", "kind": "dataset"}]
|
| 20 |
+
(config.STORAGE_DIR / "listing.json.gz").write_bytes(gzip.compress(json.dumps({"at": at, "full": True, "rows": rows}).encode()))
|
| 21 |
+
called = []
|
| 22 |
+
def run(store, **kwargs):
|
| 23 |
+
called.append(kwargs)
|
| 24 |
+
return {"counts": {"envs": 1, "tasks": 2}}
|
| 25 |
+
monkeypatch.setattr(indexer, "run", run)
|
| 26 |
+
auto_index.cycle()
|
| 27 |
+
assert called == [{"rows": rows, "listing_at": at, "budget_s": 90, "max_builds": 4}]
|
| 28 |
+
assert auto_index.status()["state"] == "idle"
|
| 29 |
+
assert auto_index.status()["last_success"] >= at
|
| 30 |
+
auto_index.cycle()
|
| 31 |
+
assert len(called) == 1 # app restarts and duplicate workers honor cadence
|
| 32 |
+
assert auto_index.cached_listing(at + 3601) == (None, None)
|
| 33 |
+
assert auto_index.cached_listing(at - 1) == (None, None)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_locked_worker_cannot_publish_duplicate_snapshot(monkeypatch):
|
| 37 |
+
fcntl = pytest.importorskip("fcntl")
|
| 38 |
+
config.CACHE_DIR.mkdir()
|
| 39 |
+
monkeypatch.setattr(indexer, "run", lambda *a, **k: pytest.fail("duplicate indexer"))
|
| 40 |
+
with (config.CACHE_DIR / "auto-index.lock").open("a") as lease:
|
| 41 |
+
fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 42 |
+
auto_index.cycle()
|
| 43 |
+
assert auto_index.status() == {}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_failure_preserves_published_pointer_and_backs_off(monkeypatch):
|
| 47 |
+
store = snapshot.open_store()
|
| 48 |
+
store.put_bytes(snapshot.POINTER, b"last-good-snapshot")
|
| 49 |
+
def fail(*a, **k):
|
| 50 |
+
raise ConnectionError("upstream response must not enter public status")
|
| 51 |
+
monkeypatch.setattr(indexer, "run", fail)
|
| 52 |
+
auto_index.cycle()
|
| 53 |
+
first = auto_index.status()
|
| 54 |
+
assert first["state"] == "retrying" and first["error"] == "ConnectionError"
|
| 55 |
+
assert first["next_at"] > time.time() + 590
|
| 56 |
+
assert store.read_bytes(snapshot.POINTER) == b"last-good-snapshot"
|
| 57 |
+
auto_index._save({**first, "next_at": 0})
|
| 58 |
+
auto_index.cycle()
|
| 59 |
+
assert auto_index.status()["failures"] == 2
|
| 60 |
+
assert auto_index.status()["next_at"] > time.time() + 1190
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_missing_or_partial_listing_requires_fresh_discovery():
|
| 64 |
+
assert auto_index.cached_listing(time.time()) == (None, None)
|
| 65 |
+
doc = {"at": time.time(), "full": False, "rows": [{"id": "owner/partial"}]}
|
| 66 |
+
(config.STORAGE_DIR / "listing.json.gz").write_bytes(gzip.compress(json.dumps(doc).encode()))
|
| 67 |
+
assert auto_index.cached_listing(time.time()) == (None, None)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_shutdown_terminates_only_owned_indexer_child(monkeypatch):
|
| 71 |
+
calls = []
|
| 72 |
+
class Child:
|
| 73 |
+
def poll(self): return None
|
| 74 |
+
def terminate(self): calls.append("terminate")
|
| 75 |
+
def wait(self, **kwargs): calls.append("wait")
|
| 76 |
+
def popen(command, **kwargs):
|
| 77 |
+
assert command[-2:] == ["-m", "app.auto_index"]
|
| 78 |
+
assert kwargs["env"]["HF_HUB_DISABLE_IMPLICIT_TOKEN"] == "1"
|
| 79 |
+
return Child()
|
| 80 |
+
monkeypatch.setattr(auto_index.subprocess, "Popen", popen)
|
| 81 |
+
auto_index._stop.set()
|
| 82 |
+
try:
|
| 83 |
+
auto_index._child()
|
| 84 |
+
finally:
|
| 85 |
+
auto_index._stop.clear()
|
| 86 |
+
assert calls == ["terminate", "wait"]
|
tests/test_capabilities.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Support claims reflect runtime evidence, not a source's framework tag."""
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
from app import spaces_live as live
|
| 5 |
+
from app.envs.capabilities import dataset_support, space_support
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def state(support, feature):
|
| 9 |
+
return next(c["state"] for c in support["capabilities"] if c["id"] == feature)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.mark.parametrize("framework,runner", [("Verifiers", "verifiers"), ("NeMo Gym", "nemo-gym"), ("verl", "verl"), ("SkyRL", "skyrl")])
|
| 13 |
+
def test_native_framework_dataset_does_not_claim_local_execution(framework, runner):
|
| 14 |
+
support = dataset_support(framework, options=[{"runner": runner, "label": framework, "ok": False, "why": "Use its native harness"}])
|
| 15 |
+
assert state(support, "browse") == "available"
|
| 16 |
+
assert state(support, "run") == "external"
|
| 17 |
+
assert support["framework"]["docs"].startswith("https://")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_task_support_distinguishes_blocked_from_runnable():
|
| 21 |
+
options = [{"runner": "harbor", "label": "Harbor", "ok": False, "why": "GPU required"}]
|
| 22 |
+
assert state(dataset_support("Harbor", options=options), "run") == "unavailable"
|
| 23 |
+
options[0]["ok"] = True
|
| 24 |
+
assert state(dataset_support("Harbor", options=options), "run") == "available"
|
| 25 |
+
assert state(dataset_support("Harbor"), "run") == "unknown"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_sleeping_server_never_claims_live_capabilities():
|
| 29 |
+
s = space_support({"running": False, "stage": "SLEEPING", "last_seen": {"step_api": True}})
|
| 30 |
+
assert state(s, "play") == "unknown"
|
| 31 |
+
assert s["transport"] is None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.mark.parametrize("protocol", ["openenv", "gymnasium", "nemo-gym", "mcp", "api"])
|
| 35 |
+
def test_probe_uses_published_protocol_not_reset_route_or_tag(monkeypatch, protocol):
|
| 36 |
+
from app import catalog
|
| 37 |
+
catalog._memo.clear()
|
| 38 |
+
rec = {"id": "fixture/env", "stage": "RUNNING", "host": "https://fixture-env.hf.space", "base_path": None, "tags": ["openenv"]}
|
| 39 |
+
monkeypatch.setattr(live, "record", lambda *a, **k: rec)
|
| 40 |
+
paths = {"/reset": {"post": {}}, "/step": {"post": {}}} if protocol in {"openenv", "gymnasium"} else (
|
| 41 |
+
{"/seed_session": {"post": {}}, "/verify": {"post": {}}} if protocol == "nemo-gym" else {"/mcp": {"post": {}}} if protocol == "mcp" else {"/guess": {"post": {}}})
|
| 42 |
+
docs = {"/openapi.json": {"info": {"version": "1"}, "paths": paths}}
|
| 43 |
+
if protocol == "openenv":
|
| 44 |
+
docs["/schema"] = {k: {"type": "object", "properties": {}} for k in ("action", "observation", "state")}
|
| 45 |
+
monkeypatch.setattr(live, "_get_json", lambda rec, path: docs.get(path))
|
| 46 |
+
monkeypatch.setattr(live, "_ui_path", lambda rec: None)
|
| 47 |
+
monkeypatch.setattr(live, "_tools", lambda rec: ([{"name": "guess"}] if protocol == "mcp" else [], None, {}))
|
| 48 |
+
monkeypatch.setattr(live, "remember", lambda *a: None)
|
| 49 |
+
info = live.probe("fixture/env")
|
| 50 |
+
assert info["framework"] == protocol
|
| 51 |
+
assert info["openenv"] is (protocol == "openenv")
|
| 52 |
+
assert info["step_api"] is (protocol == "openenv")
|
| 53 |
+
if protocol == "gymnasium":
|
| 54 |
+
assert {r["path"] for r in info["api"]} == {"/reset", "/step"}
|
| 55 |
+
assert info["support"]["transport"] == "http"
|
| 56 |
+
assert state(info["support"], "play") == "available"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_private_dataset_does_not_offer_public_mcp():
|
| 60 |
+
assert state(dataset_support("Harbor", restricted=True), "mcp") == "unavailable"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_packed_harbor_rows_keep_the_harbor_lifecycle():
|
| 64 |
+
assert dataset_support("Harbor (packed)")["framework"]["id"] == "harbor"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_unknown_runner_is_not_claimed_as_hosted():
|
| 68 |
+
assert state(dataset_support("Custom", options=[{"runner": "custom", "label": "Custom", "ok": True}]), "run") == "unavailable"
|
tests/test_community.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from fastapi.testclient import TestClient
|
| 5 |
+
|
| 6 |
+
from app import auth, community, main, settings, store
|
| 7 |
+
|
| 8 |
+
client = TestClient(main.app)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def record(id, runner="harbor", **fields):
|
| 12 |
+
return {"id": id, "dataset": f"org/{runner}", "path": "task/1", "title": "A task", "user": "hidden-user",
|
| 13 |
+
"runner": runner, "status": "done", "reward": 1, "visibility": "public", "model": "org/model",
|
| 14 |
+
"created_at": 10, **fields}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def records(monkeypatch):
|
| 19 |
+
rows = [record("harbor", reward=-3, dataset="a/same"), record("mimo", "mimo", domain="code", reward=0.25),
|
| 20 |
+
record("nemo", "nemo-gym", reward=1, dataset="b/same"), record("future", "custom-runtime", reward=20),
|
| 21 |
+
record("private", visibility="private"), record("restricted", restricted=True),
|
| 22 |
+
record("failed", status="failed", reward=None), record("hidden")]
|
| 23 |
+
monkeypatch.setattr(store, "list_runs", lambda **kw: rows)
|
| 24 |
+
monkeypatch.setattr(store, "has_artifact", lambda *a: False)
|
| 25 |
+
monkeypatch.setattr(settings, "get", lambda key, default=None: ["hidden"] if key == "hidden_runs" else default)
|
| 26 |
+
return rows
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_all_saved_runners_share_the_feed_with_scoped_counts(records):
|
| 30 |
+
data = client.get("/api/community").json()
|
| 31 |
+
assert {r["id"] for r in data["runs"]} == {"harbor", "mimo", "nemo", "future"}
|
| 32 |
+
assert data["total"] == data["stats"]["runs"] == 4
|
| 33 |
+
assert data["stats"]["datasets"] == data["stats"]["runners"] == 4
|
| 34 |
+
assert not data["stats"]["comparable"]
|
| 35 |
+
assert "mean" not in data["stats"] # no meaningless global reward average
|
| 36 |
+
assert {r["group"] for r in data["runs"]} >= {"a/same", "b/same"}
|
| 37 |
+
assert "hidden-user" not in json.dumps(data)
|
| 38 |
+
for runner in ("harbor", "mimo", "nemo-gym", "custom-runtime"):
|
| 39 |
+
subset = client.get("/api/community", params={"runner": runner}).json()
|
| 40 |
+
assert subset["total"] == subset["stats"]["runs"] == subset["stats"]["datasets"] == 1
|
| 41 |
+
assert {r["runner"] for r in subset["runs"]} == {runner}
|
| 42 |
+
assert len(subset["facets"]["runner"]) == 4 # own facet retains alternatives
|
| 43 |
+
assert len(subset["facets"]["dataset"]) == 1
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_search_includes_environment_runner_and_model_and_empty_counts(records):
|
| 47 |
+
for query, expected in (("b/same", "nemo"), ("nemo-gym", "nemo"), ("org/mimo model", "mimo")):
|
| 48 |
+
data = client.get("/api/community", params={"q": query}).json()
|
| 49 |
+
assert [r["id"] for r in data["runs"]] == [expected]
|
| 50 |
+
empty = client.get("/api/community?dataset=nobody/missing").json()
|
| 51 |
+
assert empty["total"] == empty["stats"]["datasets"] == empty["stats"]["models"] == empty["stats"]["runners"] == 0
|
| 52 |
+
assert empty["stats"]["results"] == []
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_reward_summaries_separate_metrics_domains_judges_and_revisions():
|
| 56 |
+
rows = [record("a", reward=-4), record("b", reward=-2)]
|
| 57 |
+
for field, value in (("reward_key", "latency"), ("domain", "code"), ("judge", "judge/new"), ("sha", "new-revision")):
|
| 58 |
+
rows.append(record(field, reward=100, **{field: value}))
|
| 59 |
+
data = community.stats(rows)
|
| 60 |
+
assert len(data["results"]) == 5
|
| 61 |
+
original = next(r for r in data["results"] if r["runs"] == 2)
|
| 62 |
+
assert original["mean"] == -3 and original["max"] == -2
|
| 63 |
+
assert community.stats([record("a", reward=1e308), record("b", reward=1e308)])["results"][0]["mean"] == 1e308
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@pytest.mark.parametrize("value", [None, "1", True, {}, float("nan"), float("inf"), 10**1000])
|
| 67 |
+
def test_invalid_rewards_cannot_break_or_enter_public_feed(value, monkeypatch):
|
| 68 |
+
monkeypatch.setattr(settings, "get", lambda *a: [])
|
| 69 |
+
assert not main._shareable(record("bad", reward=value))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_pagination_sort_and_negative_reward_band(records):
|
| 73 |
+
records.extend(record(f"r{i:02}", created_at=i, reward=-i, cost={"total": None}) for i in range(20))
|
| 74 |
+
all_rows = client.get("/api/community?sort=reward_asc").json()["runs"]
|
| 75 |
+
pages = [client.get("/api/community", params={"sort": "reward_asc", "offset": i, "limit": 5}).json()["runs"] for i in range(0, 24, 5)]
|
| 76 |
+
assert [r["id"] for page in pages for r in page] == [r["id"] for r in all_rows]
|
| 77 |
+
negative = client.get("/api/community?reward=zero").json()
|
| 78 |
+
assert negative["total"] == 21 and all(r["reward"] <= 0 for r in negative["runs"])
|
| 79 |
+
assert client.get("/api/community?sort=cost_asc").status_code == 200
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@pytest.mark.parametrize("runner,domain,events", [("harbor", None, False), ("mimo", "code", True), ("nemo-gym", "nemo-gym", True), (None, "code", True)])
|
| 83 |
+
def test_trace_routes_match_runner_and_keep_public_scrubbing(monkeypatch, runner, domain, events):
|
| 84 |
+
from app.mimo.runner import core
|
| 85 |
+
r = record("trace", runner, domain=domain)
|
| 86 |
+
monkeypatch.setattr(store, "get", lambda id: r)
|
| 87 |
+
monkeypatch.setattr(settings, "get", lambda *a: [])
|
| 88 |
+
monkeypatch.setattr(auth, "current_user", lambda request: None)
|
| 89 |
+
monkeypatch.setattr(store, "read_artifact", lambda *a: b'[{"content":"hidden-user"}]')
|
| 90 |
+
monkeypatch.setattr(main.runner, "is_live", lambda *a: False)
|
| 91 |
+
monkeypatch.setattr(core, "live", lambda *a: None)
|
| 92 |
+
monkeypatch.setattr(core, "events", lambda *a: [{"content": "hidden-user"}])
|
| 93 |
+
response = client.get("/api/runs/trace")
|
| 94 |
+
assert response.status_code == 200 and "hidden-user" not in response.text
|
| 95 |
+
assert ("events" in response.json()) == events
|
| 96 |
+
assert response.json()["run"]["runner"] == (runner or "mimo")
|
tests/test_deploy_spaces.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from scripts import deploy_spaces
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_upload_manifest_excludes_local_secrets_and_symlinks(tmp_path, monkeypatch):
|
| 5 |
+
for name in ('app/main.py','web/app.css','.env','.env.production','sub/.env.local','sub/private.key','.local-data/run.json','.venv/env.py'):
|
| 6 |
+
path=tmp_path/name
|
| 7 |
+
path.parent.mkdir(parents=True,exist_ok=True)
|
| 8 |
+
path.write_text('sample')
|
| 9 |
+
(tmp_path/'linked.py').symlink_to(tmp_path/'.env')
|
| 10 |
+
(tmp_path/'linked-dir').symlink_to(tmp_path/'.local-data',target_is_directory=True)
|
| 11 |
+
monkeypatch.setattr(deploy_spaces,'ROOT',tmp_path)
|
| 12 |
+
assert deploy_spaces.files()==['app/main.py','web/app.css']
|
tests/test_discovery.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Discovery evidence is metadata, not a runtime attestation or an editorial decision."""
|
| 2 |
+
from types import SimpleNamespace
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
from huggingface_hub import SpaceInfo
|
| 6 |
+
|
| 7 |
+
from app import catalog
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def space(sid="org/env", *, tags=(), files=(), **kw):
|
| 11 |
+
return SpaceInfo(id=sid, sdk="docker", private=False, tags=list(tags), likes=0,
|
| 12 |
+
siblings=[{"rfilename": f} for f in files], **kw)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.mark.parametrize("files", [("openenv.yaml",), ("my_env/openenv.yml",)])
|
| 16 |
+
def test_manifest_discovery(files):
|
| 17 |
+
d = catalog._space_summary(space(files=files))
|
| 18 |
+
assert d["openenv"] and d["manifest"] == files[0]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.mark.parametrize("file", ["src/openenv.yaml", "templates/openenv.yaml", "examples/openenv.yaml",
|
| 22 |
+
"build/openenv.yaml", "vendor/openenv.yaml", "envs/echo/openenv.yaml",
|
| 23 |
+
"src/openenv/cli/templates/openenv.yaml", ".venv/openenv.yaml"])
|
| 24 |
+
def test_vendored_manifests_are_not_declarations(file):
|
| 25 |
+
d = catalog._space_summary(space(files=[file]))
|
| 26 |
+
assert not d["openenv"] and d["manifest"] is None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.mark.parametrize("tag", ["openenv", "library:openenv", "openenv-0.7.0"])
|
| 30 |
+
def test_tag_only_remains_a_candidate_without_a_manifest(tag):
|
| 31 |
+
d = catalog._space_summary(space(tags=[tag]))
|
| 32 |
+
assert d["openenv"] and d["manifest"] is None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_names_and_generic_rl_mcp_tags_do_not_establish_openenv():
|
| 36 |
+
d = catalog._space_summary(space("openenv/not-an-env", tags=["rl-environment", "mcp-server"]))
|
| 37 |
+
assert not d["openenv"] and d["mcp"]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_space_census_deduplicates_and_applies_visibility_to_featured(monkeypatch):
|
| 41 |
+
good = space(tags=["openenv"])
|
| 42 |
+
article = space("org/article", tags=["openenv", "research-article-template"])
|
| 43 |
+
private = space("org/private"); private.private = True
|
| 44 |
+
static = space("org/static"); static.sdk = "static"
|
| 45 |
+
by = {s.id: s for s in [good, article, private, static]}
|
| 46 |
+
monkeypatch.setattr(catalog, "collections", lambda: [{"ids": [f"space:{s}" for s in by]}])
|
| 47 |
+
monkeypatch.setattr(catalog, "_api", lambda: SimpleNamespace(list_spaces=lambda **kw: iter(by.values()),
|
| 48 |
+
space_info=lambda sid, **kw: by[sid]))
|
| 49 |
+
assert [r["id"] for r in catalog.spaces()] == [good.id]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_failed_full_census_is_not_silently_published(monkeypatch):
|
| 53 |
+
def listing(**kw):
|
| 54 |
+
yield space(tags=["openenv"])
|
| 55 |
+
raise ConnectionError("page two unavailable")
|
| 56 |
+
|
| 57 |
+
monkeypatch.setattr(catalog, "collections", lambda: [])
|
| 58 |
+
monkeypatch.setattr(catalog, "_api", lambda: SimpleNamespace(list_spaces=listing))
|
| 59 |
+
with pytest.raises(ConnectionError):
|
| 60 |
+
catalog.spaces(full=True)
|
| 61 |
+
assert len(catalog.spaces(full=False)) == 1 # a quick bootstrap may serve candidates while refreshing
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_collection_never_reclassifies_framework(monkeypatch):
|
| 65 |
+
d = catalog._space_summary(space(tags=["ors"]))
|
| 66 |
+
monkeypatch.setattr(catalog, "_listing_rows", lambda: [d])
|
| 67 |
+
monkeypatch.setattr(catalog, "collections", lambda: [{"id": "fineenvs", "ids": [d["key"]]}])
|
| 68 |
+
monkeypatch.setattr(catalog, "hidden", lambda: set())
|
| 69 |
+
assert catalog.environments()[0]["framework"] == "ors"
|
tests/test_frameworks.py
CHANGED
|
@@ -135,7 +135,9 @@ def test_nemo_gym_pivot_row_reads_as_a_conversation_and_hides_the_experts_next_s
|
|
| 135 |
assert "--agent toolcall_schema_single_step_tool_use_with_argument_comparison_agent" in run and "gym eval run --no-serve" in run
|
| 136 |
assert "--repo-id nvidia/Nemotron-RL-Agentic-Function-Calling-Pivot-v1" in run and "sed -n '1p'" in run
|
| 137 |
opt = view["framework_run"]
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
assert ["Step", "turn 6 · step 1"] in view["glance"] and card["chips"][0] == "turn 6 · step 1"
|
| 140 |
|
| 141 |
|
|
@@ -210,6 +212,8 @@ def test_nemo_gym_blends_route_each_row_and_run_without_one_agent():
|
|
| 210 |
def test_nemo_gym_catalog_is_current_and_complete():
|
| 211 |
cat = nemogym.catalog()
|
| 212 |
assert len(cat["commit"]) == 40 and len(cat["agents"]) > 100 and len(cat["servers"]) > 100
|
|
|
|
|
|
|
| 213 |
for name in ("mcqa_simple_agent", "workplace_assistant_simple_agent", "calendar_simple_agent", "code_gen_simple_agent",
|
| 214 |
"instruction_following_simple_agent", "reasoning_gym_simple_agent"):
|
| 215 |
a = cat["agents"][name]
|
|
@@ -406,3 +410,11 @@ def test_search_never_finds_a_row_through_its_withheld_answer(monkeypatch):
|
|
| 406 |
files = rows.Files.__new__(rows.Files)
|
| 407 |
monkeypatch.setattr(rows.Files, "_scan", lambda self, c, s, o, match, what: ({"rows": [h for h in found if match(h[1])]}, None))
|
| 408 |
assert [i for i, _, _ in files.search("d", "train", "7", 0)[0]["rows"]] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
assert "--agent toolcall_schema_single_step_tool_use_with_argument_comparison_agent" in run and "gym eval run --no-serve" in run
|
| 136 |
assert "--repo-id nvidia/Nemotron-RL-Agentic-Function-Calling-Pivot-v1" in run and "sed -n '1p'" in run
|
| 137 |
opt = view["framework_run"]
|
| 138 |
+
# This browsing fixture mixes Chat Completions and Responses tool formats;
|
| 139 |
+
# execution requires the dataset's native Responses function definitions.
|
| 140 |
+
assert opt["runner"] == "nemo-gym" and opt["ok"] is False and "native NeMo Gym" in opt["why"]
|
| 141 |
assert ["Step", "turn 6 · step 1"] in view["glance"] and card["chips"][0] == "turn 6 · step 1"
|
| 142 |
|
| 143 |
|
|
|
|
| 212 |
def test_nemo_gym_catalog_is_current_and_complete():
|
| 213 |
cat = nemogym.catalog()
|
| 214 |
assert len(cat["commit"]) == 40 and len(cat["agents"]) > 100 and len(cat["servers"]) > 100
|
| 215 |
+
assert cat["servers"]["instruction_following"]["verification"] == "Rule-based instruction checks"
|
| 216 |
+
assert cat["servers"]["math_proof_judgement"]["verification"] == "Deterministic answer parsing"
|
| 217 |
for name in ("mcqa_simple_agent", "workplace_assistant_simple_agent", "calendar_simple_agent", "code_gen_simple_agent",
|
| 218 |
"instruction_following_simple_agent", "reasoning_gym_simple_agent"):
|
| 219 |
a = cat["agents"][name]
|
|
|
|
| 410 |
files = rows.Files.__new__(rows.Files)
|
| 411 |
monkeypatch.setattr(rows.Files, "_scan", lambda self, c, s, o, match, what: ({"rows": [h for h in found if match(h[1])]}, None))
|
| 412 |
assert [i for i, _, _ in files.search("d", "train", "7", 0)[0]["rows"]] == []
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def test_verifiers_v1_taskdata_system_prompt_is_part_of_the_conversation():
|
| 416 |
+
row = {"prompt": "Solve the task", "system_prompt": "Use the supplied tools", "answer": "PRIVATE_REFERENCE"}
|
| 417 |
+
view, _, _, _, text = everything(verifiers_rows.Verifiers(), row, tags=["verifiers"])
|
| 418 |
+
turns = next(s for s in view["sections"] if s["id"] == "task")["body"][0]["data"]["turns"]
|
| 419 |
+
assert [(t["role"], t["text"]) for t in turns] == [("system", "Use the supplied tools"), ("user", "Solve the task")]
|
| 420 |
+
assert "PRIVATE_REFERENCE" not in text
|
tests/test_live.py
CHANGED
|
@@ -352,7 +352,7 @@ OPENAPI = {"paths": {
|
|
| 352 |
|
| 353 |
def test_routes_are_plain_paths_on_non_openenv_servers_only():
|
| 354 |
routes = live._api_routes(OPENAPI, openenv=False)
|
| 355 |
-
assert {(r["method"], r["path"]) for r in routes} == {("POST", "/guess"), ("GET", "/tasks/{task_id}"), ("POST", "/self")}
|
| 356 |
guess = next(r for r in routes if r["path"] == "/guess")
|
| 357 |
assert guess["schema"]["properties"]["word"] == {"type": "string", "title": "Word"} and guess["schema"]["required"] == ["word"]
|
| 358 |
assert next(r for r in routes if r["path"] == "/tasks/{task_id}")["params"] == ["task_id"]
|
|
@@ -383,8 +383,8 @@ def test_route_calls_keep_their_cookies_and_withhold_answers_from_gets(monkeypat
|
|
| 383 |
return httpx.Response(200, json={"output": "ok"}, headers={"set-cookie": "session=abc; path=/"})
|
| 384 |
return httpx.Response(200, json={"task_id": "t1", "prompt": "p", "answer": "SECRET", "meta": {"ground_truth": "TRUTHVAL"}})
|
| 385 |
|
| 386 |
-
real =
|
| 387 |
-
monkeypatch.setattr(live
|
| 388 |
sid = live.start("org/env", "web:a")["session"]
|
| 389 |
out = live.act(sid, "web:a", "http", {"method": "POST", "path": "/guess", "body": {"word": "crane"}})["result"]
|
| 390 |
assert out["status"] == 200 and out["json"] == {"output": "ok"}
|
|
@@ -397,7 +397,7 @@ def test_route_calls_keep_their_cookies_and_withhold_answers_from_gets(monkeypat
|
|
| 397 |
|
| 398 |
def test_a_reset_step_server_still_lists_its_tools(monkeypatch):
|
| 399 |
"""No MCP tools of its own (OpenEnv answers "Environment does not support MCP"): the page gets exactly what the MCP
|
| 400 |
-
bridge offers an agent, step taking the server's own action fields.
|
| 401 |
info = {"running": True, "stage": "RUNNING", "step_api": True, "mcp": None, "task_api": None,
|
| 402 |
"schema": {"action": {"type": "object", "properties": {"latex": {"type": "string", "description": "Predicted LaTeX"}}, "required": ["latex"]}}}
|
| 403 |
monkeypatch.setattr(live, "probe", lambda spec, fresh=False: info)
|
|
@@ -406,4 +406,4 @@ def test_a_reset_step_server_still_lists_its_tools(monkeypatch):
|
|
| 406 |
assert "latex" in json.dumps(tools["step"]["inputSchema"])
|
| 407 |
assert tools == {t["name"]: t for t in mcp_bridge._tools(info)} # the page and the bridge never disagree
|
| 408 |
monkeypatch.setattr(live, "probe", lambda spec, fresh=False: {**info, "mcp": [{"name": "look", "inputSchema": {"type": "object"}}]})
|
| 409 |
-
assert "
|
|
|
|
| 352 |
|
| 353 |
def test_routes_are_plain_paths_on_non_openenv_servers_only():
|
| 354 |
routes = live._api_routes(OPENAPI, openenv=False)
|
| 355 |
+
assert {(r["method"], r["path"]) for r in routes} == {("POST", "/guess"), ("GET", "/tasks/{task_id}"), ("POST", "/self"), ("POST", "/reset")}
|
| 356 |
guess = next(r for r in routes if r["path"] == "/guess")
|
| 357 |
assert guess["schema"]["properties"]["word"] == {"type": "string", "title": "Word"} and guess["schema"]["required"] == ["word"]
|
| 358 |
assert next(r for r in routes if r["path"] == "/tasks/{task_id}")["params"] == ["task_id"]
|
|
|
|
| 383 |
return httpx.Response(200, json={"output": "ok"}, headers={"set-cookie": "session=abc; path=/"})
|
| 384 |
return httpx.Response(200, json={"task_id": "t1", "prompt": "p", "answer": "SECRET", "meta": {"ground_truth": "TRUTHVAL"}})
|
| 385 |
|
| 386 |
+
real = live.SpaceClient
|
| 387 |
+
monkeypatch.setattr(live, "SpaceClient", lambda **kw: real(transport=httpx.MockTransport(handler), **kw))
|
| 388 |
sid = live.start("org/env", "web:a")["session"]
|
| 389 |
out = live.act(sid, "web:a", "http", {"method": "POST", "path": "/guess", "body": {"word": "crane"}})["result"]
|
| 390 |
assert out["status"] == 200 and out["json"] == {"output": "ok"}
|
|
|
|
| 397 |
|
| 398 |
def test_a_reset_step_server_still_lists_its_tools(monkeypatch):
|
| 399 |
"""No MCP tools of its own (OpenEnv answers "Environment does not support MCP"): the page gets exactly what the MCP
|
| 400 |
+
bridge offers an agent, step taking the server's own action fields. Tool servers use the same descriptor."""
|
| 401 |
info = {"running": True, "stage": "RUNNING", "step_api": True, "mcp": None, "task_api": None,
|
| 402 |
"schema": {"action": {"type": "object", "properties": {"latex": {"type": "string", "description": "Predicted LaTeX"}}, "required": ["latex"]}}}
|
| 403 |
monkeypatch.setattr(live, "probe", lambda spec, fresh=False: info)
|
|
|
|
| 406 |
assert "latex" in json.dumps(tools["step"]["inputSchema"])
|
| 407 |
assert tools == {t["name"]: t for t in mcp_bridge._tools(info)} # the page and the bridge never disagree
|
| 408 |
monkeypatch.setattr(live, "probe", lambda spec, fresh=False: {**info, "mcp": [{"name": "look", "inputSchema": {"type": "object"}}]})
|
| 409 |
+
assert {t["name"] for t in client.get("/api/spaces/o/geo/live").json()["bridge_tools"]} == {"look", "reset", "state"}
|