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