File size: 16,604 Bytes
cdac089 69091a1 786283e 69091a1 cdac089 69091a1 cdac089 786283e 69091a1 9559d96 cdac089 ac7d306 4792e6e cdac089 d4a309a cdac089 69091a1 cdac089 786283e cdac089 d007526 9559d96 ac7d306 9559d96 cdac089 d007526 1ba2764 d007526 1ba2764 d007526 ac7d306 9559d96 ac7d306 9559d96 ac7d306 9559d96 d007526 cdac089 fb80aa2 4792e6e fb80aa2 4792e6e ac7d306 d007526 c89eccb fb80aa2 cdac089 d007526 786283e d4a309a 786283e cdac089 786283e 1ba2764 cdac089 d4a309a cdac089 d007526 cdac089 4792e6e cdac089 9559d96 4792e6e cdac089 1ba2764 cdac089 0979c2f 786283e d4a309a 786283e d4a309a cdac089 69091a1 bcb8efe 69091a1 9559d96 ac7d306 9559d96 c89eccb 9559d96 cdac089 69091a1 1ba2764 cdac089 d4a309a 69091a1 d4a309a cdac089 69091a1 cdac089 1ba2764 cdac089 d4a309a 69091a1 cdac089 69091a1 cdac089 69091a1 cdac089 9559d96 cdac089 9559d96 4792e6e ac7d306 9559d96 cdac089 c89eccb cdac089 c89eccb d007526 cdac089 1ba2764 cdac089 9559d96 cdac089 9559d96 cdac089 | 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 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 | """Lantern web server: runs Beam episodes and streams them to the browser over SSE.
Live runs spend the Space owner's inference credits, so they are limited to one at a
time and a daily cap. Every finished episode is saved and can be replayed for free.
"""
from __future__ import annotations
import asyncio
import json
import hashlib
import logging
import hmac
import os
import secrets
import threading
import time
import uuid
from datetime import date
from pathlib import Path
import httpx
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from lantern.agent import EpisodeConfig, run_episode
from lantern.coach import Hint, HintQueue
from lantern.guard import check_hint
from lantern.env import TASKS, DungeonEnv
from lantern.journal import Journal
from lantern.llm import ChatClient, OutOfCredits
from lantern.store import EpisodeStore
DATA_DIR = Path(os.environ.get("LANTERN_DATA", "data"))
DAILY_LIVE_RUNS = int(os.environ.get("LANTERN_DAILY_RUNS", "20"))
VISITOR_DAILY_RUNS = int(os.environ.get("LANTERN_VISITOR_DAILY_RUNS", "3"))
MAX_CALLS_PER_RUN = int(os.environ.get("LANTERN_MAX_CALLS", "60"))
WEB_DIR = Path(__file__).parent / "web"
app = FastAPI(title="Lantern")
log = logging.getLogger("lantern.app")
# Episodes and the journal are mirrored to a Hub dataset so they survive restarts.
store = EpisodeStore(DATA_DIR, os.environ.get("LANTERN_DATASET"), os.environ.get("LANTERN_DATASET_TOKEN"))
store.pull()
journal = Journal(store.journal_path)
class Run:
"""One live episode running in a background thread, buffering its events."""
def __init__(self, task_id: str, seed: int, use_lessons: bool):
self.id = uuid.uuid4().hex[:12]
self.task_id, self.seed, self.use_lessons = task_id, seed, use_lessons
self.events: list[dict] = []
self.done = False
self.stop = threading.Event()
self._lock = threading.Lock()
self.visitor: str | None = None # keyed hash of who started it; only they may coach
self.hints = HintQueue()
self.hints_sent = 0
self.coached = False
def start(self) -> None:
threading.Thread(target=self._play, daemon=True).start()
def request_stop(self) -> None:
"""End the run for viewers right away.
A Beam call already in flight cannot be cancelled cleanly, so the worker thread
lets it finish and discards the result; viewers and the run slot don't wait for it.
"""
self.stop.set()
with self._lock:
if self.done:
return
turns = [e for e in self.events if e["type"] == "turn"]
# Usage up to the last finished call; a call still in flight isn't counted.
usage = dict(turns[-1].get("usage", {})) if turns else {}
usage.setdefault("calls", len(turns))
self.events.append({"type": "end", "task": self.task_id, "outcome": "stopped",
"steps": turns[-1]["steps"] if turns else 0, "reward": 0.0,
"usage": usage, "new_lessons": []})
self._finish()
def add_hint(self, hint: Hint) -> None:
"""Queue advice from the viewer; it interrupts Beam's current plan between moves."""
with self._lock:
self.hints.add(hint)
self.hints_sent += 1
self.coached = True
def _emit(self, event: dict) -> bool:
"""Record an event unless the run was already stopped; returns False once stopped."""
with self._lock:
if self.done:
return False
self.events.append(event)
return True
def _play(self) -> None:
env = None
try:
env = DungeonEnv(TASKS[self.task_id], seed=self.seed)
lessons = journal.lessons(self.task_id)
# Journal v2: Beam curates the journal from each lesson's win/loss record.
# With "Use journal" off Beam never sees the lessons, so the run must neither credit
# them with its outcome nor rewrite the journal (same as the benchmark's "off").
config = EpisodeConfig(use_lessons=self.use_lessons, max_llm_calls=MAX_CALLS_PER_RUN,
reflect=self.use_lessons, curate=True,
lesson_records=journal.records(self.task_id) if self.use_lessons else None)
for event in run_episode(env, ChatClient(), lessons, config, self.stop, self.hints):
if self.stop.is_set():
break
# A coached run's lessons could carry a viewer's text into the journal that
# every later run reads in its system prompt, so only uncoached runs write to it.
if event["type"] == "end" and event.get("journal_records") is not None and not self.coached:
event["journal"] = journal.set_records(self.task_id, event["journal_records"])
store.push_journal()
if not self._emit(event):
break
except OutOfCredits as exc:
log.warning("Live runs paused, provider said: %s", exc)
credits.mark_out()
self._emit({"type": "error", "kind": "credits", "message": CREDITS_MESSAGE})
except httpx.HTTPStatusError as exc: # refused even after retries; usually temporary
log.warning("Provider refused a call: %s", exc)
self._emit({"type": "error", "kind": "busy", "message": BUSY_MESSAGE})
except Exception as exc: # surface the failure to the viewer instead of hanging
log.exception("Run %s failed", self.id)
# Details go to the server log only; viewers and the saved episode get a plain message.
self._emit({"type": "error", "message": "Something went wrong on the server, so this run stopped."})
finally:
try:
if env is not None:
env.close()
finally: # a failing cleanup must never leave the Space stuck "busy"
with self._lock:
if not self.done:
self._finish()
def _finish(self) -> None:
"""Mark done and save; callers hold the lock."""
self.done = True
end = next((e for e in reversed(self.events) if e["type"] == "end"), {})
# Coached runs hold text typed by visitors (also quoted in Beam's reasoning), so they
# stay on the Space and never go to the public dataset.
store.save({"id": self.id, "task": self.task_id, "seed": self.seed, "source": "space",
"use_lessons": self.use_lessons, "finished_at": time.time(),
"outcome": end.get("outcome", "error"), "steps": end.get("steps", 0),
"coached": self.coached,
"events": list(self.events)}, upload=not self.coached)
runs: dict[str, Run] = {}
_quota = {"day": date.today(), "used": 0}
def _roll_quota() -> None:
"""Start a fresh daily count once the date changes (called on every read and write)."""
if _quota["day"] != date.today():
_quota.update(day=date.today(), used=0)
_lock = threading.Lock()
CREDITS_MESSAGE = ("Live runs are paused: this demo has reached its inference spending limit "
"for now. Replays still work.")
BUSY_MESSAGE = ("The model is busy right now and didn't answer. Please try again in a minute; "
"replays work in the meantime.")
class CreditsState:
"""Remembers an out-of-credits error; allows a fresh attempt after `retry_s` in case of a top-up."""
def __init__(self, retry_s: float = 600):
self.retry_s = retry_s
self.out_since: float | None = None
def mark_out(self) -> None:
self.out_since = time.time()
@property
def available(self) -> bool:
if self.out_since is not None and time.time() - self.out_since > self.retry_s:
self.out_since = None
return self.out_since is None
credits = CreditsState()
class VisitorQuota:
"""Per-visitor daily live-run allowance.
Visitors are identified by a keyed hash of their IP address. The raw IP is never
stored or logged, the key lives only in memory (new on every restart), and counts
reset each day, so nothing here can identify a person.
"""
def __init__(self, limit: int):
self.limit = limit
self._key = secrets.token_bytes(32)
self._day = date.today()
self._counts: dict[str, int] = {}
def visitor(self, request: Request) -> str:
# HF's proxy appends the real client address to any X-Forwarded-For the client sent,
# so the left-most entry is client-controlled (verified: a fake one dodged the limit).
# Use the right-most entry, the one the proxy added.
forwarded = [part.strip() for part in request.headers.get("x-forwarded-for", "").split(",") if part.strip()]
ip = forwarded[-1] if forwarded else (request.client.host if request.client else "unknown")
return hmac.new(self._key, ip.encode(), hashlib.sha256).hexdigest()
def _roll(self) -> None:
if self._day != date.today():
self._day, self._counts = date.today(), {}
def left(self, visitor: str) -> int:
self._roll()
return max(0, self.limit - self._counts.get(visitor, 0))
def use(self, visitor: str) -> None:
self._roll()
self._counts[visitor] = self._counts.get(visitor, 0) + 1
visitors = VisitorQuota(VISITOR_DAILY_RUNS)
VISITOR_LIMIT_MESSAGE = (f"You've used your {VISITOR_DAILY_RUNS} live runs for today. Thanks for exploring! "
"Replays are free and unlimited, and live runs reset at midnight UTC.")
MAX_HINT_CHARS = 120
MAX_HINTS_PER_RUN = 30
class HintRequest(BaseModel):
text: str = Field(min_length=1, max_length=MAX_HINT_CHARS)
# Where the viewer was looking when they sent it; their screen can lag the game.
seen_step: int | None = Field(default=None, ge=0, le=100_000)
seen_x: int | None = Field(default=None, ge=0, le=200)
seen_y: int | None = Field(default=None, ge=0, le=200)
def clean_hint(text: str) -> str:
"""Printable characters only, whitespace collapsed; hints are shown to Beam as data."""
kept = "".join(" " if ch.isspace() else ch for ch in text if ch.isspace() or ch.isprintable())
kept = kept.replace('"', "'") # hints are shown to Beam inside double quotes; don't let one close them
return " ".join(kept.split())[:MAX_HINT_CHARS]
class RunRequest(BaseModel):
task: str
seed: int = Field(default=0, ge=0, le=1_000_000)
use_lessons: bool = True
@app.get("/api/tasks")
def list_tasks() -> list[dict]:
return [{"id": t.id, "title": t.title, "goal": t.goal, "max_steps": t.max_steps,
"lessons": len(journal.lessons(t.id))} for t in TASKS.values()]
@app.get("/api/status")
def status(request: Request) -> dict:
_roll_quota()
busy = any(not r.done for r in runs.values())
return {"busy": busy, "runs_left_today": max(0, DAILY_LIVE_RUNS - _quota["used"]),
"your_runs_left": visitors.left(visitors.visitor(request)),
"live_available": credits.available,
"live_message": None if credits.available else CREDITS_MESSAGE}
@app.post("/api/runs")
def start_run(req: RunRequest, request: Request) -> dict:
if req.task not in TASKS:
raise HTTPException(404, f"Unknown level '{req.task}'.")
with _lock:
_roll_quota()
if any(not r.done for r in runs.values()):
raise HTTPException(409, "Beam is already exploring. Watch that run or open a replay.")
if not credits.available:
raise HTTPException(402, CREDITS_MESSAGE)
visitor = visitors.visitor(request)
if visitors.left(visitor) <= 0:
raise HTTPException(429, VISITOR_LIMIT_MESSAGE)
if _quota["used"] >= DAILY_LIVE_RUNS:
raise HTTPException(429, "This demo's live runs for today are used up. Replays are free "
"and unlimited, and live runs reset at midnight UTC.")
_quota["used"] += 1
visitors.use(visitor)
run = Run(req.task, req.seed, req.use_lessons)
run.visitor = visitor
runs[run.id] = run
run.start()
return {"id": run.id}
@app.post("/api/runs/{run_id}/hint")
def send_hint(run_id: str, req: HintRequest, request: Request) -> dict:
run = runs.get(run_id)
if run is None or run.done:
raise HTTPException(404, "That run has finished.")
if run.visitor != visitors.visitor(request):
raise HTTPException(403, "Only the person who started this run can coach it.")
if run.hints_sent >= MAX_HINTS_PER_RUN:
raise HTTPException(429, f"That's the limit of {MAX_HINTS_PER_RUN} hints for one run.")
text = clean_hint(req.text)
if not text:
raise HTTPException(400, "Type a hint first.")
problem = check_hint(text) # PII, health information, profanity; the text itself is never logged
if problem:
raise HTTPException(400, f"That hint looks like it contains {problem}. Please keep hints about the game.")
run.add_hint(Hint(text, req.seen_step, req.seen_x, req.seen_y))
return {"queued": text}
@app.post("/api/runs/{run_id}/stop")
def stop_run(run_id: str, request: Request) -> dict:
run = runs.get(run_id)
if run is None:
raise HTTPException(404, "No such run.")
if run.visitor is not None and run.visitor != visitors.visitor(request):
raise HTTPException(403, "Only the person who started this run can stop it.")
run.request_stop()
return {"stopped": True}
@app.get("/api/runs/{run_id}/events")
async def run_events(run_id: str) -> StreamingResponse:
run = runs.get(run_id)
if run is None:
raise HTTPException(404, "No such run.")
async def stream():
sent = 0
while True:
while sent < len(run.events):
yield f"data: {json.dumps(run.events[sent])}\n\n"
sent += 1
if run.done and sent >= len(run.events):
yield "event: close\ndata: {}\n\n"
return
await asyncio.sleep(0.25)
return StreamingResponse(stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@app.get("/api/episodes")
def list_episodes() -> list[dict]:
return store.summaries(50)
@app.get("/api/episodes/{episode_id}")
def get_episode(episode_id: str) -> dict:
if not episode_id.isalnum():
raise HTTPException(400, "Bad episode id.")
record = store.load(episode_id)
if record is None:
raise HTTPException(404, "No such episode.")
return record
def _asset_version() -> str:
"""Short hash of the page's own scripts and styles; changes whenever they change."""
digest = hashlib.sha256()
for name in ("app.js", "audio.js", "scene3d.js", "style.css"):
digest.update((WEB_DIR / name).read_bytes())
return digest.hexdigest()[:10]
ASSET_VERSION = _asset_version()
@app.get("/")
def index() -> HTMLResponse:
# Stamp asset URLs so a new deploy is never served stale scripts from a browser cache
# (the Space runs in an iframe, where a hard refresh doesn't reach the app's files).
html = (WEB_DIR / "index.html").read_text("utf-8")
for name in ("app.js", "audio.js", "scene3d.js", "style.css"):
html = html.replace(f"/static/{name}", f"/static/{name}?v={ASSET_VERSION}")
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
@app.middleware("http")
async def revalidate_static(request: Request, call_next):
"""Browsers must check static files with the server (cheap, via ETag) before reusing them."""
response = await call_next(request)
if request.url.path.startswith("/static/"):
response.headers["Cache-Control"] = "no-cache"
return response
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
|