Spaces:
Running on Zero
Running on Zero
Michael Stattelman commited on
Commit ·
43c3b3c
1
Parent(s): 21c5089
Add application file
Browse files- README.md +3 -3
- app.py +31 -18
- app/main.py +1 -1
- space.py +0 -98
- tests/test_space.py +11 -0
README.md
CHANGED
|
@@ -65,9 +65,9 @@ With Compose, set the same build arg and uncomment the `deploy` block in `docker
|
|
| 65 |
|
| 66 |
## Hugging Face Space
|
| 67 |
|
| 68 |
-
This folder is also a Gradio Space, set up for ZeroGPU hardware (it also runs on a CPU Space). `app.py`
|
| 69 |
|
| 70 |
-
On ZeroGPU a GPU is attached for each decision. The
|
| 71 |
|
| 72 |
There is no token on the Space: on a public Space anyone can use the page and the API. Make the Space private on Hugging Face if that is not what you want.
|
| 73 |
|
|
@@ -80,7 +80,7 @@ cd <your-space>
|
|
| 80 |
git lfs install
|
| 81 |
# copy the contents of this DecisionLab folder into it (not the folder itself), then:
|
| 82 |
git add .
|
| 83 |
-
git commit -m "DecisionLab 2.1.
|
| 84 |
git push
|
| 85 |
```
|
| 86 |
|
|
|
|
| 65 |
|
| 66 |
## Hugging Face Space
|
| 67 |
|
| 68 |
+
This folder is also a Gradio Space, set up for ZeroGPU hardware (it also runs on a CPU Space). `app.py` starts the Space through Gradio's own `launch()` (ZeroGPU needs it) and puts the lab's routes in front of Gradio's, so the page at `/` and the API at `/api/*` are the container's. Gradio's `decide` endpoint stays available for `gradio_client`, running the same decision code.
|
| 69 |
|
| 70 |
+
On ZeroGPU a GPU is attached for each decision. The models load before the Space starts serving (ZeroGPU prepares them at startup). Each model's time is measured on the GPU, and the round trip also includes the GPU hand-off. Each decision uses your ZeroGPU quota, so a full scoreboard run (one decision per demo) uses more of it.
|
| 71 |
|
| 72 |
There is no token on the Space: on a public Space anyone can use the page and the API. Make the Space private on Hugging Face if that is not what you want.
|
| 73 |
|
|
|
|
| 80 |
git lfs install
|
| 81 |
# copy the contents of this DecisionLab folder into it (not the folder itself), then:
|
| 82 |
git add .
|
| 83 |
+
git commit -m "DecisionLab 2.1.2"
|
| 84 |
git push
|
| 85 |
```
|
| 86 |
|
app.py
CHANGED
|
@@ -4,17 +4,19 @@ The Space runs this file. It serves the SAME FastAPI app as the container, so th
|
|
| 4 |
JavaScript) and /api/* are the container's. A Gradio app is mounted beside it at /gradio: a small form and a "decide"
|
| 5 |
API endpoint for gradio_client, both running the same decision code (app.main.run_decision).
|
| 6 |
|
| 7 |
-
ZeroGPU (Hugging Face docs, checked 2026-09-
|
| 8 |
- `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
|
| 9 |
emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
@spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
|
| 19 |
|
| 20 |
No token auth anywhere (operator ruling 2026-09-28): on a public Space, anyone can use the page and the API.
|
|
@@ -31,11 +33,12 @@ os.environ.setdefault("MODELS_DIR", str(HERE / "models")) # the Space's own
|
|
| 31 |
os.environ.setdefault("DLAB_WARMUP", "0") # no real GPU outside @spaces.GPU: no warm-up run
|
| 32 |
|
| 33 |
import gradio as gr # noqa: E402
|
| 34 |
-
import
|
| 35 |
|
| 36 |
from app import main as lab # noqa: E402
|
| 37 |
from app.main import VERSION, Busy, app, run_decision # noqa: E402
|
| 38 |
from app.models import load_all # noqa: E402
|
|
|
|
| 39 |
|
| 40 |
GPU_SECONDS = int(os.getenv("GPU_SECONDS", "60")) # longest a single decision may hold the GPU
|
| 41 |
|
|
@@ -48,7 +51,6 @@ def run_models_on_gpu(state, questions, keys):
|
|
| 48 |
lab.RUN_MODELS = run_models_on_gpu
|
| 49 |
|
| 50 |
|
| 51 |
-
GRADIO_PATH = "/gradio"
|
| 52 |
EXAMPLE_STATE = "I was charged twice for March. Refund the duplicate or I cancel."
|
| 53 |
EXAMPLE_QUESTIONS = json.dumps({
|
| 54 |
"dept": {"type": "choice", "instructions": "Which team?",
|
|
@@ -81,7 +83,7 @@ def decide(state: str, questions_json: str, models: str = "") -> dict:
|
|
| 81 |
def build_blocks() -> gr.Blocks:
|
| 82 |
with gr.Blocks(title="DecisionLab API") as demo:
|
| 83 |
gr.Markdown(f"# DecisionLab {VERSION}: Gradio API\n"
|
| 84 |
-
"The full lab is
|
| 85 |
with gr.Row():
|
| 86 |
state = gr.Textbox(label="State (text or JSON)", lines=8, value=EXAMPLE_STATE)
|
| 87 |
questions = gr.Code(label="Questions (JSON)", language="json", value=EXAMPLE_QUESTIONS)
|
|
@@ -92,15 +94,26 @@ def build_blocks() -> gr.Blocks:
|
|
| 92 |
return demo
|
| 93 |
|
| 94 |
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
def main() -> None:
|
| 99 |
-
#
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
if __name__ == "__main__":
|
|
|
|
| 4 |
JavaScript) and /api/* are the container's. A Gradio app is mounted beside it at /gradio: a small form and a "decide"
|
| 5 |
API endpoint for gradio_client, both running the same decision code (app.main.run_decision).
|
| 6 |
|
| 7 |
+
ZeroGPU (Hugging Face docs and the `spaces` package source, checked 2026-09-30):
|
| 8 |
- `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
|
| 9 |
emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
|
| 10 |
+
- ZeroGPU's startup step (spaces/zero/__init__.py: `gradio.one_launch(torch.pack)`) runs inside Gradio's own
|
| 11 |
+
Blocks.launch(). So this file starts the server with demo.launch(), NOT uvicorn + mount_gradio_app: without
|
| 12 |
+
launch() the step never runs and the Space fails with "No @spaces.GPU function detected during startup"
|
| 13 |
+
(2.1.0 and 2.1.1, 2026-09-30).
|
| 14 |
+
- Models are loaded BEFORE launch(), so the startup step packs them (the documented ZeroGPU pattern).
|
| 15 |
+
- The lab's routes (the page at "/", /static, /api/*) are put in front of Gradio's inside Gradio's server, so "/"
|
| 16 |
+
is the container's page. Gradio's own routes stay for gradio_client (api_name "/decide").
|
| 17 |
+
- Only the model-running step is decorated; the request checks and the concurrency limit stay in the main process.
|
| 18 |
+
Each model times itself inside the GPU call, so model timings stay comparable.
|
| 19 |
+
- Gradio's server-side rendering is off: its Node proxy would take port 7860.
|
| 20 |
@spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
|
| 21 |
|
| 22 |
No token auth anywhere (operator ruling 2026-09-28): on a public Space, anyone can use the page and the API.
|
|
|
|
| 33 |
os.environ.setdefault("DLAB_WARMUP", "0") # no real GPU outside @spaces.GPU: no warm-up run
|
| 34 |
|
| 35 |
import gradio as gr # noqa: E402
|
| 36 |
+
from starlette.middleware import Middleware # noqa: E402
|
| 37 |
|
| 38 |
from app import main as lab # noqa: E402
|
| 39 |
from app.main import VERSION, Busy, app, run_decision # noqa: E402
|
| 40 |
from app.models import load_all # noqa: E402
|
| 41 |
+
from app.security import SecurityMiddleware # noqa: E402
|
| 42 |
|
| 43 |
GPU_SECONDS = int(os.getenv("GPU_SECONDS", "60")) # longest a single decision may hold the GPU
|
| 44 |
|
|
|
|
| 51 |
lab.RUN_MODELS = run_models_on_gpu
|
| 52 |
|
| 53 |
|
|
|
|
| 54 |
EXAMPLE_STATE = "I was charged twice for March. Refund the duplicate or I cancel."
|
| 55 |
EXAMPLE_QUESTIONS = json.dumps({
|
| 56 |
"dept": {"type": "choice", "instructions": "Which team?",
|
|
|
|
| 83 |
def build_blocks() -> gr.Blocks:
|
| 84 |
with gr.Blocks(title="DecisionLab API") as demo:
|
| 85 |
gr.Markdown(f"# DecisionLab {VERSION}: Gradio API\n"
|
| 86 |
+
"The full lab is the main page. `gradio_client` (api_name \"/decide\") runs the same decision code.")
|
| 87 |
with gr.Row():
|
| 88 |
state = gr.Textbox(label="State (text or JSON)", lines=8, value=EXAMPLE_STATE)
|
| 89 |
questions = gr.Code(label="Questions (JSON)", language="json", value=EXAMPLE_QUESTIONS)
|
|
|
|
| 94 |
return demo
|
| 95 |
|
| 96 |
|
| 97 |
+
def lab_first(server_app) -> None:
|
| 98 |
+
"""Put the lab's routes (page, /static, /api/*) in front of Gradio's, so "/" is DecisionLab's page."""
|
| 99 |
+
lab_routes = list(app.routes)
|
| 100 |
+
rest = [r for r in server_app.router.routes if r not in lab_routes]
|
| 101 |
+
server_app.router.routes[:] = lab_routes + rest
|
| 102 |
|
| 103 |
|
| 104 |
def main() -> None:
|
| 105 |
+
load_all() # before launch(): ZeroGPU's startup step packs the loaded models
|
| 106 |
+
demo = build_blocks()
|
| 107 |
+
demo.launch(
|
| 108 |
+
server_name=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
|
| 109 |
+
server_port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860),
|
| 110 |
+
ssr_mode=False,
|
| 111 |
+
prevent_thread_lock=True,
|
| 112 |
+
app_kwargs={"docs_url": None, "redoc_url": None, "openapi_url": None,
|
| 113 |
+
"middleware": [Middleware(SecurityMiddleware, max_body=lab.MAX_BODY_BYTES)]},
|
| 114 |
+
)
|
| 115 |
+
lab_first(demo.app)
|
| 116 |
+
demo.block_thread()
|
| 117 |
|
| 118 |
|
| 119 |
if __name__ == "__main__":
|
app/main.py
CHANGED
|
@@ -27,7 +27,7 @@ MAX_BODY_BYTES = int(os.getenv("MAX_BODY_BYTES", str(256 * 1024)))
|
|
| 27 |
DECIDE_SLOTS = Gate(int(os.getenv("MAX_PENDING_DECIDES", "4"))) # DL-SA-004
|
| 28 |
|
| 29 |
# DL-SA-007: no /docs, /redoc or /openapi.json
|
| 30 |
-
VERSION = "2.1.
|
| 31 |
PAGE = stamp_assets((STATIC / "index.html").read_text(encoding="utf-8"), VERSION)
|
| 32 |
|
| 33 |
|
|
|
|
| 27 |
DECIDE_SLOTS = Gate(int(os.getenv("MAX_PENDING_DECIDES", "4"))) # DL-SA-004
|
| 28 |
|
| 29 |
# DL-SA-007: no /docs, /redoc or /openapi.json
|
| 30 |
+
VERSION = "2.1.2"
|
| 31 |
PAGE = stamp_assets((STATIC / "index.html").read_text(encoding="utf-8"), VERSION)
|
| 32 |
|
| 33 |
|
space.py
DELETED
|
@@ -1,98 +0,0 @@
|
|
| 1 |
-
"""DecisionLab on a Hugging Face Space (Gradio SDK, ZeroGPU hardware).
|
| 2 |
-
|
| 3 |
-
The Space runs this file. It serves the SAME FastAPI app as the container, so the lab page at "/" (HTML, CSS and
|
| 4 |
-
JavaScript) and /api/* are the container's. A Gradio app is mounted beside it at /gradio: a small form and a "decide"
|
| 5 |
-
API endpoint for gradio_client, both running the same decision code (app.main.run_decision).
|
| 6 |
-
|
| 7 |
-
ZeroGPU (Hugging Face docs, checked 2026-09-28):
|
| 8 |
-
- `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
|
| 9 |
-
emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
|
| 10 |
-
- Decorated calls run apart from the main process, so all models are loaded BEFORE the server starts, and only the
|
| 11 |
-
model-running step is decorated. The request checks and the concurrency limit stay in the main process.
|
| 12 |
-
- Each model times itself inside the GPU call, so model timings stay comparable; the GPU hand-off only adds to the
|
| 13 |
-
round trip.
|
| 14 |
-
@spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
|
| 15 |
-
|
| 16 |
-
No token auth anywhere (operator ruling 2026-09-28): on a public Space, anyone can use the page and the API.
|
| 17 |
-
"""
|
| 18 |
-
import spaces # noqa: I001 -- must be the first import (patches torch for ZeroGPU)
|
| 19 |
-
|
| 20 |
-
import json
|
| 21 |
-
import os
|
| 22 |
-
from pathlib import Path
|
| 23 |
-
|
| 24 |
-
HERE = Path(__file__).resolve().parent
|
| 25 |
-
os.environ.setdefault("MODELS_DIR", str(HERE / "models")) # the Space's own models/ folder
|
| 26 |
-
os.environ.setdefault("DLAB_WARMUP", "0") # no real GPU outside @spaces.GPU: no warm-up run
|
| 27 |
-
|
| 28 |
-
import gradio as gr # noqa: E402
|
| 29 |
-
import uvicorn # noqa: E402
|
| 30 |
-
|
| 31 |
-
from app import main as lab # noqa: E402
|
| 32 |
-
from app.main import VERSION, Busy, app, run_decision # noqa: E402
|
| 33 |
-
from app.models import load_all # noqa: E402
|
| 34 |
-
|
| 35 |
-
GPU_SECONDS = int(os.getenv("GPU_SECONDS", "60")) # longest a single decision may hold the GPU
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
@spaces.GPU(duration=GPU_SECONDS)
|
| 39 |
-
def run_models_on_gpu(state, questions, keys):
|
| 40 |
-
return lab.run_models(state, questions, keys)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
lab.RUN_MODELS = run_models_on_gpu
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
GRADIO_PATH = "/gradio"
|
| 47 |
-
EXAMPLE_STATE = "I was charged twice for March. Refund the duplicate or I cancel."
|
| 48 |
-
EXAMPLE_QUESTIONS = json.dumps({
|
| 49 |
-
"dept": {"type": "choice", "instructions": "Which team?",
|
| 50 |
-
"criteria": {"billing": "Payments and refunds", "tech": "Bugs and errors"}},
|
| 51 |
-
"churn": {"type": "noul", "instructions": "The customer threatens to leave"},
|
| 52 |
-
}, indent=2)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def decide(state: str, questions_json: str, models: str = "") -> dict:
|
| 56 |
-
"""Run one decision. state: text, or a JSON object. questions_json: the questions as JSON.
|
| 57 |
-
models: comma-separated model keys (see /api/status); empty runs every model."""
|
| 58 |
-
try:
|
| 59 |
-
questions = json.loads(questions_json)
|
| 60 |
-
except ValueError as exc:
|
| 61 |
-
raise gr.Error(f"Questions must be valid JSON: {exc}") from None
|
| 62 |
-
if not isinstance(questions, dict):
|
| 63 |
-
raise gr.Error("Questions must be a JSON object: {name: {type, instructions, criteria}}.")
|
| 64 |
-
text = (state or "").strip()
|
| 65 |
-
try:
|
| 66 |
-
parsed = json.loads(text) if text.startswith("{") else text
|
| 67 |
-
except ValueError:
|
| 68 |
-
parsed = text
|
| 69 |
-
keys = [k.strip() for k in (models or "").split(",") if k.strip()] or None
|
| 70 |
-
try:
|
| 71 |
-
return run_decision(parsed, questions, keys)
|
| 72 |
-
except (ValueError, Busy) as exc:
|
| 73 |
-
raise gr.Error(str(exc)) from None
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def build_blocks() -> gr.Blocks:
|
| 77 |
-
with gr.Blocks(title="DecisionLab API") as demo:
|
| 78 |
-
gr.Markdown(f"# DecisionLab {VERSION}: Gradio API\n"
|
| 79 |
-
"The full lab is at the [main page](/). This form and `gradio_client` run the same decision code.")
|
| 80 |
-
with gr.Row():
|
| 81 |
-
state = gr.Textbox(label="State (text or JSON)", lines=8, value=EXAMPLE_STATE)
|
| 82 |
-
questions = gr.Code(label="Questions (JSON)", language="json", value=EXAMPLE_QUESTIONS)
|
| 83 |
-
models = gr.Textbox(label="Models (comma-separated keys from /api/status; empty = every model)", value="")
|
| 84 |
-
run = gr.Button("Run", variant="primary")
|
| 85 |
-
out = gr.JSON(label="Result")
|
| 86 |
-
run.click(decide, [state, questions, models], out, api_name="decide")
|
| 87 |
-
return demo
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def main() -> None:
|
| 91 |
-
load_all() # every model is loaded before the first request (see the note above)
|
| 92 |
-
served = gr.mount_gradio_app(app, build_blocks(), path=GRADIO_PATH)
|
| 93 |
-
uvicorn.run(served, host=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
|
| 94 |
-
port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860))
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
if __name__ == "__main__":
|
| 98 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_space.py
CHANGED
|
@@ -80,6 +80,17 @@ class SpacePackagingTest(unittest.TestCase):
|
|
| 80 |
with self.subTest(pattern=pattern):
|
| 81 |
self.assertRegex(attrs, rf"(?m)^{re.escape(pattern)} filter=lfs diff=lfs merge=lfs -text$")
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
def test_no_leftover_space_py(self):
|
| 84 |
self.assertFalse((ROOT / "space.py").exists())
|
| 85 |
|
|
|
|
| 80 |
with self.subTest(pattern=pattern):
|
| 81 |
self.assertRegex(attrs, rf"(?m)^{re.escape(pattern)} filter=lfs diff=lfs merge=lfs -text$")
|
| 82 |
|
| 83 |
+
def test_the_space_starts_through_gradio_launch(self):
|
| 84 |
+
"""ZeroGPU's startup step runs inside Gradio's Blocks.launch(); uvicorn + mount_gradio_app skips it
|
| 85 |
+
("No @spaces.GPU function detected during startup", 2026-09-30)."""
|
| 86 |
+
import ast
|
| 87 |
+
tree = ast.parse((ROOT / "app.py").read_text(encoding="utf-8"))
|
| 88 |
+
calls = {n.func.attr for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)}
|
| 89 |
+
imported = {a.name for n in ast.walk(tree) if isinstance(n, ast.Import) for a in n.names}
|
| 90 |
+
self.assertIn("launch", calls)
|
| 91 |
+
self.assertNotIn("mount_gradio_app", calls)
|
| 92 |
+
self.assertNotIn("uvicorn", imported)
|
| 93 |
+
|
| 94 |
def test_no_leftover_space_py(self):
|
| 95 |
self.assertFalse((ROOT / "space.py").exists())
|
| 96 |
|