Michael Stattelman commited on
Commit
43c3b3c
·
1 Parent(s): 21c5089

Add application file

Browse files
Files changed (5) hide show
  1. README.md +3 -3
  2. app.py +31 -18
  3. app/main.py +1 -1
  4. space.py +0 -98
  5. 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` serves the same FastAPI app as the container, so the page at `/` and the API at `/api/*` are identical. A Gradio app is mounted at `/gradio`: a small form and a `decide` endpoint for `gradio_client`, running the same decision code.
69
 
70
- On ZeroGPU a GPU is attached for each decision. The Space starts serving at once and the models load in the background, as in the container. 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,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.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-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: only the model-running step is decorated; the request checks and
11
- the concurrency limit stay in the main process.
12
- - The server starts serving AT ONCE and the models load in the background (the page shows their progress, as in the
13
- container). ZeroGPU gives a Space limited time to start: loading ~2.6 GB of models before serving made it report
14
- "No @spaces.GPU function detected during startup" and take port 7860 for its error page (2.1.0, 2026-09-30).
15
- - Gradio's server-side rendering is off: its Node proxy would also want port 7860.
16
- - Each model times itself inside the GPU call, so model timings stay comparable; the GPU hand-off only adds to the
17
- round trip.
 
 
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 uvicorn # noqa: E402
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 at the [main page](/). This form and `gradio_client` run the same decision code.")
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
- served = gr.mount_gradio_app(app, build_blocks(), path=GRADIO_PATH, ssr_mode=False)
 
 
 
 
96
 
97
 
98
  def main() -> None:
99
- # Serve at once; load the models in the background (see the note above). The loader runs only once, even if the
100
- # lab's own startup hook also starts it.
101
- threading.Thread(target=load_all, name="model-loader", daemon=True).start()
102
- uvicorn.run(served, host=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
103
- port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860))
 
 
 
 
 
 
 
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.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