Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 18,133 Bytes
da5cba1 6abb7be da5cba1 6abb7be da5cba1 6abb7be da5cba1 6abb7be da5cba1 6abb7be da5cba1 6abb7be da5cba1 6abb7be da5cba1 6abb7be da5cba1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 | """One rollout, start to finish: sandbox up, environment set, agent runs, task's own verifier grades.
Everything a rollout does becomes an *event*, kept in memory for live viewers and appended to
`events.jsonl` in batches. Event kinds the UI understands:
phase {name, status: "start"|"done"|"error", detail}
text {text} an assistant message
tool {tool, title, input, output, status, ms}
step {tokens: {...}, cost} one model call finished
log {text} setup / verifier output worth showing
checks {checks: [{id, passed, score, method, tier, weight, message}], reward, summary}
error {text}
The user's token is held here for the rollout's lifetime only. It never reaches an event or a file.
"""
from __future__ import annotations
import json
import re
import secrets
import threading
import time
import traceback
import uuid
from concurrent.futures import ThreadPoolExecutor
from .. import catalog, config, models, store, version
_pool = ThreadPoolExecutor(max_workers=config.MAX_ACTIVE_ROLLOUTS, thread_name_prefix="rollout")
_live: dict[str, "Rollout"] = {}
_caps: dict[str, "Rollout"] = {} # model-proxy capability -> its rollout, while it runs
_live_lock = threading.Lock()
# key-shaped: a known prefix, then a run with an uppercase letter or a digit (code names like hf_raise_for_status don't match)
SECRET_RE = re.compile(r"\b(hf_|sk-|sk_|api_key=|Bearer )(?=[A-Za-z0-9\-]*[A-Z0-9])[A-Za-z0-9\-]{4,}(?![a-z_])")
ACTIVE_STATES = {"queued", "starting", "setup", "running", "verifying"}
class Cancelled(Exception):
pass
class Rollout:
def __init__(self, run: dict, token: str, agent_key: str | None = None):
self.run = run
self.id = run["id"]
self.token = token
self.agent_key = agent_key # a bring-your-own endpoint's key: memory only, like the HF token
# what the sandbox gets instead of credentials (see config.PUBLIC_URL): valid while this rollout is live
self.cap = secrets.token_urlsafe(32)
self.stream: dict | None = None # the model reply streaming through the proxy right now, counted as it goes
self.events: list[dict] = store.read_events(self.id)
self._pending: list[dict] = []
self._last_flush = time.time()
self._lock = threading.Lock()
self.t0 = time.time()
self.sandbox = None
self.cancelled = threading.Event()
self.tokens = {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0}
self.model_cost = 0.0
# ββ events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _redact(self, v):
"""Nothing secret reaches a trace: the user's HF token, an endpoint key, or anything shaped like one
(the General verifier, for one, prints the first characters of its judge key)."""
if isinstance(v, str):
for secret in (self.token, self.agent_key, getattr(self, "cap", None)):
if secret:
v = v.replace(secret, "[redacted]")
return SECRET_RE.sub(lambda m: m.group(1) + "[redacted]", v)
if isinstance(v, dict):
return {k: self._redact(x) for k, x in v.items()}
if isinstance(v, list):
return [self._redact(x) for x in v]
return v
def emit(self, kind: str, **data) -> None:
ev = {"i": len(self.events), "t": round(time.time() - self.t0, 2), "kind": kind, **self._redact(data)}
with self._lock:
self.events.append(ev)
self._pending.append(ev)
if time.time() - self._last_flush > 1.0 or kind in ("phase", "checks", "error", "prompt"):
self._flush()
def _flush(self) -> None:
pending, self._pending = self._pending, []
self._last_flush = time.time()
store.append_events(self.id, pending)
def phase(self, name: str, status: str = "start", detail: str = "") -> None:
self.emit("phase", name=name, status=status, detail=detail)
if status == "start":
self.update(status={"sandbox": "starting", "setup": "setup", "agent": "running",
"verify": "verifying"}.get(name, self.run.get("status")), phase=name)
def log(self, text: str) -> None:
if text and text.strip():
self.emit("log", text=text.strip()[-6000:])
def add_tokens(self, t: dict) -> None:
for k in self.tokens:
self.tokens[k] += int(t.get(k) or 0)
ep = self.run.get("endpoint")
if ep: # your own endpoint: metered only if you told us its prices
t = self.tokens
self.model_cost = ((t["input"] + t["cache_read"] + t["cache_write"]) * (ep.get("price_in") or 0)
+ (t["output"] + t["reasoning"]) * (ep.get("price_out") or 0)) / 1e6
else:
self.model_cost = models.cost(self.run["model"], self.run.get("provider"), self.tokens)
self.update(tokens=self.tokens, cost=self.cost_now())
def cost_now(self) -> dict:
hours = (time.time() - self.t0) / 3600
sandbox = hours * config.FLAVOR_PRICE_PER_HOUR.get(self.run.get("flavor") or "", 0.0)
judge = self.run.get("judge_cost") or 0.0
return {"model": round(self.model_cost, 5), "judge": round(judge, 5), "sandbox": round(sandbox, 5),
"total": round(self.model_cost + judge + sandbox, 5)}
def update(self, **fields) -> None:
fields = self._redact(fields)
self.run.update(fields)
store.update(self.id, **fields)
def check_cancel(self) -> None:
if self.cancelled.is_set():
raise Cancelled()
# ββ lifecycle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def start_sandbox(self, image: str, flavor: str) -> "object":
from huggingface_hub import Sandbox
self.phase("sandbox", detail=f"{image.rsplit(':', 1)[-1]} on {flavor}")
self.update(flavor=flavor)
t = time.time()
self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
forward_hf_token=False, token=self.token, start_timeout=900,
labels={"app": "rl-explorer", "runner": "mimo", "run": self.id})
self.update(sandbox_id=self.sandbox.id)
self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
return self.sandbox
def agent_api(self) -> tuple[str, str | None, str]:
"""(base URL, key, model string) the agent's model calls go to."""
ep = self.run.get("endpoint")
if ep:
return ep["base_url"], self.agent_key, self.run["model"]
mid = f"{self.run['model']}:{self.run['provider']}" if self.run.get("provider") else self.run["model"]
return config.ROUTER, self.token, mid
def llm_base(self) -> str | None:
"""Only a per-run capability reaches the sandbox, including local previews."""
if config.PUBLIC_URL:
return f"{config.PUBLIC_URL}/api/llm/{self.cap}/v1"
from ...runner import _proxy_url
return f"{_proxy_url().rstrip('/')}/rlx/llm/{self.cap}/v1"
def check_model_network(self):
"""Check the exact relay path from the sandbox, without a model call."""
url = self.llm_base().removesuffix("/v1") + "/health"
script = ("import urllib.request,urllib.error\n"
f"url={url!r}\n"
"try:\n"
" r=urllib.request.urlopen(url,timeout=15)\n"
" print('relay HTTP',r.status)\n"
"except urllib.error.HTTPError as e:\n"
" print('relay HTTP',e.code);raise SystemExit(1)\n"
"except Exception as e:\n"
" print(type(e).__name__);raise SystemExit(1)\n")
import shlex
result = self.sh("python3 -c " + shlex.quote(script), timeout=25)
if result.exit_code:
raise RuntimeError("The sandbox cannot reach the model relay. Check HTTPS egress from the sandbox and ingress to the explorer. "
"No account token was forwarded. " + (result.stdout or "")[-120:])
self.log("Network check passed: sandbox β model relay. HF account token stays on the explorer.")
def upstream(self, model: str) -> tuple[str, str | None] | None:
"""For the model proxy: (base URL, key) for a model this rollout may call, else None."""
base, key, mid = self.agent_api()
if model == mid:
return base, key
if self.run.get("judge") and model == self.run["judge"]:
return config.ROUTER, self.token
return None
def sh(self, cmd: str, timeout: float = 600, **kw):
"""Run a shell command in the sandbox; never raises on a non-zero exit."""
self.check_cancel()
return self.sandbox.run(cmd, shell=True, check=False, timeout=timeout, **kw)
def settle_model(self) -> None:
"""Check, with tiny requests, that the model takes this rollout's settings, and adjust rather than fail.
Some models take no thinking level at all (Kimi-K2-Instruct answers HTTP 400 to reasoning_effort=low): run
those with thinking off. A provider that caps replies below the agent's output limit gets its own cap."""
p = self.run.get("params") or {}
base, key, mid = self.agent_api()
byo = bool(self.run.get("endpoint"))
eff = p.get("thinking")
if eff in ("low", "medium", "high") and _refused(base, key, mid, byo, {"reasoning_effort": eff}, r"reason|think|effort"):
p = {**p, "thinking": "none", "thinking_requested": eff}
self.update(params=p)
self.log(f"{self.run['model']} doesn't take a thinking level (it refused reasoning_effort={eff}), "
"so this rollout runs with thinking off.")
if self.run["domain"] != "music" and not p.get("max_tokens"):
from .opencode import OUTPUT_LIMIT
if _refused(base, key, mid, byo, {"max_tokens": OUTPUT_LIMIT}, r"token|length|context|max"):
self.update(params={**p, "output_limit": 32000})
self.log(f"The provider doesn't allow {OUTPUT_LIMIT:,}-token replies for this model; "
"the agent's replies are capped at 32,000 tokens.")
def execute(self) -> None:
from .domains import ADAPTERS
if self.run.get("runner") == "nemo-gym":
from ... import nemo_runner as adapter
else:
adapter = ADAPTERS[self.run["domain"]]
try:
self.update(status="starting", started_at=time.time())
self.settle_model()
result = adapter.run(self)
self.update(status="done", phase="done", reward=result.get("reward"), reward_error=result.get("error"),
graded=result.get("reward") is not None, wall_s=round(time.time() - self.t0, 2),
finished_at=time.time(), cost=self.cost_now())
self.phase("done", "done", f"reward {result.get('reward')}" if result.get("reward") is not None
else f"not scored: {result.get('error')}")
except Cancelled:
self.update(status="cancelled", finished_at=time.time(), cost=self.cost_now())
self.phase("done", "error", "cancelled")
except Exception as e: # noqa: BLE001
if self.cancelled.is_set(): # killing the sandbox makes the blocked call fail: that is the cancel, not a crash
self.update(status="cancelled", finished_at=time.time(), cost=self.cost_now())
else:
msg = friendly(e) or f"{type(e).__name__}: {str(e)[:300]}"
self.emit("error", text=msg, trace=traceback.format_exc()[-3000:])
self.update(status="failed", error=msg, finished_at=time.time(), cost=self.cost_now())
finally:
with self._lock:
self._flush()
self.stop_sandbox()
self.agent_key = None
self.native_task = None
with _live_lock:
_caps.pop(self.cap, None)
with _live_lock:
_live.pop(self.id, None)
self.token = ""
def stop_sandbox(self) -> None:
if self.sandbox is None:
return
try:
from huggingface_hub import Sandbox
Sandbox.kill(self.sandbox.id, token=self.token)
except Exception: # it idles out on its own anyway
pass
self.sandbox = None
def friendly(e: Exception) -> str | None:
"""The Hub errors people actually hit, said so they know what to do."""
status = getattr(getattr(e, "response", None), "status_code", None)
text = str(e)
if status == 402 or "402 Payment Required" in text:
return ("Your Hugging Face account has no prepaid credit for sandboxes, so none could start. Add credit at "
"https://huggingface.co/settings/billing and run again. Nothing was charged.")
if status in (401, 403) and "/api/jobs" in text:
return ("Your sign-in isn't allowed to start sandboxes. Sign in again and allow Jobs, or use a write token.")
if status == 429:
return "Hugging Face is rate-limiting sandbox starts for your account. Wait a minute and run again."
return None
# ββ public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def active_for(user: str) -> int:
with _live_lock:
return sum(1 for r in _live.values() if r.run["user"] == user and not r.cancelled.is_set())
def submit(user: str, token: str, task: dict, model: str, provider: str | None, judge: str | None,
endpoint: dict | None = None, agent_key: str | None = None, params: dict | None = None,
visibility: str = "public", extra: dict | None = None, reserved: bool = False) -> dict:
"""`extra`: fields the explorer adds to the record (its dataset, ref, environment and runner). `reserved`: the
explorer already holds this rollout's place (app/runner._slot, with the admin's limits), so no check of its own."""
if not reserved and active_for(user) >= config.MAX_ACTIVE_PER_USER:
raise RuntimeError(f"You already have {config.MAX_ACTIVE_PER_USER} rollouts running. Wait for one to finish.")
with _live_lock:
if not reserved and len(_live) >= config.MAX_ACTIVE_ROLLOUTS:
raise RuntimeError("The explorer is at capacity right now. Try again in a few minutes.")
run = store.create({
"id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
"user": user, "task_id": task["id"], "domain": task["domain"], "title": task["title"],
"facets": task.get("facets"), "model": model, "provider": provider, "judge": judge,
"status": "queued", "harness": "opencode", "endpoint": endpoint, "params": params or {},
"visibility": "private" if visibility == "private" else "public",
"image": catalog.image_for(task["id"]) if task["domain"] not in ("music", "nemo-gym") else None,
"provenance": version.provenance(),
**(extra or {}),
})
r = Rollout(run, token, agent_key)
r.native_task = task.get("native_task") # reference action stays in memory; never in the run record
with _live_lock:
_live[r.id] = r
_caps[r.cap] = r
_pool.submit(r.execute)
return run
_refusals: dict[tuple, bool] = {}
def _refused(base: str, key: str | None, mid: str, byo: bool, extra: dict, about: str) -> bool:
"""Does the model answer HTTP 400/422, about `about`, to a two-word request carrying `extra`? Cached."""
k = (base, mid, json.dumps(extra, sort_keys=True))
if k not in _refusals:
import httpx
from .. import endpoints
body = {"model": mid, "max_tokens": 16, **extra, "messages": [{"role": "user", "content": "Say OK."}]}
try:
if byo: # the user's endpoint: pinned connection, capped reply
r = endpoints.request("POST", f"{base}/chat/completions", key, timeout=60, json=body)
else:
r = httpx.post(f"{base}/chat/completions", json=body, timeout=60,
headers={"Authorization": f"Bearer {key}"} if key else {})
except Exception:
return False # can't tell; let the rollout itself report a real connection problem
refused = r.status_code in (400, 422) and bool(re.search(about, r.text, re.I))
if r.status_code in (200, 400, 422): # only cache real answers, not rate limits or outages
_refusals[k] = refused
return refused
return _refusals[k]
def by_cap(cap: str) -> Rollout | None:
with _live_lock:
r = _caps.get(cap)
return r if r is not None and not r.cancelled.is_set() else None
def live(run_id: str) -> Rollout | None:
with _live_lock:
return _live.get(run_id)
def events(run_id: str, after: int = 0) -> list[dict]:
r = live(run_id)
if r is not None:
with r._lock:
return r.events[after:]
return store.read_events(run_id, after)
def cancel(run_id: str) -> bool:
r = live(run_id)
if r is None:
return False
r.cancelled.set()
with _live_lock:
_caps.pop(r.cap, None)
# Say so at once: the worker may sit in a blocking sandbox call for a while after the kill.
r.update(status="cancelled", finished_at=time.time(), cost=r.cost_now())
r.phase("done", "error", "stopped by you")
threading.Thread(target=r.stop_sandbox, daemon=True).start()
return True
|