IMJONEZZ commited on
Commit
d94c85e
·
1 Parent(s): d2fa034

space: ZeroGPU port — Gradio SDK runtime, on-Space Warden inference

Browse files

app.py keeps the custom surface (CRT landing, xterm /play, raw /pty
websocket) and adds: 4-bit on-Space model load when `spaces` is present,
an OpenAI-style /v1/chat/completions SSE endpoint guarded by a per-boot
token (game subprocesses speak the existing api backend at loopback),
and a minimal gr.Blocks engine room at /engine. Frontmatter flips
sdk: docker -> gradio; requirements.txt feeds the Space runtime.
Without `spaces` everything degrades to operator env or scripted.

Files changed (3) hide show
  1. README.md +3 -2
  2. requirements.txt +11 -0
  3. space/app.py +187 -17
README.md CHANGED
@@ -3,8 +3,9 @@ title: SCRYPT
3
  emoji: 🕯️
4
  colorFrom: green
5
  colorTo: gray
6
- sdk: docker
7
- app_port: 7860
 
8
  pinned: false
9
  ---
10
 
 
3
  emoji: 🕯️
4
  colorFrom: green
5
  colorTo: gray
6
+ sdk: gradio
7
+ python_version: "3.12"
8
+ app_file: space/app.py
9
  pinned: false
10
  ---
11
 
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF Space (Gradio SDK / ZeroGPU) dependencies. The scrypt package itself is
2
+ # imported from the repo checkout via sys.path — see space/app.py.
3
+ textual>=1.0
4
+ rich>=13.0
5
+ pyyaml>=6.0
6
+ httpx>=0.27
7
+ uvicorn[standard]>=0.30
8
+ torch
9
+ transformers>=4.57
10
+ accelerate
11
+ bitsandbytes
space/app.py CHANGED
@@ -1,10 +1,10 @@
1
- """SCRYPT on the web — a custom frontend riding gradio.Server's backend.
2
 
3
  The prize brief: push past the default Gradio look. So Gradio here is the
4
- *engine room*, not the face. The app IS a `gradio.Server` (a FastAPI app
5
- with Gradio's machinery available); we drive it with uvicorn so our own
6
- routesincluding a raw PTY websocket Gradio's launcher would refuse
7
- take full priority. Nothing of the default Gradio UI is ever rendered.
8
 
9
  GET / a hand-built Osaka-Jade CRT landing page (static)
10
  GET /api/whisper the Warden mutters a line, in voice, so the landing
@@ -13,6 +13,17 @@ take full priority. Nothing of the default Gradio UI is ever rendered.
13
  WS /pty a pseudo-terminal bridge: each visitor gets their own
14
  `python -m scrypt.app` subprocess — their own sandbox,
15
  their own Warden — streamed to the browser byte for byte
 
 
 
 
 
 
 
 
 
 
 
16
  """
17
 
18
  from __future__ import annotations
@@ -21,16 +32,101 @@ import asyncio
21
  import json
22
  import os
23
  import random
 
 
24
  import tempfile
25
  from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- from fastapi import WebSocket, WebSocketDisconnect
28
- from fastapi.responses import FileResponse
29
  from fastapi.staticfiles import StaticFiles
30
  from gradio import Server
31
 
32
  STATIC = Path(__file__).parent / "static"
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # Curated in-voice teasers for the landing page. Scripted on purpose: the
35
  # greeter must never cost an API call or wake the model.
