Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Security: credential-free sandboxes via a per-rollout model proxy, CSP, local-mode hardening
Browse files- CONTRIBUTING.md +5 -2
- README.md +1 -1
- SECURITY.md +63 -0
- app/auth.py +26 -10
- app/config.py +4 -0
- app/endpoints.py +52 -14
- app/main.py +119 -19
- app/runner/core.py +30 -2
- app/runner/domains.py +8 -6
- app/runner/opencode.py +13 -8
- web/index.html +1 -1
- web/js/boot.js +2 -0
- web/js/explore.js +1 -1
CONTRIBUTING.md
CHANGED
|
@@ -17,8 +17,9 @@ uv run uvicorn app.main:app --port 8000 # http://localhost:8000
|
|
| 17 |
```
|
| 18 |
|
| 19 |
Locally there is no OAuth app: your own Hugging Face token (`HF_TOKEN` or `hf auth login`) is the signed-in user,
|
| 20 |
-
rollouts run on your account, and traces go to `./.local-runs`.
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
## How a rollout works
|
| 24 |
|
|
@@ -103,6 +104,8 @@ which opens each task's image as a sandbox; nothing in them comes from the hidde
|
|
| 103 |
If a grader needs a fix, fix it around the vendored file, not inside it.
|
| 104 |
* **"Not scored" is never 0.** If the testbed, the judge or the render fails, the reward is `None` and the page says
|
| 105 |
why. Averaging infrastructure failures in as zeros misreads results; Xiaomi's harness masks them the same way.
|
|
|
|
|
|
|
| 106 |
* **Nothing secret reaches a trace.** Tokens and endpoint keys are held in memory for the rollout only, and every
|
| 107 |
event passes through the redaction in `Rollout._redact`. Never log a request header or an environment dump.
|
| 108 |
* **Public means anonymous.** The public view of a rollout (`_public_view` in `app/main.py`) has no user, no sandbox
|
|
|
|
| 17 |
```
|
| 18 |
|
| 19 |
Locally there is no OAuth app: your own Hugging Face token (`HF_TOKEN` or `hf auth login`) is the signed-in user,
|
| 20 |
+
rollouts run on your account, and traces go to `./.local-runs`. The same image the Space runs:
|
| 21 |
+
`docker build -t mimo . && docker run -p 127.0.0.1:7860:7860 -e HF_TOKEN -e MIMO_TRUST_NETWORK=1 mimo`.
|
| 22 |
+
Read [SECURITY.md](SECURITY.md) before exposing it beyond your own machine.
|
| 23 |
|
| 24 |
## How a rollout works
|
| 25 |
|
|
|
|
| 104 |
If a grader needs a fix, fix it around the vendored file, not inside it.
|
| 105 |
* **"Not scored" is never 0.** If the testbed, the judge or the render fails, the reward is `None` and the page says
|
| 106 |
why. Averaging infrastructure failures in as zeros misreads results; Xiaomi's harness masks them the same way.
|
| 107 |
+
* **Sandboxes hold no credential on the Space.** Model and judge calls go through `/api/llm/<capability>`; don't
|
| 108 |
+
add code that puts a token or key into a sandbox when `config.PUBLIC_URL` is set.
|
| 109 |
* **Nothing secret reaches a trace.** Tokens and endpoint keys are held in memory for the rollout only, and every
|
| 110 |
event passes through the redaction in `Rollout._redact`. Never log a request header or an environment dump.
|
| 111 |
* **Public means anonymous.** The public view of a rollout (`_public_view` in `app/main.py`) has no user, no sandbox
|
README.md
CHANGED
|
@@ -48,7 +48,7 @@ and Jobs permissions). A rollout runs on **your** account:
|
|
| 48 |
|
| 49 |
Nothing that grades a task (hidden tests, rubric answers, the expected crash) is in the sandbox while the agent works,
|
| 50 |
and the agent has no web tools. Rollouts keep running if you close the page; find them under **Rollouts**.
|
| 51 |
-
Traces are kept in a private bucket. Your token is never stored with them: it lives only in an encrypted cookie in your browser.
|
| 52 |
|
| 53 |
## Rollouts and the community
|
| 54 |
|
|
|
|
| 48 |
|
| 49 |
Nothing that grades a task (hidden tests, rubric answers, the expected crash) is in the sandbox while the agent works,
|
| 50 |
and the agent has no web tools. Rollouts keep running if you close the page; find them under **Rollouts**.
|
| 51 |
+
Traces are kept in a private bucket. Your token is never stored with them: it lives only in an encrypted cookie in your browser, and the sandbox never sees it (model calls go through a per-rollout proxy). See [SECURITY.md](SECURITY.md).
|
| 52 |
|
| 53 |
## Rollouts and the community
|
| 54 |
|
SECURITY.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security
|
| 2 |
+
|
| 3 |
+
How the explorer protects accounts, credentials and data, on the public Space and when you run it yourself.
|
| 4 |
+
Report a vulnerability privately to the FineEnvs team through Hugging Face rather than in a public discussion.
|
| 5 |
+
|
| 6 |
+
## Credentials
|
| 7 |
+
|
| 8 |
+
* **Sessions.** Signing in (OAuth, or a pasted token) produces one cookie, encrypted with Fernet under
|
| 9 |
+
`SESSION_SECRET`, HttpOnly, Secure, valid 7 days. The token inside cannot be read back out of it.
|
| 10 |
+
* **On the Space, sandboxes hold no credential.** A rollout's sandbox reaches its model, and the General verifier
|
| 11 |
+
its judge, through `/api/llm/<capability>/v1`. The capability is random per rollout, works only while that
|
| 12 |
+
rollout is live, and only for its own agent model and judge; this server adds the user's HF token or endpoint key
|
| 13 |
+
on the way out. Whatever the agent runs, it cannot read your token.
|
| 14 |
+
* **Locally, the token is forwarded into the sandbox.** A remote HF Sandbox can't reach your machine, so it calls
|
| 15 |
+
the model directly with your token, and processes in the sandbox can read it. Use a token you can revoke, or set
|
| 16 |
+
`MIMO_PUBLIC_URL` to a public tunnel to this server so the proxy is used instead.
|
| 17 |
+
* **Nothing secret reaches a trace.** Every event is redacted (the exact token, endpoint key and capability, and
|
| 18 |
+
anything shaped like `hf_…`, `sk-…` or `Bearer …`) before it is stored. The General verifier prints the first
|
| 19 |
+
characters of its judge key; that is caught too.
|
| 20 |
+
|
| 21 |
+
## Your own endpoint
|
| 22 |
+
|
| 23 |
+
This server calls a bring-your-own endpoint (to test it, list its models, proxy the agent, and for Music), so:
|
| 24 |
+
the URL must be `https`, every address its name resolves to must be public, and the connection goes to the address
|
| 25 |
+
that was checked (TLS still verified against the name), which closes DNS rebinding. Replies to probes are capped
|
| 26 |
+
at 4 MB, probes at 30 per account per 10 minutes, and redirects are not followed.
|
| 27 |
+
|
| 28 |
+
## Requests
|
| 29 |
+
|
| 30 |
+
* **Cross-site requests.** The cookie is `SameSite=None` so the Space works inside the huggingface.co iframe, so
|
| 31 |
+
every state-changing request must come from this page (checked by `Origin`, or `Sec-Fetch-Site`).
|
| 32 |
+
* **Headers.** A strict Content-Security-Policy on every page (scripts only from this origin and jsDelivr, no
|
| 33 |
+
inline scripts, framing only by huggingface.co and hf.space), `nosniff`, a referrer policy and a permissions
|
| 34 |
+
policy. Task files and rollout artifacts are served with `Content-Security-Policy: sandbox`, so a task's HTML
|
| 35 |
+
can never run with this origin.
|
| 36 |
+
* **Input.** Model ids, URLs and keys are length- and character-limited; task files are confined to their
|
| 37 |
+
workspace; database tables are looked up, never interpolated; run ids and paths are validated.
|
| 38 |
+
|
| 39 |
+
## Rollouts
|
| 40 |
+
|
| 41 |
+
* **Private rollouts** are visible only to their owner. Rollouts from before visibility existed stay private.
|
| 42 |
+
* **Public rollouts** are shown without who ran them: the public view has no user, no sandbox id, no endpoint URL,
|
| 43 |
+
and the owner's username and endpoint host are scrubbed from every string in the trace.
|
| 44 |
+
* Only the owner can stop a rollout or change its visibility. My rollouts lists only your own.
|
| 45 |
+
* Per account at most 4 rollouts run at once, and 24 across the Space.
|
| 46 |
+
|
| 47 |
+
## Running it yourself
|
| 48 |
+
|
| 49 |
+
Without an OAuth app the server is in local mode: your machine's HF token (`HF_TOKEN` or `hf auth login`) is the
|
| 50 |
+
signed-in user. To keep that safe:
|
| 51 |
+
|
| 52 |
+
* It answers only to `localhost`, `127.0.0.1` and `[::1]` (a web page can't DNS-rebind its name onto your
|
| 53 |
+
machine to use your token). Set `MIMO_ALLOWED_HOSTS` to serve under another name.
|
| 54 |
+
* The machine's token signs in only requests from this machine. Anyone else on the network has to sign in with
|
| 55 |
+
their own token. Requests that arrive through a proxy are not treated as local.
|
| 56 |
+
* In Docker, publish to localhost only and say so explicitly:
|
| 57 |
+
`docker run -p 127.0.0.1:7860:7860 -e HF_TOKEN -e MIMO_TRUST_NETWORK=1 mimo`. Without `MIMO_TRUST_NETWORK=1`,
|
| 58 |
+
requests from Docker's network must sign in like anyone else.
|
| 59 |
+
|
| 60 |
+
## Hosting your own Space
|
| 61 |
+
|
| 62 |
+
Set `SESSION_SECRET` as a Space secret (sessions otherwise use the OAuth client secret), keep the runs bucket
|
| 63 |
+
private, and leave `hf_oauth_scopes` at `inference-api` and `jobs`.
|
app/auth.py
CHANGED
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
| 13 |
import base64
|
| 14 |
import hashlib
|
| 15 |
import json
|
|
|
|
| 16 |
import secrets
|
| 17 |
import time
|
| 18 |
from urllib.parse import urlencode
|
|
@@ -21,7 +22,7 @@ import httpx
|
|
| 21 |
from cryptography.fernet import Fernet, InvalidToken
|
| 22 |
from fastapi import APIRouter, HTTPException, Request
|
| 23 |
from fastapi.responses import JSONResponse, RedirectResponse
|
| 24 |
-
from pydantic import BaseModel
|
| 25 |
|
| 26 |
from . import config
|
| 27 |
|
|
@@ -66,8 +67,28 @@ def _cookie_user(request: Request) -> dict | None:
|
|
| 66 |
return data if data.get("exp", 0) > time.time() else None
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def current_user(request: Request) -> dict | None:
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
def require_user(request: Request) -> dict:
|
|
@@ -99,6 +120,8 @@ def login(request: Request):
|
|
| 99 |
for s, t in list(_states.items()):
|
| 100 |
if now - t > 600:
|
| 101 |
_states.pop(s, None)
|
|
|
|
|
|
|
| 102 |
state = secrets.token_urlsafe(24)
|
| 103 |
_states[state] = now
|
| 104 |
q = urlencode({"client_id": config.OAUTH_CLIENT_ID, "redirect_uri": _redirect_uri(request), "response_type": "code",
|
|
@@ -130,7 +153,7 @@ def callback(request: Request, code: str = "", state: str = ""):
|
|
| 130 |
|
| 131 |
# ── access token ─────────────────────────────────────────────────────────────
|
| 132 |
class TokenLogin(BaseModel):
|
| 133 |
-
token: str
|
| 134 |
|
| 135 |
|
| 136 |
def _token_gaps(access: dict) -> list[str]:
|
|
@@ -168,13 +191,6 @@ def token_login(body: TokenLogin):
|
|
| 168 |
"exp": time.time() + config.SESSION_DAYS * 86400, "missing_scopes": gaps})
|
| 169 |
|
| 170 |
|
| 171 |
-
@router.get("/logout")
|
| 172 |
-
def logout():
|
| 173 |
-
resp = RedirectResponse("/")
|
| 174 |
-
resp.delete_cookie(COOKIE, samesite="none", secure=True, path="/")
|
| 175 |
-
return resp
|
| 176 |
-
|
| 177 |
-
|
| 178 |
@router.post("/api/logout")
|
| 179 |
def logout_api():
|
| 180 |
resp = JSONResponse({"ok": True})
|
|
|
|
| 13 |
import base64
|
| 14 |
import hashlib
|
| 15 |
import json
|
| 16 |
+
import os
|
| 17 |
import secrets
|
| 18 |
import time
|
| 19 |
from urllib.parse import urlencode
|
|
|
|
| 22 |
from cryptography.fernet import Fernet, InvalidToken
|
| 23 |
from fastapi import APIRouter, HTTPException, Request
|
| 24 |
from fastapi.responses import JSONResponse, RedirectResponse
|
| 25 |
+
from pydantic import BaseModel, Field
|
| 26 |
|
| 27 |
from . import config
|
| 28 |
|
|
|
|
| 67 |
return data if data.get("exp", 0) > time.time() else None
|
| 68 |
|
| 69 |
|
| 70 |
+
TRUST_NETWORK = os.environ.get("MIMO_TRUST_NETWORK") == "1"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _is_loopback(request: Request) -> bool:
|
| 74 |
+
import ipaddress
|
| 75 |
+
# a browser on this machine talks to us directly; forwarding headers mean a proxy is in between (or someone
|
| 76 |
+
# spoofing one, which uvicorn's --proxy-headers would otherwise believe), so the peer isn't provably local
|
| 77 |
+
if any(h in request.headers for h in ("x-forwarded-for", "x-real-ip", "forwarded")):
|
| 78 |
+
return False
|
| 79 |
+
try:
|
| 80 |
+
return ipaddress.ip_address((request.client.host if request.client else "") or "").is_loopback
|
| 81 |
+
except ValueError:
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
|
| 85 |
def current_user(request: Request) -> dict | None:
|
| 86 |
+
"""Locally, the machine's own token signs in only requests from this machine (a browser on it, or
|
| 87 |
+
`docker run -p 127.0.0.1:...` with MIMO_TRUST_NETWORK=1): anyone else on the network has to sign in."""
|
| 88 |
+
u = _cookie_user(request)
|
| 89 |
+
if u or not config.LOCAL_MODE:
|
| 90 |
+
return u
|
| 91 |
+
return _local_user() if (_is_loopback(request) or TRUST_NETWORK) else None
|
| 92 |
|
| 93 |
|
| 94 |
def require_user(request: Request) -> dict:
|
|
|
|
| 120 |
for s, t in list(_states.items()):
|
| 121 |
if now - t > 600:
|
| 122 |
_states.pop(s, None)
|
| 123 |
+
if len(_states) > 5000: # an unauthenticated route: don't let it grow without bound
|
| 124 |
+
raise HTTPException(429, "Too many sign-ins in progress. Try again in a few minutes.")
|
| 125 |
state = secrets.token_urlsafe(24)
|
| 126 |
_states[state] = now
|
| 127 |
q = urlencode({"client_id": config.OAUTH_CLIENT_ID, "redirect_uri": _redirect_uri(request), "response_type": "code",
|
|
|
|
| 153 |
|
| 154 |
# ── access token ─────────────────────────────────────────────────────────────
|
| 155 |
class TokenLogin(BaseModel):
|
| 156 |
+
token: str = Field(max_length=300)
|
| 157 |
|
| 158 |
|
| 159 |
def _token_gaps(access: dict) -> list[str]:
|
|
|
|
| 191 |
"exp": time.time() + config.SESSION_DAYS * 86400, "missing_scopes": gaps})
|
| 192 |
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
@router.post("/api/logout")
|
| 195 |
def logout_api():
|
| 196 |
resp = JSONResponse({"ok": True})
|
app/config.py
CHANGED
|
@@ -23,6 +23,10 @@ OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
|
|
| 23 |
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 24 |
OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| 25 |
SPACE_HOST = os.environ.get("SPACE_HOST", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
LOCAL_MODE = not OAUTH_CLIENT_ID
|
| 27 |
# `jobs` runs the sandbox on the user's account, `inference-api` calls Inference Providers as them.
|
| 28 |
OAUTH_SCOPES = ["openid", "profile", "inference-api", "jobs"]
|
|
|
|
| 23 |
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 24 |
OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| 25 |
SPACE_HOST = os.environ.get("SPACE_HOST", "")
|
| 26 |
+
# Where a sandbox can reach this server. When set (always on a Space), sandboxes never hold the user's HF token or
|
| 27 |
+
# endpoint key: model and judge calls go through /api/llm/<per-rollout capability>. Locally a remote sandbox can't
|
| 28 |
+
# reach your machine, so the token is forwarded into it instead (set MIMO_PUBLIC_URL to a tunnel to avoid that).
|
| 29 |
+
PUBLIC_URL = (os.environ.get("MIMO_PUBLIC_URL") or (f"https://{SPACE_HOST}" if SPACE_HOST else "")).rstrip("/")
|
| 30 |
LOCAL_MODE = not OAUTH_CLIENT_ID
|
| 31 |
# `jobs` runs the sandbox on the user's account, `inference-api` calls Inference Providers as them.
|
| 32 |
OAUTH_SCOPES = ["openid", "profile", "inference-api", "jobs"]
|
app/endpoints.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
"""Bring your own model: any OpenAI-compatible endpoint (a LiteLLM proxy, vLLM, OpenAI, Together, OpenRouter...).
|
| 2 |
|
| 3 |
The sandbox still runs on the user's Hugging Face account, so they sign in with HF either way; only the agent's
|
| 4 |
-
model calls go to their endpoint. The API key is held in memory for the rollout
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
This server calls the endpoint too (to test it, list its models, and for Music, which has no sandbox), so the
|
| 8 |
URL must be https and resolve to public addresses only: otherwise anyone could make the Space probe its own
|
|
@@ -25,24 +26,61 @@ class EndpointError(ValueError):
|
|
| 25 |
pass
|
| 26 |
|
| 27 |
|
| 28 |
-
def
|
| 29 |
-
u = urlparse((base_url or "").strip())
|
| 30 |
-
if u.scheme != "https" or not u.hostname:
|
| 31 |
-
raise EndpointError("Use an https URL, like https://api.example.com/v1. The sandbox reaches it over the internet.")
|
| 32 |
-
if u.username or u.password:
|
| 33 |
-
raise EndpointError("Put the key in the API key field, not in the URL.")
|
| 34 |
try:
|
| 35 |
-
infos = socket.getaddrinfo(
|
| 36 |
except socket.gaierror:
|
| 37 |
-
raise EndpointError(f"Couldn't resolve {
|
|
|
|
| 38 |
for *_, addr in infos:
|
| 39 |
ip = ipaddress.ip_address(addr[0])
|
| 40 |
if not ip.is_global:
|
| 41 |
-
raise EndpointError(f"{
|
| 42 |
"the internet, because the agent calls it from an HF Sandbox.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
return u.geturl().rstrip("/")
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def _headers(key: str | None) -> dict:
|
| 47 |
return {"Authorization": f"Bearer {key}"} if key else {}
|
| 48 |
|
|
@@ -60,7 +98,7 @@ def _explain(r: httpx.Response) -> str:
|
|
| 60 |
|
| 61 |
def list_models(base_url: str, key: str | None) -> list[str]:
|
| 62 |
base = check_url(base_url)
|
| 63 |
-
r =
|
| 64 |
if r.status_code != 200:
|
| 65 |
raise EndpointError(f"Couldn't list models: {_explain(r)}")
|
| 66 |
data = r.json()
|
|
@@ -78,13 +116,13 @@ def test(base_url: str, key: str | None, model: str) -> dict:
|
|
| 78 |
if not (model or "").strip():
|
| 79 |
raise EndpointError("Enter the model name your endpoint expects.")
|
| 80 |
t = time.time()
|
| 81 |
-
r =
|
| 82 |
"model": model.strip(), "max_tokens": 400, "tools": [PROBE_TOOL], "tool_choice": "auto",
|
| 83 |
"messages": [{"role": "user", "content": "Call record_answer with the answer to 2+2."}]})
|
| 84 |
ms = round((time.time() - t) * 1000)
|
| 85 |
if r.status_code != 200:
|
| 86 |
# some servers reject the tools field outright: tell chat-only apart from broken
|
| 87 |
-
plain =
|
| 88 |
"model": model.strip(), "max_tokens": 50, "messages": [{"role": "user", "content": "Say OK."}]})
|
| 89 |
if plain.status_code == 200:
|
| 90 |
return {"ok": True, "tools": False, "ms": ms, "detail": f"Chat works, but tool calls failed: {_explain(r)}"}
|
|
|
|
| 1 |
"""Bring your own model: any OpenAI-compatible endpoint (a LiteLLM proxy, vLLM, OpenAI, Together, OpenRouter...).
|
| 2 |
|
| 3 |
The sandbox still runs on the user's Hugging Face account, so they sign in with HF either way; only the agent's
|
| 4 |
+
model calls go to their endpoint. The API key is held in memory for the rollout. On the Space it never enters the
|
| 5 |
+
sandbox (calls go through this server's per-rollout proxy); locally it is handed over through a root-only file.
|
| 6 |
+
It is never written to a trace, to the run record or to disk on this server.
|
| 7 |
|
| 8 |
This server calls the endpoint too (to test it, list its models, and for Music, which has no sandbox), so the
|
| 9 |
URL must be https and resolve to public addresses only: otherwise anyone could make the Space probe its own
|
|
|
|
| 26 |
pass
|
| 27 |
|
| 28 |
|
| 29 |
+
def _resolve(host: str, port: int) -> list[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
try:
|
| 31 |
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
| 32 |
except socket.gaierror:
|
| 33 |
+
raise EndpointError(f"Couldn't resolve {host}.")
|
| 34 |
+
ips = []
|
| 35 |
for *_, addr in infos:
|
| 36 |
ip = ipaddress.ip_address(addr[0])
|
| 37 |
if not ip.is_global:
|
| 38 |
+
raise EndpointError(f"{host} resolves to a private address. The endpoint has to be reachable from "
|
| 39 |
"the internet, because the agent calls it from an HF Sandbox.")
|
| 40 |
+
ips.append(addr[0])
|
| 41 |
+
return ips
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def check_url(base_url: str) -> str:
|
| 45 |
+
u = urlparse((base_url or "").strip())
|
| 46 |
+
if u.scheme != "https" or not u.hostname:
|
| 47 |
+
raise EndpointError("Use an https URL, like https://api.example.com/v1. The sandbox reaches it over the internet.")
|
| 48 |
+
if u.username or u.password:
|
| 49 |
+
raise EndpointError("Put the key in the API key field, not in the URL.")
|
| 50 |
+
if len(base_url) > 500:
|
| 51 |
+
raise EndpointError("That URL is too long.")
|
| 52 |
+
_resolve(u.hostname, u.port or 443)
|
| 53 |
return u.geturl().rstrip("/")
|
| 54 |
|
| 55 |
|
| 56 |
+
def pinned(url: str) -> tuple[str, dict, dict]:
|
| 57 |
+
"""(url with the host replaced by an IP we checked, Host header, TLS extensions).
|
| 58 |
+
|
| 59 |
+
Checking a hostname and then letting the HTTP client resolve it again leaves a gap: a DNS answer that
|
| 60 |
+
changes in between (DNS rebinding) could point the second lookup at this server's own network. Connecting
|
| 61 |
+
to the address that was checked closes it; TLS is still verified against the real hostname (SNI)."""
|
| 62 |
+
u = urlparse(url)
|
| 63 |
+
ip = _resolve(u.hostname, u.port or 443)[0]
|
| 64 |
+
host_ip = f"[{ip}]" if ":" in ip else ip
|
| 65 |
+
netloc = host_ip + (f":{u.port}" if u.port else "")
|
| 66 |
+
return u._replace(netloc=netloc).geturl(), {"Host": u.netloc}, {"sni_hostname": u.hostname}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
MAX_BODY = 4_000_000 # a probe's reply is small; don't let an endpoint stream gigabytes into this server
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def request(method: str, url: str, key: str | None, **kw) -> httpx.Response:
|
| 73 |
+
target, host, ext = pinned(url)
|
| 74 |
+
with httpx.Client(timeout=kw.pop("timeout", TIMEOUT), follow_redirects=False) as c:
|
| 75 |
+
with c.stream(method, target, headers={**_headers(key), **host}, extensions=ext, **kw) as r:
|
| 76 |
+
body = b""
|
| 77 |
+
for chunk in r.iter_bytes():
|
| 78 |
+
body += chunk
|
| 79 |
+
if len(body) > MAX_BODY:
|
| 80 |
+
raise EndpointError("The endpoint's reply was too large.")
|
| 81 |
+
return httpx.Response(r.status_code, headers=r.headers, content=body, request=r.request)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
def _headers(key: str | None) -> dict:
|
| 85 |
return {"Authorization": f"Bearer {key}"} if key else {}
|
| 86 |
|
|
|
|
| 98 |
|
| 99 |
def list_models(base_url: str, key: str | None) -> list[str]:
|
| 100 |
base = check_url(base_url)
|
| 101 |
+
r = request("GET", f"{base}/models", key)
|
| 102 |
if r.status_code != 200:
|
| 103 |
raise EndpointError(f"Couldn't list models: {_explain(r)}")
|
| 104 |
data = r.json()
|
|
|
|
| 116 |
if not (model or "").strip():
|
| 117 |
raise EndpointError("Enter the model name your endpoint expects.")
|
| 118 |
t = time.time()
|
| 119 |
+
r = request("POST", f"{base}/chat/completions", key, timeout=60, json={
|
| 120 |
"model": model.strip(), "max_tokens": 400, "tools": [PROBE_TOOL], "tool_choice": "auto",
|
| 121 |
"messages": [{"role": "user", "content": "Call record_answer with the answer to 2+2."}]})
|
| 122 |
ms = round((time.time() - t) * 1000)
|
| 123 |
if r.status_code != 200:
|
| 124 |
# some servers reject the tools field outright: tell chat-only apart from broken
|
| 125 |
+
plain = request("POST", f"{base}/chat/completions", key, timeout=60, json={
|
| 126 |
"model": model.strip(), "max_tokens": 50, "messages": [{"role": "user", "content": "Say OK."}]})
|
| 127 |
if plain.status_code == 200:
|
| 128 |
return {"ok": True, "tools": False, "ms": ms, "detail": f"Chat works, but tool calls failed: {_explain(r)}"}
|
app/main.py
CHANGED
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
| 7 |
|
| 8 |
import json
|
| 9 |
import mimetypes
|
|
|
|
| 10 |
import random
|
| 11 |
from functools import lru_cache
|
| 12 |
|
|
@@ -34,6 +35,30 @@ async def _revalidate(request: Request, call_next):
|
|
| 34 |
return resp
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
@app.middleware("http")
|
| 38 |
async def _same_origin_posts(request: Request, call_next):
|
| 39 |
"""The session cookie is SameSite=None (it has to work in the huggingface.co iframe), so a state-changing
|
|
@@ -161,10 +186,13 @@ def list_models():
|
|
| 161 |
|
| 162 |
|
| 163 |
# ── rollouts ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
| 164 |
class Endpoint(BaseModel):
|
| 165 |
-
base_url: str
|
| 166 |
-
model: str
|
| 167 |
-
api_key: str | None = None
|
| 168 |
price_in: float | None = Field(None, ge=0, le=1000) # $ per 1M tokens, only for the cost shown
|
| 169 |
price_out: float | None = Field(None, ge=0, le=1000)
|
| 170 |
|
|
@@ -178,24 +206,37 @@ class Params(BaseModel):
|
|
| 178 |
|
| 179 |
|
| 180 |
class RunRequest(BaseModel):
|
| 181 |
-
task_id: str
|
| 182 |
-
model: str | None = None
|
| 183 |
-
provider: str | None = None
|
| 184 |
-
judge: str | None = None
|
| 185 |
endpoint: Endpoint | None = None
|
| 186 |
params: Params = Params()
|
| 187 |
visibility: Literal["public", "private"] = "public"
|
| 188 |
|
| 189 |
|
| 190 |
class EndpointProbe(BaseModel):
|
| 191 |
-
base_url: str
|
| 192 |
-
api_key: str | None = None
|
| 193 |
-
model: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
|
| 196 |
@app.post("/api/endpoints/models")
|
| 197 |
def endpoint_models(body: EndpointProbe, request: Request):
|
| 198 |
-
auth.require_user(request) # this server makes the call, so only for signed-in users
|
| 199 |
try:
|
| 200 |
return {"models": endpoints.list_models(body.base_url, body.api_key)}
|
| 201 |
except endpoints.EndpointError as e:
|
|
@@ -206,7 +247,7 @@ def endpoint_models(body: EndpointProbe, request: Request):
|
|
| 206 |
|
| 207 |
@app.post("/api/endpoints/test")
|
| 208 |
def endpoint_test(body: EndpointProbe, request: Request):
|
| 209 |
-
auth.require_user(request)
|
| 210 |
try:
|
| 211 |
return endpoints.test(body.base_url, body.api_key, body.model or "")
|
| 212 |
except endpoints.EndpointError as e:
|
|
@@ -273,16 +314,26 @@ def _is_public(run: dict) -> bool:
|
|
| 273 |
return run.get("visibility") == "public"
|
| 274 |
|
| 275 |
|
| 276 |
-
def _scrub(value,
|
|
|
|
|
|
|
| 277 |
if isinstance(value, str):
|
| 278 |
-
|
|
|
|
|
|
|
| 279 |
if isinstance(value, dict):
|
| 280 |
-
return {k: _scrub(v,
|
| 281 |
if isinstance(value, list):
|
| 282 |
-
return [_scrub(v,
|
| 283 |
return value
|
| 284 |
|
| 285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
def _public_view(run: dict) -> dict:
|
| 287 |
out = {k: run.get(k) for k in PUBLIC_FIELDS if k in run}
|
| 288 |
ep = run.get("endpoint")
|
|
@@ -291,7 +342,7 @@ def _public_view(run: dict) -> dict:
|
|
| 291 |
if run.get("error"):
|
| 292 |
out["error"] = run["error"]
|
| 293 |
out["visibility"] = "public"
|
| 294 |
-
return _scrub(out,
|
| 295 |
|
| 296 |
|
| 297 |
def _owner_view(run: dict) -> dict:
|
|
@@ -329,7 +380,7 @@ def get_run(run_id: str, request: Request, after: int = 0):
|
|
| 329 |
events = core.events(run_id, after)
|
| 330 |
if owner:
|
| 331 |
return {"run": _owner_view(run), "events": events, "live": live is not None}
|
| 332 |
-
return {"run": _public_view(run), "events": _scrub(events,
|
| 333 |
|
| 334 |
|
| 335 |
class Visibility(BaseModel):
|
|
@@ -447,7 +498,56 @@ def artifact(run_id: str, name: str, request: Request):
|
|
| 447 |
data = store.read_artifact(run_id, name)
|
| 448 |
if data is None:
|
| 449 |
raise HTTPException(404, "no such artifact")
|
| 450 |
-
return Response(data, media_type=mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
|
| 453 |
# ── the site ────────────────────────────────────────────────────────���────────
|
|
|
|
| 7 |
|
| 8 |
import json
|
| 9 |
import mimetypes
|
| 10 |
+
import os
|
| 11 |
import random
|
| 12 |
from functools import lru_cache
|
| 13 |
|
|
|
|
| 35 |
return resp
|
| 36 |
|
| 37 |
|
| 38 |
+
LOCAL_HOSTS = {"localhost", "127.0.0.1", "[::1]", "::1"} | {h.strip() for h in os.environ.get("MIMO_ALLOWED_HOSTS", "").split(",") if h.strip()}
|
| 39 |
+
CSP = ("default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
| 40 |
+
"font-src https://fonts.gstatic.com; img-src 'self' data: blob: https:; connect-src 'self'; frame-src 'self'; object-src 'none'; "
|
| 41 |
+
"base-uri 'none'; form-action 'self'; frame-ancestors 'self' https://huggingface.co https://*.hf.space")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@app.middleware("http")
|
| 45 |
+
async def _security(request: Request, call_next):
|
| 46 |
+
"""Locally, only answer to localhost: a web page could otherwise DNS-rebind its own name to 127.0.0.1 and drive
|
| 47 |
+
this server (and the machine's HF token) from the browser. Everywhere: the usual security headers."""
|
| 48 |
+
if config.LOCAL_MODE:
|
| 49 |
+
host = (request.headers.get("host") or "").rsplit(":", 1)[0] if not (request.headers.get("host") or "").startswith("[") \
|
| 50 |
+
else (request.headers.get("host") or "").split("]")[0] + "]"
|
| 51 |
+
if host not in LOCAL_HOSTS:
|
| 52 |
+
return JSONResponse({"detail": "host not allowed (set MIMO_ALLOWED_HOSTS to serve under another name)"}, 403)
|
| 53 |
+
resp = await call_next(request)
|
| 54 |
+
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
| 55 |
+
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
| 56 |
+
resp.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
| 57 |
+
if not request.url.path.startswith("/api/"):
|
| 58 |
+
resp.headers.setdefault("Content-Security-Policy", CSP)
|
| 59 |
+
return resp
|
| 60 |
+
|
| 61 |
+
|
| 62 |
@app.middleware("http")
|
| 63 |
async def _same_origin_posts(request: Request, call_next):
|
| 64 |
"""The session cookie is SameSite=None (it has to work in the huggingface.co iframe), so a state-changing
|
|
|
|
| 186 |
|
| 187 |
|
| 188 |
# ── rollouts ─────────────────────────────────────────────────────────────────
|
| 189 |
+
SAFE_NAME = r"^[A-Za-z0-9._:/@+\-]{1,200}$" # model ids: letters, digits and the punctuation real ids use
|
| 190 |
+
|
| 191 |
+
|
| 192 |
class Endpoint(BaseModel):
|
| 193 |
+
base_url: str = Field(max_length=500)
|
| 194 |
+
model: str = Field(pattern=SAFE_NAME)
|
| 195 |
+
api_key: str | None = Field(None, max_length=500)
|
| 196 |
price_in: float | None = Field(None, ge=0, le=1000) # $ per 1M tokens, only for the cost shown
|
| 197 |
price_out: float | None = Field(None, ge=0, le=1000)
|
| 198 |
|
|
|
|
| 206 |
|
| 207 |
|
| 208 |
class RunRequest(BaseModel):
|
| 209 |
+
task_id: str = Field(max_length=200)
|
| 210 |
+
model: str | None = Field(None, pattern=SAFE_NAME)
|
| 211 |
+
provider: str | None = Field(None, pattern=SAFE_NAME)
|
| 212 |
+
judge: str | None = Field(None, pattern=SAFE_NAME)
|
| 213 |
endpoint: Endpoint | None = None
|
| 214 |
params: Params = Params()
|
| 215 |
visibility: Literal["public", "private"] = "public"
|
| 216 |
|
| 217 |
|
| 218 |
class EndpointProbe(BaseModel):
|
| 219 |
+
base_url: str = Field(max_length=500)
|
| 220 |
+
api_key: str | None = Field(None, max_length=500)
|
| 221 |
+
model: str | None = Field(None, pattern=SAFE_NAME)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
_probes: dict[str, list[float]] = {}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _probe_limit(user: str) -> None:
|
| 228 |
+
"""This server makes the call, so cap how often one account can have it do so."""
|
| 229 |
+
import time as _t
|
| 230 |
+
now = _t.time()
|
| 231 |
+
hits = [t for t in _probes.get(user, []) if now - t < 600]
|
| 232 |
+
if len(hits) >= 30:
|
| 233 |
+
raise HTTPException(429, "Too many endpoint checks. Wait a few minutes.")
|
| 234 |
+
_probes[user] = hits + [now]
|
| 235 |
|
| 236 |
|
| 237 |
@app.post("/api/endpoints/models")
|
| 238 |
def endpoint_models(body: EndpointProbe, request: Request):
|
| 239 |
+
_probe_limit(auth.require_user(request)["name"]) # this server makes the call, so only for signed-in users
|
| 240 |
try:
|
| 241 |
return {"models": endpoints.list_models(body.base_url, body.api_key)}
|
| 242 |
except endpoints.EndpointError as e:
|
|
|
|
| 247 |
|
| 248 |
@app.post("/api/endpoints/test")
|
| 249 |
def endpoint_test(body: EndpointProbe, request: Request):
|
| 250 |
+
_probe_limit(auth.require_user(request)["name"])
|
| 251 |
try:
|
| 252 |
return endpoints.test(body.base_url, body.api_key, body.model or "")
|
| 253 |
except endpoints.EndpointError as e:
|
|
|
|
| 314 |
return run.get("visibility") == "public"
|
| 315 |
|
| 316 |
|
| 317 |
+
def _scrub(value, secrets_: list[str] | str):
|
| 318 |
+
if isinstance(secrets_, str):
|
| 319 |
+
secrets_ = [secrets_] if secrets_ and len(secrets_) > 2 else []
|
| 320 |
if isinstance(value, str):
|
| 321 |
+
for x in secrets_:
|
| 322 |
+
value = value.replace(x, "[redacted]" if "." in x or "/" in x else "[user]")
|
| 323 |
+
return value
|
| 324 |
if isinstance(value, dict):
|
| 325 |
+
return {k: _scrub(v, secrets_) for k, v in value.items()}
|
| 326 |
if isinstance(value, list):
|
| 327 |
+
return [_scrub(v, secrets_) for v in value]
|
| 328 |
return value
|
| 329 |
|
| 330 |
|
| 331 |
+
def _private_strings(run: dict) -> list[str]:
|
| 332 |
+
"""What must never show publicly: the owner's name, and where their own endpoint lives."""
|
| 333 |
+
ep = run.get("endpoint") or {}
|
| 334 |
+
return [x for x in (run.get("user"), ep.get("base_url"), ep.get("host")) if x and len(x) > 2]
|
| 335 |
+
|
| 336 |
+
|
| 337 |
def _public_view(run: dict) -> dict:
|
| 338 |
out = {k: run.get(k) for k in PUBLIC_FIELDS if k in run}
|
| 339 |
ep = run.get("endpoint")
|
|
|
|
| 342 |
if run.get("error"):
|
| 343 |
out["error"] = run["error"]
|
| 344 |
out["visibility"] = "public"
|
| 345 |
+
return _scrub(out, _private_strings(run))
|
| 346 |
|
| 347 |
|
| 348 |
def _owner_view(run: dict) -> dict:
|
|
|
|
| 380 |
events = core.events(run_id, after)
|
| 381 |
if owner:
|
| 382 |
return {"run": _owner_view(run), "events": events, "live": live is not None}
|
| 383 |
+
return {"run": _public_view(run), "events": _scrub(events, _private_strings(run)), "live": live is not None}
|
| 384 |
|
| 385 |
|
| 386 |
class Visibility(BaseModel):
|
|
|
|
| 498 |
data = store.read_artifact(run_id, name)
|
| 499 |
if data is None:
|
| 500 |
raise HTTPException(404, "no such artifact")
|
| 501 |
+
return Response(data, media_type=mimetypes.guess_type(name)[0] or "application/octet-stream",
|
| 502 |
+
headers={"Content-Security-Policy": "sandbox", "X-Content-Type-Options": "nosniff"})
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
# ── the model proxy ──────────────────────────────────────────────────────────
|
| 506 |
+
# On the Space, a sandbox holds no credential: OpenCode (and the General verifier's judge) call this route with a
|
| 507 |
+
# per-rollout capability in the path. It works only while that rollout is live, only for the rollout's own agent
|
| 508 |
+
# model and judge, and this server adds the user's token or endpoint key on the way out.
|
| 509 |
+
@app.post("/api/llm/{cap}/v1/chat/completions")
|
| 510 |
+
async def llm_proxy(cap: str, request: Request):
|
| 511 |
+
import asyncio
|
| 512 |
+
|
| 513 |
+
from starlette.background import BackgroundTask
|
| 514 |
+
from starlette.responses import StreamingResponse
|
| 515 |
+
|
| 516 |
+
r = core.by_cap(cap)
|
| 517 |
+
if r is None:
|
| 518 |
+
return JSONResponse({"error": {"message": "unknown or finished rollout"}}, 404)
|
| 519 |
+
raw = await request.body()
|
| 520 |
+
if len(raw) > 20_000_000:
|
| 521 |
+
return JSONResponse({"error": {"message": "request too large"}}, 413)
|
| 522 |
+
try:
|
| 523 |
+
body = json.loads(raw)
|
| 524 |
+
except ValueError:
|
| 525 |
+
return JSONResponse({"error": {"message": "invalid JSON"}}, 400)
|
| 526 |
+
up = r.upstream(str(body.get("model") or ""))
|
| 527 |
+
if up is None:
|
| 528 |
+
return JSONResponse({"error": {"message": "this rollout may not call that model"}}, 403)
|
| 529 |
+
base, key = up
|
| 530 |
+
url = f"{base}/chat/completions"
|
| 531 |
+
headers = {"Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {})}
|
| 532 |
+
ext = {}
|
| 533 |
+
if r.run.get("endpoint") and base != config.ROUTER: # the user's own endpoint: connect to the address we checked
|
| 534 |
+
try:
|
| 535 |
+
url, host, ext = await asyncio.to_thread(endpoints.pinned, url)
|
| 536 |
+
except endpoints.EndpointError as e:
|
| 537 |
+
return JSONResponse({"error": {"message": str(e)}}, 502)
|
| 538 |
+
headers.update(host)
|
| 539 |
+
client = httpx.AsyncClient(timeout=httpx.Timeout(900, connect=30), follow_redirects=False)
|
| 540 |
+
try:
|
| 541 |
+
resp = await client.send(client.build_request("POST", url, content=raw, headers=headers, extensions=ext), stream=True)
|
| 542 |
+
except httpx.HTTPError as e:
|
| 543 |
+
await client.aclose()
|
| 544 |
+
return JSONResponse({"error": {"message": f"upstream unreachable: {type(e).__name__}"}}, 502)
|
| 545 |
+
|
| 546 |
+
async def close():
|
| 547 |
+
await resp.aclose()
|
| 548 |
+
await client.aclose()
|
| 549 |
+
return StreamingResponse(resp.aiter_bytes(), status_code=resp.status_code,
|
| 550 |
+
media_type=resp.headers.get("content-type", "application/json"), background=BackgroundTask(close))
|
| 551 |
|
| 552 |
|
| 553 |
# ── the site ────────────────────────────────────────────────────────���────────
|
app/runner/core.py
CHANGED
|
@@ -17,6 +17,7 @@ The user's token is held here for the rollout's lifetime only. It never reaches
|
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
import re
|
|
|
|
| 20 |
import threading
|
| 21 |
import time
|
| 22 |
import traceback
|
|
@@ -27,6 +28,7 @@ from .. import catalog, config, models, store, version
|
|
| 27 |
|
| 28 |
_pool = ThreadPoolExecutor(max_workers=config.MAX_ACTIVE_ROLLOUTS, thread_name_prefix="rollout")
|
| 29 |
_live: dict[str, "Rollout"] = {}
|
|
|
|
| 30 |
_live_lock = threading.Lock()
|
| 31 |
|
| 32 |
|
|
@@ -47,6 +49,8 @@ class Rollout:
|
|
| 47 |
self.id = run["id"]
|
| 48 |
self.token = token
|
| 49 |
self.agent_key = agent_key # a bring-your-own endpoint's key: memory only, like the HF token
|
|
|
|
|
|
|
| 50 |
self.events: list[dict] = store.read_events(self.id)
|
| 51 |
self._pending: list[dict] = []
|
| 52 |
self._last_flush = time.time()
|
|
@@ -62,7 +66,7 @@ class Rollout:
|
|
| 62 |
"""Nothing secret reaches a trace: the user's HF token, an endpoint key, or anything shaped like one
|
| 63 |
(the General verifier, for one, prints the first characters of its judge key)."""
|
| 64 |
if isinstance(v, str):
|
| 65 |
-
for secret in (self.token, self.agent_key):
|
| 66 |
if secret:
|
| 67 |
v = v.replace(secret, "[redacted]")
|
| 68 |
return SECRET_RE.sub(lambda m: m.group(1) + "[redacted]", v)
|
|
@@ -131,7 +135,7 @@ class Rollout:
|
|
| 131 |
self.update(flavor=flavor)
|
| 132 |
t = time.time()
|
| 133 |
self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
|
| 134 |
-
forward_hf_token=
|
| 135 |
labels={"app": "mimo-explorer", "run": self.id})
|
| 136 |
self.update(sandbox_id=self.sandbox.id)
|
| 137 |
self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
|
|
@@ -145,6 +149,19 @@ class Rollout:
|
|
| 145 |
mid = f"{self.run['model']}:{self.run['provider']}" if self.run.get("provider") else self.run["model"]
|
| 146 |
return config.ROUTER, self.token, mid
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
def sh(self, cmd: str, timeout: float = 600, **kw):
|
| 149 |
"""Run a shell command in the sandbox; never raises on a non-zero exit."""
|
| 150 |
self.check_cancel()
|
|
@@ -176,6 +193,8 @@ class Rollout:
|
|
| 176 |
self._flush()
|
| 177 |
self.stop_sandbox()
|
| 178 |
self.agent_key = None
|
|
|
|
|
|
|
| 179 |
with _live_lock:
|
| 180 |
_live.pop(self.id, None)
|
| 181 |
|
|
@@ -231,10 +250,17 @@ def submit(user: str, token: str, task: dict, model: str, provider: str | None,
|
|
| 231 |
r = Rollout(run, token, agent_key)
|
| 232 |
with _live_lock:
|
| 233 |
_live[r.id] = r
|
|
|
|
| 234 |
_pool.submit(r.execute)
|
| 235 |
return run
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
def live(run_id: str) -> Rollout | None:
|
| 239 |
with _live_lock:
|
| 240 |
return _live.get(run_id)
|
|
@@ -253,6 +279,8 @@ def cancel(run_id: str) -> bool:
|
|
| 253 |
if r is None:
|
| 254 |
return False
|
| 255 |
r.cancelled.set()
|
|
|
|
|
|
|
| 256 |
# Say so at once: the worker may sit in a blocking sandbox call for a while after the kill.
|
| 257 |
r.update(status="cancelled", finished_at=time.time(), cost=r.cost_now())
|
| 258 |
r.phase("done", "error", "stopped by you")
|
|
|
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
import re
|
| 20 |
+
import secrets
|
| 21 |
import threading
|
| 22 |
import time
|
| 23 |
import traceback
|
|
|
|
| 28 |
|
| 29 |
_pool = ThreadPoolExecutor(max_workers=config.MAX_ACTIVE_ROLLOUTS, thread_name_prefix="rollout")
|
| 30 |
_live: dict[str, "Rollout"] = {}
|
| 31 |
+
_caps: dict[str, "Rollout"] = {} # model-proxy capability -> its rollout, while it runs
|
| 32 |
_live_lock = threading.Lock()
|
| 33 |
|
| 34 |
|
|
|
|
| 49 |
self.id = run["id"]
|
| 50 |
self.token = token
|
| 51 |
self.agent_key = agent_key # a bring-your-own endpoint's key: memory only, like the HF token
|
| 52 |
+
# what the sandbox gets instead of credentials (see config.PUBLIC_URL): valid while this rollout is live
|
| 53 |
+
self.cap = secrets.token_urlsafe(32)
|
| 54 |
self.events: list[dict] = store.read_events(self.id)
|
| 55 |
self._pending: list[dict] = []
|
| 56 |
self._last_flush = time.time()
|
|
|
|
| 66 |
"""Nothing secret reaches a trace: the user's HF token, an endpoint key, or anything shaped like one
|
| 67 |
(the General verifier, for one, prints the first characters of its judge key)."""
|
| 68 |
if isinstance(v, str):
|
| 69 |
+
for secret in (self.token, self.agent_key, getattr(self, "cap", None)):
|
| 70 |
if secret:
|
| 71 |
v = v.replace(secret, "[redacted]")
|
| 72 |
return SECRET_RE.sub(lambda m: m.group(1) + "[redacted]", v)
|
|
|
|
| 135 |
self.update(flavor=flavor)
|
| 136 |
t = time.time()
|
| 137 |
self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
|
| 138 |
+
forward_hf_token=not config.PUBLIC_URL, token=self.token, start_timeout=900,
|
| 139 |
labels={"app": "mimo-explorer", "run": self.id})
|
| 140 |
self.update(sandbox_id=self.sandbox.id)
|
| 141 |
self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
|
|
|
|
| 149 |
mid = f"{self.run['model']}:{self.run['provider']}" if self.run.get("provider") else self.run["model"]
|
| 150 |
return config.ROUTER, self.token, mid
|
| 151 |
|
| 152 |
+
def llm_base(self) -> str | None:
|
| 153 |
+
"""The OpenAI-compatible base URL the sandbox should use, or None to call the provider directly."""
|
| 154 |
+
return f"{config.PUBLIC_URL}/api/llm/{self.cap}/v1" if config.PUBLIC_URL else None
|
| 155 |
+
|
| 156 |
+
def upstream(self, model: str) -> tuple[str, str | None] | None:
|
| 157 |
+
"""For the model proxy: (base URL, key) for a model this rollout may call, else None."""
|
| 158 |
+
base, key, mid = self.agent_api()
|
| 159 |
+
if model == mid:
|
| 160 |
+
return base, key
|
| 161 |
+
if self.run.get("judge") and model == self.run["judge"]:
|
| 162 |
+
return config.ROUTER, self.token
|
| 163 |
+
return None
|
| 164 |
+
|
| 165 |
def sh(self, cmd: str, timeout: float = 600, **kw):
|
| 166 |
"""Run a shell command in the sandbox; never raises on a non-zero exit."""
|
| 167 |
self.check_cancel()
|
|
|
|
| 193 |
self._flush()
|
| 194 |
self.stop_sandbox()
|
| 195 |
self.agent_key = None
|
| 196 |
+
with _live_lock:
|
| 197 |
+
_caps.pop(self.cap, None)
|
| 198 |
with _live_lock:
|
| 199 |
_live.pop(self.id, None)
|
| 200 |
|
|
|
|
| 250 |
r = Rollout(run, token, agent_key)
|
| 251 |
with _live_lock:
|
| 252 |
_live[r.id] = r
|
| 253 |
+
_caps[r.cap] = r
|
| 254 |
_pool.submit(r.execute)
|
| 255 |
return run
|
| 256 |
|
| 257 |
|
| 258 |
+
def by_cap(cap: str) -> Rollout | None:
|
| 259 |
+
with _live_lock:
|
| 260 |
+
r = _caps.get(cap)
|
| 261 |
+
return r if r is not None and not r.cancelled.is_set() else None
|
| 262 |
+
|
| 263 |
+
|
| 264 |
def live(run_id: str) -> Rollout | None:
|
| 265 |
with _live_lock:
|
| 266 |
return _live.get(run_id)
|
|
|
|
| 279 |
if r is None:
|
| 280 |
return False
|
| 281 |
r.cancelled.set()
|
| 282 |
+
with _live_lock:
|
| 283 |
+
_caps.pop(r.cap, None)
|
| 284 |
# Say so at once: the worker may sit in a blocking sandbox call for a while after the kill.
|
| 285 |
r.update(status="cancelled", finished_at=time.time(), cost=r.cost_now())
|
| 286 |
r.phase("done", "error", "stopped by you")
|
app/runner/domains.py
CHANGED
|
@@ -297,8 +297,9 @@ class General:
|
|
| 297 |
r.sh(f"chown agent:agent {shlex.quote(man['cwd'])}/answer.md")
|
| 298 |
self._push(r, root, [(u["source"], u["target"]) for u in man["verifier"]["uploads"]], "verifier")
|
| 299 |
judge = r.run.get("judge") or "thinkingmachines/Inkling"
|
| 300 |
-
|
| 301 |
-
|
|
|
|
| 302 |
exports = " ".join(f'{k}="{v}"' for k, v in env.items())
|
| 303 |
rf = man["verifier"].get("reward_file") or "/logs/verifier/reward.json"
|
| 304 |
rd = man["verifier"].get("reward_detail_file") or "/logs/verifier/reward_detail.json"
|
|
@@ -529,9 +530,10 @@ class Music:
|
|
| 529 |
params = r.run.get("params") or {}
|
| 530 |
if r.run.get("scripted") is not None: # validation: score a fixed piece, no model call
|
| 531 |
return self._score(r, raw, r.run.get("scripted_final", ""), "", {}, params)
|
| 532 |
-
|
|
|
|
| 533 |
from .. import endpoints
|
| 534 |
-
|
| 535 |
text, thinking, usage = "", "", {}
|
| 536 |
# verl recipes/design/config/music.yaml: response_length 100000. Providers that cap output lower refuse
|
| 537 |
# such a request outright, so step down rather than fail the rollout on a limit.
|
|
@@ -541,8 +543,8 @@ class Music:
|
|
| 541 |
**({"temperature": params["temperature"]} if params.get("temperature") is not None else {}),
|
| 542 |
**({"reasoning_effort": params["thinking"]} if params.get("thinking") not in (None, "default") else {}),
|
| 543 |
"messages": [{"role": "user", "content": raw["prompt"]}]}
|
| 544 |
-
with httpx.stream("POST",
|
| 545 |
-
headers={"Authorization": f"Bearer {key}"} if key else {}) as resp:
|
| 546 |
if resp.status_code != 200:
|
| 547 |
err = resp.read()[:400].decode(errors="replace")
|
| 548 |
if resp.status_code in (400, 422) and n + 1 < len(budgets):
|
|
|
|
| 297 |
r.sh(f"chown agent:agent {shlex.quote(man['cwd'])}/answer.md")
|
| 298 |
self._push(r, root, [(u["source"], u["target"]) for u in man["verifier"]["uploads"]], "verifier")
|
| 299 |
judge = r.run.get("judge") or "thinkingmachines/Inkling"
|
| 300 |
+
proxy = r.llm_base() # on the Space the judge goes through the rollout's proxy: no token in the sandbox
|
| 301 |
+
env = {"GA_JUDGE_KEY": "proxied" if proxy else "$HF_TOKEN", "GA_JUDGE_MODEL": judge, "GA_JUDGE_API": "chat",
|
| 302 |
+
"GA_JUDGE_URL": proxy or config.ROUTER, "VERIFY_DETERMINISTIC": "1", "VERIFY_AGENT_JUDGE": "1"}
|
| 303 |
exports = " ".join(f'{k}="{v}"' for k, v in env.items())
|
| 304 |
rf = man["verifier"].get("reward_file") or "/logs/verifier/reward.json"
|
| 305 |
rd = man["verifier"].get("reward_detail_file") or "/logs/verifier/reward_detail.json"
|
|
|
|
| 530 |
params = r.run.get("params") or {}
|
| 531 |
if r.run.get("scripted") is not None: # validation: score a fixed piece, no model call
|
| 532 |
return self._score(r, raw, r.run.get("scripted_final", ""), "", {}, params)
|
| 533 |
+
url, host_hdr, ext = f"{base}/chat/completions", {}, {}
|
| 534 |
+
if r.run.get("endpoint"): # the user's own endpoint: connect to the address that was checked
|
| 535 |
from .. import endpoints
|
| 536 |
+
url, host_hdr, ext = endpoints.pinned(f"{endpoints.check_url(base)}/chat/completions")
|
| 537 |
text, thinking, usage = "", "", {}
|
| 538 |
# verl recipes/design/config/music.yaml: response_length 100000. Providers that cap output lower refuse
|
| 539 |
# such a request outright, so step down rather than fail the rollout on a limit.
|
|
|
|
| 543 |
**({"temperature": params["temperature"]} if params.get("temperature") is not None else {}),
|
| 544 |
**({"reasoning_effort": params["thinking"]} if params.get("thinking") not in (None, "default") else {}),
|
| 545 |
"messages": [{"role": "user", "content": raw["prompt"]}]}
|
| 546 |
+
with httpx.stream("POST", url, timeout=900, json=body, extensions=ext,
|
| 547 |
+
headers={**host_hdr, **({"Authorization": f"Bearer {key}"} if key else {})}) as resp:
|
| 548 |
if resp.status_code != 200:
|
| 549 |
err = resp.read()[:400].decode(errors="replace")
|
| 550 |
if resp.status_code in (400, 422) and n + 1 < len(budgets):
|
app/runner/opencode.py
CHANGED
|
@@ -63,17 +63,20 @@ def install(r) -> str:
|
|
| 63 |
|
| 64 |
|
| 65 |
def config_for(model: str, provider: str | None, steps: int, mcp: list[dict] | None = None,
|
| 66 |
-
endpoint: dict | None = None, params: dict | None = None) -> dict:
|
|
|
|
|
|
|
| 67 |
params = params or {}
|
| 68 |
-
if endpoint: # your own OpenAI-compatible endpoint; the key arrives as AGENT_API_KEY
|
| 69 |
mid, prov = model, {"byo": {"npm": "@ai-sdk/openai-compatible", "name": "Your endpoint",
|
| 70 |
-
"options": {"baseURL": endpoint["base_url"],
|
|
|
|
| 71 |
"models": {model: {"name": model, "tool_call": True}}}}
|
| 72 |
ref = f"byo/{model}"
|
| 73 |
else:
|
| 74 |
mid = f"{model}:{provider}" if provider else model
|
| 75 |
prov = {"hf": {"npm": "@ai-sdk/openai-compatible", "name": "Hugging Face Inference Providers",
|
| 76 |
-
"options": {"baseURL": config.ROUTER, "apiKey": "{env:HF_TOKEN}"},
|
| 77 |
"models": {mid: {"name": model, "tool_call": True}}}}
|
| 78 |
ref = f"hf/{mid}"
|
| 79 |
entry = next(iter(next(iter(prov.values()))["models"].values()))
|
|
@@ -104,12 +107,13 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
|
|
| 104 |
ep, params = r.run.get("endpoint"), r.run.get("params") or {}
|
| 105 |
steps = params.get("steps") or steps
|
| 106 |
timeout = params["timeout_min"] * 60 if params.get("timeout_min") else timeout
|
| 107 |
-
|
|
|
|
| 108 |
r.sandbox.files.write("/tmp/opencode.json", json.dumps(cfg))
|
| 109 |
r.sandbox.files.write("/tmp/prompt.txt", prompt)
|
| 110 |
r.sh("chmod 644 /tmp/opencode.json /tmp/prompt.txt")
|
| 111 |
key_env = ""
|
| 112 |
-
if ep:
|
| 113 |
# a root-only file, read by the root shell when it builds the command: the key is never on a command line
|
| 114 |
r.sandbox.files.write("/root/.agent_key", r.agent_key or "")
|
| 115 |
r.sh("chmod 600 /root/.agent_key")
|
|
@@ -122,7 +126,8 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
|
|
| 122 |
"XDG_CACHE_HOME": f"{xdg}/cache", "XDG_STATE_HOME": f"{xdg}/state"}
|
| 123 |
if (params.get("max_tokens") or 0) > 32000: # upstream silently caps output at 32k otherwise
|
| 124 |
flags["OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"] = str(params["max_tokens"])
|
| 125 |
-
|
|
|
|
| 126 |
# --thinking streams the model's reasoning as its own events, so the trace shows why, not only what;
|
| 127 |
# --title skips the extra model call that would otherwise name the session
|
| 128 |
inner = f'{env} opencode run --format json --thinking --auto --title mimo-rollout "$(cat /tmp/prompt.txt)"'
|
|
@@ -146,7 +151,7 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
|
|
| 146 |
try:
|
| 147 |
res = r.sh(cmd, timeout=timeout, on_stdout=on_out, on_stderr=on_err)
|
| 148 |
finally:
|
| 149 |
-
if ep:
|
| 150 |
r.sandbox.run("rm -f /root/.agent_key", shell=True, check=False, timeout=30)
|
| 151 |
if buf[0].strip():
|
| 152 |
_event(r, buf[0], texts)
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
def config_for(model: str, provider: str | None, steps: int, mcp: list[dict] | None = None,
|
| 66 |
+
endpoint: dict | None = None, params: dict | None = None, proxy: str | None = None) -> dict:
|
| 67 |
+
"""With `proxy` (the Space), every model call goes to this server's per-rollout proxy and the sandbox holds no
|
| 68 |
+
credential at all; the capability in the URL is the only secret, and it dies with the rollout."""
|
| 69 |
params = params or {}
|
| 70 |
+
if endpoint: # your own OpenAI-compatible endpoint; without the proxy, the key arrives as AGENT_API_KEY
|
| 71 |
mid, prov = model, {"byo": {"npm": "@ai-sdk/openai-compatible", "name": "Your endpoint",
|
| 72 |
+
"options": {"baseURL": proxy or endpoint["base_url"],
|
| 73 |
+
"apiKey": "proxied" if proxy else "{env:AGENT_API_KEY}"},
|
| 74 |
"models": {model: {"name": model, "tool_call": True}}}}
|
| 75 |
ref = f"byo/{model}"
|
| 76 |
else:
|
| 77 |
mid = f"{model}:{provider}" if provider else model
|
| 78 |
prov = {"hf": {"npm": "@ai-sdk/openai-compatible", "name": "Hugging Face Inference Providers",
|
| 79 |
+
"options": {"baseURL": proxy or config.ROUTER, "apiKey": "proxied" if proxy else "{env:HF_TOKEN}"},
|
| 80 |
"models": {mid: {"name": model, "tool_call": True}}}}
|
| 81 |
ref = f"hf/{mid}"
|
| 82 |
entry = next(iter(next(iter(prov.values()))["models"].values()))
|
|
|
|
| 107 |
ep, params = r.run.get("endpoint"), r.run.get("params") or {}
|
| 108 |
steps = params.get("steps") or steps
|
| 109 |
timeout = params["timeout_min"] * 60 if params.get("timeout_min") else timeout
|
| 110 |
+
proxy = r.llm_base()
|
| 111 |
+
cfg = config_for(r.run["model"], r.run.get("provider"), steps, mcp, ep, params, proxy)
|
| 112 |
r.sandbox.files.write("/tmp/opencode.json", json.dumps(cfg))
|
| 113 |
r.sandbox.files.write("/tmp/prompt.txt", prompt)
|
| 114 |
r.sh("chmod 644 /tmp/opencode.json /tmp/prompt.txt")
|
| 115 |
key_env = ""
|
| 116 |
+
if ep and not proxy:
|
| 117 |
# a root-only file, read by the root shell when it builds the command: the key is never on a command line
|
| 118 |
r.sandbox.files.write("/root/.agent_key", r.agent_key or "")
|
| 119 |
r.sh("chmod 600 /root/.agent_key")
|
|
|
|
| 126 |
"XDG_CACHE_HOME": f"{xdg}/cache", "XDG_STATE_HOME": f"{xdg}/state"}
|
| 127 |
if (params.get("max_tokens") or 0) > 32000: # upstream silently caps output at 32k otherwise
|
| 128 |
flags["OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"] = str(params["max_tokens"])
|
| 129 |
+
token_env = "" if proxy else 'HF_TOKEN="$HF_TOKEN" '
|
| 130 |
+
env = f"env {key_env}{token_env}HOME={home or '$HOME'} " + " ".join(f"{k}={v}" for k, v in flags.items())
|
| 131 |
# --thinking streams the model's reasoning as its own events, so the trace shows why, not only what;
|
| 132 |
# --title skips the extra model call that would otherwise name the session
|
| 133 |
inner = f'{env} opencode run --format json --thinking --auto --title mimo-rollout "$(cat /tmp/prompt.txt)"'
|
|
|
|
| 151 |
try:
|
| 152 |
res = r.sh(cmd, timeout=timeout, on_stdout=on_out, on_stderr=on_err)
|
| 153 |
finally:
|
| 154 |
+
if ep and not proxy:
|
| 155 |
r.sandbox.run("rm -f /root/.agent_key", shell=True, check=False, timeout=30)
|
| 156 |
if buf[0].strip():
|
| 157 |
_event(r, buf[0], texts)
|
web/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 13 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 14 |
<link rel="stylesheet" href="/app.css">
|
| 15 |
-
<script
|
| 16 |
</head>
|
| 17 |
<body>
|
| 18 |
<div class="progress" id="progress" aria-hidden="true"><i></i></div>
|
|
|
|
| 12 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 13 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 14 |
<link rel="stylesheet" href="/app.css">
|
| 15 |
+
<script src="/js/boot.js"></script>
|
| 16 |
</head>
|
| 17 |
<body>
|
| 18 |
<div class="progress" id="progress" aria-hidden="true"><i></i></div>
|
web/js/boot.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Runs before the page paints (loaded blocking in <head>): apply the saved theme so there's no flash.
|
| 2 |
+
try { const t = localStorage.getItem("theme"); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* private mode */ }
|
web/js/explore.js
CHANGED
|
@@ -50,7 +50,7 @@ export async function mount(el, params) {
|
|
| 50 |
if (!DATA) {
|
| 51 |
el.innerHTML = skeleton();
|
| 52 |
try { await progress.wrap(load()); }
|
| 53 |
-
catch (e) { el.innerHTML = `<div class="wrap page">${emptyState("alert", "Couldn't load the environments", esc(e.message), `<button class="btn"
|
| 54 |
if (root !== el) return;
|
| 55 |
}
|
| 56 |
if (params) {
|
|
|
|
| 50 |
if (!DATA) {
|
| 51 |
el.innerHTML = skeleton();
|
| 52 |
try { await progress.wrap(load()); }
|
| 53 |
+
catch (e) { el.innerHTML = `<div class="wrap page">${emptyState("alert", "Couldn't load the environments", esc(e.message), `<button class="btn" id="reload" type="button">${icon("refresh")}Try again</button>`)}</div>`; $("#reload", el)?.addEventListener("click", () => location.reload()); return; }
|
| 54 |
if (root !== el) return;
|
| 55 |
}
|
| 56 |
if (params) {
|