36
  WHISPERS = [
@@ -65,17 +161,58 @@ def play() -> FileResponse:
65
  return FileResponse(STATIC / "play.html")
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  # ----------------------------------------------------------- the PTY bridge
69
 
70
- GAME_ENV = {
71
- "TERM": "xterm-256color",
72
- "COLORTERM": "truecolor",
73
- "PYTHONUNBUFFERED": "1",
74
- # The cloud Warden talks over the API; the Space secret carries the key.
75
- # No key -> the game falls back to scripted lines and says so. Sandboxes
76
- # are always fabricated here; a hosted box never mirrors a real home.
77
- "SCRYPT_BACKEND": os.environ.get("SCRYPT_BACKEND", "api"),
78
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
 
81
  async def _pump_pty_to_ws(master_fd: int, ws: WebSocket) -> None:
@@ -104,7 +241,7 @@ async def pty_bridge(ws: WebSocket) -> None:
104
  home = tempfile.mkdtemp(prefix="scrypt-")
105
  pid, master_fd = pty.fork()
106
  if pid == 0: # child: become the game
107
- env = {**os.environ, **GAME_ENV, "SCRYPT_HOME": home}
108
  os.execvpe("python", ["python", "-m", "scrypt.app"], env)
109
  os._exit(127) # unreachable unless exec fails
110
 
@@ -144,6 +281,39 @@ async def pty_bridge(ws: WebSocket) -> None:
144
  app.mount("/static", StaticFiles(directory=STATIC), name="static")
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  if __name__ == "__main__":
148
  import uvicorn
149
 
 
1
+ """SCRYPT on the web — a custom frontend riding gradio's backend, ZeroGPU brain.
2
 
3
  The prize brief: push past the default Gradio look. So Gradio here is the
4
+ *engine room*, not the face. We build our own FastAPI surface (landing page,
5
+ xterm.js terminal, raw PTY websocket) and mount a minimal gr.Blocks at
6
+ /engineit exists so the ZeroGPU machinery has a Gradio app to hang onto,
7
+ and as a bare smoke-test console for the model.
8
 
9
  GET / a hand-built Osaka-Jade CRT landing page (static)
10
  GET /api/whisper the Warden mutters a line, in voice, so the landing
 
13
  WS /pty a pseudo-terminal bridge: each visitor gets their own
14
  `python -m scrypt.app` subprocess — their own sandbox,
15
  their own Warden — streamed to the browser byte for byte
16
+ POST /v1/chat/completions
17
+ OpenAI-style SSE endpoint backed by a @spaces.GPU
18
+ generator. Game subprocesses can't hold a ZeroGPU slot
19
+ themselves, so they speak the existing `api` backend
20
+ protocol at this loopback URL. Guarded by a per-boot
21
+ token: visitors can't burn GPU quota directly.
22
+
23
+ On ZeroGPU the model loads 4-bit at startup (CUDA is emulated until a
24
+ @spaces.GPU call attaches a real slice). Anywhere else — local Docker,
25
+ a laptop — there is no `spaces` package, no model, and the game falls back
26
+ to operator-supplied API env or the scripted Warden. The game never stalls.
27
  """
28
 
29
  from __future__ import annotations
 
32
  import json
33
  import os
34
  import random
35
+ import secrets
36
+ import sys
37
  import tempfile
38
  from pathlib import Path
39
+ from threading import Thread
40
+
41
+ REPO_ROOT = Path(__file__).resolve().parent.parent
42
+ if str(REPO_ROOT) not in sys.path:
43
+ sys.path.insert(0, str(REPO_ROOT))
44
+
45
+ # ZeroGPU contract: `import spaces` must precede any CUDA-touching import.
46
+ try:
47
+ import spaces # noqa: F401 (present on HF Spaces, absent elsewhere)
48
+ except ImportError:
49
+ spaces = None
50
 
51
+ from fastapi import Request, WebSocket, WebSocketDisconnect
52
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
53
  from fastapi.staticfiles import StaticFiles
54
  from gradio import Server
55
 
56
  STATIC = Path(__file__).parent / "static"
57
 
58
+ # ------------------------------------------------------------ the Warden brain
59
+
60
+ MODEL_ID = os.environ.get("WARDEN_MODEL", "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16")
61
+ INTERNAL_KEY = os.environ.get("SCRYPT_INTERNAL_KEY") or secrets.token_hex(16)
62
+
63
+ tok = None
64
+ model = None
65
+ WARDEN_ERR = "spaces package not present (not on a ZeroGPU Space)"
66
+
67
+ if spaces is not None:
68
+ try:
69
+ import torch
70
+ from transformers import (
71
+ AutoModelForCausalLM,
72
+ AutoTokenizer,
73
+ BitsAndBytesConfig,
74
+ )
75
+
76
+ tok = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
77
+ # 4-bit fits the `large` (48GB) slice and matches the Q4 GGUF the
78
+ # local game ships — quality parity, not a downgrade.
79
+ model = AutoModelForCausalLM.from_pretrained(
80
+ MODEL_ID,
81
+ trust_remote_code=True,
82
+ quantization_config=BitsAndBytesConfig(
83
+ load_in_4bit=True,
84
+ bnb_4bit_quant_type="nf4",
85
+ bnb_4bit_compute_dtype=torch.bfloat16,
86
+ ),
87
+ device_map="cuda",
88
+ )
89
+ WARDEN_ERR = ""
90
+ except Exception as err: # model is optional: the game survives without it
91
+ WARDEN_ERR = f"{type(err).__name__}: {err}"
92
+
93
+ WARDEN_READY = not WARDEN_ERR
94
+
95
+
96
+ def _generate_stream(messages, max_tokens, temperature, enable_thinking):
97
+ """Token generator. Wrapped by @spaces.GPU when on a Space."""
98
+ from transformers import TextIteratorStreamer
99
+
100
+ inputs = tok.apply_chat_template(
101
+ messages,
102
+ add_generation_prompt=True,
103
+ return_tensors="pt",
104
+ enable_thinking=enable_thinking,
105
+ ).to(model.device)
106
+ streamer = TextIteratorStreamer(tok, skip_prompt=True, skip_special_tokens=True)
107
+ thread = Thread(
108
+ target=model.generate,
109
+ kwargs=dict(
110
+ input_ids=inputs,
111
+ max_new_tokens=max_tokens,
112
+ do_sample=temperature > 0,
113
+ temperature=max(temperature, 1e-3),
114
+ top_p=0.95,
115
+ streamer=streamer,
116
+ ),
117
+ daemon=True,
118
+ )
119
+ thread.start()
120
+ yield from streamer
121
+
122
+
123
+ if spaces is not None:
124
+ # ~1.5K-token prefill + short generations on a 3.5B-active MoE: seconds.
125
+ _generate_stream = spaces.GPU(duration=90)(_generate_stream)
126
+
127
+
128
+ # ----------------------------------------------------------------- the surface
129
+
130
  # Curated in-voice teasers for the landing page. Scripted on purpose: the
131
  # greeter must never cost an API call or wake the model.
132
  WHISPERS = [
 
161
  return FileResponse(STATIC / "play.html")
162
 
163
 
164
+ # ------------------------------------------------- the loopback inference API
165
+
166
+
167
+ @app.post("/v1/chat/completions")
168
+ async def chat_completions(request: Request):
169
+ """OpenAI-compatible SSE, just enough for scrypt.inference.api. Only the
170
+ game's own subprocesses hold the per-boot bearer; everyone else gets 401
171
+ rather than a lever on our GPU quota."""
172
+ if request.headers.get("authorization") != f"Bearer {INTERNAL_KEY}":
173
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
174
+ if not WARDEN_READY:
175
+ return JSONResponse({"error": f"warden offline: {WARDEN_ERR}"}, status_code=503)
176
+
177
+ body = await request.json()
178
+ messages = body.get("messages", [])
179
+ max_tokens = int(body.get("max_tokens", 256))
180
+ temperature = float(body.get("temperature", 0.6))
181
+ thinking = bool(body.get("chat_template_kwargs", {}).get("enable_thinking", False))
182
+
183
+ def sse():
184
+ for chunk in _generate_stream(messages, max_tokens, temperature, thinking):
185
+ payload = {"choices": [{"delta": {"content": chunk}}]}
186
+ yield f"data: {json.dumps(payload)}\n\n"
187
+ yield "data: [DONE]\n\n"
188
+
189
+ return StreamingResponse(sse(), media_type="text/event-stream")
190
+
191
+
192
  # ----------------------------------------------------------- the PTY bridge
193
 
194
+
195
+ def game_env() -> dict:
196
+ """Environment for one visitor's game process. Sandboxes are always
197
+ fabricated here; a hosted box never mirrors a real home."""
198
+ env = {
199
+ "TERM": "xterm-256color",
200
+ "COLORTERM": "truecolor",
201
+ "PYTHONUNBUFFERED": "1",
202
+ "PYTHONPATH": str(REPO_ROOT),
203
+ }
204
+ if WARDEN_READY:
205
+ env |= {
206
+ "SCRYPT_BACKEND": "api",
207
+ "SCRYPT_API_BASE": "http://127.0.0.1:7860/v1",
208
+ "SCRYPT_API_KEY": INTERNAL_KEY,
209
+ "SCRYPT_MODEL": MODEL_ID,
210
+ }
211
+ elif os.environ.get("SCRYPT_API_KEY"):
212
+ env["SCRYPT_BACKEND"] = os.environ.get("SCRYPT_BACKEND", "api")
213
+ else:
214
+ env["SCRYPT_BACKEND"] = "scripted"
215
+ return env
216
 
217
 
218
  async def _pump_pty_to_ws(master_fd: int, ws: WebSocket) -> None:
 
241
  home = tempfile.mkdtemp(prefix="scrypt-")
242
  pid, master_fd = pty.fork()
243
  if pid == 0: # child: become the game
244
+ env = {**os.environ, **game_env(), "SCRYPT_HOME": home}
245
  os.execvpe("python", ["python", "-m", "scrypt.app"], env)
246
  os._exit(127) # unreachable unless exec fails
247
 
 
281
  app.mount("/static", StaticFiles(directory=STATIC), name="static")
282
 
283
 
284
+ # ------------------------------------------------------------ the engine room
285
+
286
+ import gradio as gr # noqa: E402
287
+
288
+
289
+ def _probe(text: str):
290
+ """Manual smoke test: one message in, the Warden's line streamed back."""
291
+ if not WARDEN_READY:
292
+ yield f"warden offline: {WARDEN_ERR}"
293
+ return
294
+ acc = ""
295
+ for chunk in _generate_stream(
296
+ [{"role": "user", "content": text}], 80, 0.6, False
297
+ ):
298
+ acc += chunk
299
+ yield acc
300
+
301
+
302
+ with gr.Blocks(title="SCRYPT engine room") as engine:
303
+ gr.Markdown(
304
+ "# SCRYPT engine room\n"
305
+ f"model: `{MODEL_ID}`\n\n"
306
+ f"status: {'**ready**' if WARDEN_READY else f'offline — {WARDEN_ERR}'}\n\n"
307
+ "The game lives at [/](/) — this page only exists to keep the "
308
+ "machinery warm and let us poke the model directly."
309
+ )
310
+ box = gr.Textbox(label="say something to the Warden")
311
+ out = gr.Textbox(label="the Warden")
312
+ box.submit(_probe, box, out)
313
+
314
+ gr.mount_gradio_app(app, engine, path="/engine")
315
+
316
+
317
  if __name__ == "__main__":
318
  import uvicorn
319