unity4ar commited on
Commit
7036a02
·
verified ·
1 Parent(s): 16501bb

Refactor: Docker+llama.cpp -> Gradio SDK + ZeroGPU transformers backend

Browse files
Files changed (7) hide show
  1. .dockerignore +0 -29
  2. Dockerfile +0 -52
  3. README.md +18 -10
  4. entrypoint.sh +0 -43
  5. llm/omni_client.py +11 -0
  6. llm/zerogpu_backend.py +163 -0
  7. requirements.txt +17 -0
.dockerignore DELETED
@@ -1,29 +0,0 @@
1
- # Keep the build context (and the pushed Space repo) small. Multi-GB vendored
2
- # trees, the local model runtime, and Windows-only launchers are excluded.
3
- .git
4
- .venv
5
- venv
6
- runtime/
7
- tools/
8
- __pycache__/
9
- **/__pycache__/
10
- *.pyc
11
- tmp/
12
- *.log
13
- *.err.log
14
-
15
- # Heavy / local-only data. The maps, processed graph and voices are kept;
16
- # backups, saved games and the smoke screenshot are not needed in the image.
17
- data/backups/
18
- data/games/
19
- data/archives/
20
- data/raw/archives/
21
- data/*.log
22
- data/*.png
23
-
24
- # Windows launchers — not used on Linux.
25
- run_game.ps1
26
- run_game.cmd
27
-
28
- # Build artifact placeholders
29
- C:*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile DELETED
@@ -1,52 +0,0 @@
1
- # Phantom Grid — Docker Hugging Face Space (Linux, CPU).
2
- #
3
- # The image bakes in a prebuilt llama.cpp OpenAI-compatible server
4
- # (llama-cpp-python) and the MiniCPM4.1-8B Q4_K_M GGUF (text backend, <32B),
5
- # so there is NO build-from-source or model download at container startup.
6
- FROM python:3.12-slim
7
-
8
- ENV PYTHONUNBUFFERED=1 \
9
- PIP_NO_CACHE_DIR=1 \
10
- HF_HOME=/app/.hfcache \
11
- MODEL_DIR=/app/models \
12
- MODEL_PATH=/app/models/MiniCPM4.1-8B-Q4_K_M.gguf
13
-
14
- # Minimal OS deps. libgomp1 is needed by the llama.cpp OpenMP runtime.
15
- RUN apt-get update \
16
- && apt-get install -y --no-install-recommends libgomp1 curl ca-certificates \
17
- && rm -rf /var/lib/apt/lists/*
18
-
19
- WORKDIR /app
20
-
21
- # --- Python deps -----------------------------------------------------------
22
- COPY requirements-space.txt ./
23
- RUN pip install --upgrade pip \
24
- && pip install -r requirements-space.txt \
25
- # Prebuilt CPU wheel for the OpenAI-compatible llama.cpp server (no compile).
26
- && pip install "llama-cpp-python[server]" \
27
- --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
28
-
29
- # --- Bake the model at BUILD time -----------------------------------------
30
- # Downloaded into the image so the Space boots without a multi-GB pull.
31
- RUN python -c "from huggingface_hub import hf_hub_download; \
32
- hf_hub_download(repo_id='openbmb/MiniCPM4.1-8B-GGUF', \
33
- filename='MiniCPM4.1-8B-Q4_K_M.gguf', local_dir='/app/models')"
34
-
35
- # --- App code --------------------------------------------------------------
36
- COPY . .
37
-
38
- # Writable dirs for HF's runtime user (Spaces run as uid 1000).
39
- RUN mkdir -p /app/data/games /app/.hfcache \
40
- && chmod -R 777 /app/data /app/.hfcache /app/models
41
-
42
- # Point the app at the in-container llama.cpp server (text-only, no voice).
43
- ENV PHANTOM_GRID_LLM_PROVIDER=external_llama_cpp_server \
44
- PHANTOM_GRID_LLAMACPP_BASE_URL=http://127.0.0.1:8080/v1 \
45
- PHANTOM_GRID_LLM_MODEL=MiniCPM4.1-8B-Q4_K_M.gguf \
46
- PHANTOM_GRID_WITNESS_CHAT_TTS=0 \
47
- PHANTOM_GRID_HOST=0.0.0.0 \
48
- PORT=7860
49
-
50
- EXPOSE 7860
51
-
52
- ENTRYPOINT ["bash", "entrypoint.sh"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -3,16 +3,25 @@ title: Phantom Grid
3
  emoji: 🕵️
4
  colorFrom: indigo
5
  colorTo: gray
6
- sdk: docker
7
- app_port: 7860
 
 
8
  pinned: false
9
  license: mit
 
10
  tags:
11
  - thousand-token-wood
12
  - delightful
13
  - game
14
  - agent
15
  - minicpm
 
 
 
 
 
 
16
  ---
17
 
18
  # 🕵️ Phantom Grid
@@ -33,17 +42,16 @@ in a custom `gr.Server` HTML/JS board interface (no stock Gradio components).
33
  ## 🤖 Model & inference
34
 
35
  - **Model:** [`openbmb/MiniCPM4.1-8B`](https://huggingface.co/openbmb/MiniCPM4.1-8B)
36
- (text, Q4_K_M GGUF) — ~8B params, well under the 32B cap.
37
- - **Inference:** the blessed llama.cpp path an OpenAI-compatible
38
- `llama.cpp` server (`llama-cpp-python`) on `:8080/v1`, with the app pointing at it via
39
- `PHANTOM_GRID_LLAMACPP_BASE_URL`. The model is **baked into the image at build time**, so the
40
- Space boots without any multi-GB download or build-from-source.
41
 
42
  ## 🖥️ Hardware
43
 
44
- Runs on **CPU** (the model is Q4_K_M, ~5 GB RAM). Witness replies are slow on the free
45
- CPU tier; an upgraded CPU or a GPU Space makes interviews snappy. The text backend has no
46
- voice path (`PHANTOM_GRID_WITNESS_CHAT_TTS=0`).
47
 
48
  ## 🎥 Demo video
49
 
 
3
  emoji: 🕵️
4
  colorFrom: indigo
5
  colorTo: gray
6
+ sdk: gradio
7
+ sdk_version: 6.17.3
8
+ app_file: app.py
9
+ python_version: "3.10"
10
  pinned: false
11
  license: mit
12
+ hf_oauth: false
13
  tags:
14
  - thousand-token-wood
15
  - delightful
16
  - game
17
  - agent
18
  - minicpm
19
+ env:
20
+ - PHANTOM_GRID_LLM_PROVIDER=zerogpu_transformers
21
+ - PHANTOM_GRID_ZEROGPU_MODEL_ID=openbmb/MiniCPM4.1-8B
22
+ - PHANTOM_GRID_ZEROGPU_DURATION=90
23
+ - PHANTOM_GRID_HOST=0.0.0.0
24
+ - PHANTOM_GRID_WITNESS_CHAT_TTS=0
25
  ---
26
 
27
  # 🕵️ Phantom Grid
 
42
  ## 🤖 Model & inference
43
 
44
  - **Model:** [`openbmb/MiniCPM4.1-8B`](https://huggingface.co/openbmb/MiniCPM4.1-8B)
45
+ (text, bf16 transformers) — ~8B params, well under the 32B cap.
46
+ - **Inference:** in-process Hugging Face `transformers`. The model lives in CPU
47
+ RAM and is moved to cuda inside a `@spaces.GPU`-decorated function, so the
48
+ H200 is only held while the model is actually generating.
 
49
 
50
  ## 🖥️ Hardware
51
 
52
+ Runs on **ZeroGPU** (H200, free 40 min/day for org members). Each generation is
53
+ capped at `PHANTOM_GRID_ZEROGPU_DURATION` seconds (default 90). No voice path
54
+ on this Space (`PHANTOM_GRID_WITNESS_CHAT_TTS=0`).
55
 
56
  ## 🎥 Demo video
57
 
entrypoint.sh DELETED
@@ -1,43 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Linux entrypoint for the Phantom Grid Docker Space.
3
- # 1. Start the baked-in llama.cpp OpenAI-compatible server on :8080.
4
- # 2. Wait until it reports healthy (/v1/models).
5
- # 3. Launch the gr.Server app, bound to 0.0.0.0:$PORT for the Space.
6
- set -euo pipefail
7
-
8
- MODEL_PATH="${MODEL_PATH:-/app/models/MiniCPM4.1-8B-Q4_K_M.gguf}"
9
- LLAMA_PORT="${LLAMA_PORT:-8080}"
10
- CTX="${PHANTOM_GRID_LLAMACPP_CONTEXT_LENGTH:-8192}"
11
- APP_PORT="${PORT:-7860}"
12
-
13
- echo "[entrypoint] Starting llama.cpp server on :${LLAMA_PORT} with ${MODEL_PATH}"
14
- # llama_cpp.server's pydantic Settings reads HOST/PORT from env and they
15
- # override the CLI flags — so on HF Spaces (PORT=7860) the server would bind
16
- # 7860 and collide with Gradio. Scope HOST/PORT to just this subprocess.
17
- HOST=127.0.0.1 PORT="${LLAMA_PORT}" python -m llama_cpp.server \
18
- --model "${MODEL_PATH}" \
19
- --host 127.0.0.1 \
20
- --port "${LLAMA_PORT}" \
21
- --n_ctx "${CTX}" \
22
- --n_gpu_layers "${LLAMA_N_GPU_LAYERS:-0}" \
23
- &
24
- LLAMA_PID=$!
25
-
26
- cleanup() { kill "${LLAMA_PID}" 2>/dev/null || true; }
27
- trap cleanup EXIT INT TERM
28
-
29
- echo "[entrypoint] Waiting for llama.cpp /v1/models to become ready..."
30
- for i in $(seq 1 180); do
31
- if curl -sf "http://127.0.0.1:${LLAMA_PORT}/v1/models" >/dev/null 2>&1; then
32
- echo "[entrypoint] llama.cpp server is ready."
33
- break
34
- fi
35
- if ! kill -0 "${LLAMA_PID}" 2>/dev/null; then
36
- echo "[entrypoint] llama.cpp server exited unexpectedly." >&2
37
- exit 1
38
- fi
39
- sleep 2
40
- done
41
-
42
- echo "[entrypoint] Launching Phantom Grid app on 0.0.0.0:${APP_PORT}"
43
- exec python app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
llm/omni_client.py CHANGED
@@ -35,6 +35,9 @@ class OmniClient:
35
  return cls(load_settings())
36
 
37
  def health(self, timeout: float = 0.75) -> dict[str, Any]:
 
 
 
38
  if self.settings.llm_provider in {"llama_cpp_server", "external_llama_cpp_server"}:
39
  base_url = self.settings.llamacpp_base_url.rstrip("/")
40
  try:
@@ -358,6 +361,14 @@ class OmniClient:
358
  {"role": "system", "content": system_prompt},
359
  {"role": "user", "content": user_prompt},
360
  ])]
 
 
 
 
 
 
 
 
361
  if self.settings.llm_provider in {"llama_cpp_server", "external_llama_cpp_server"}:
362
  text_url = f"{self.settings.llamacpp_base_url.rstrip('/')}/chat/completions"
363
  model = self.settings.llm_model or (self.settings.llamacpp_model_path.name if self.settings.llamacpp_model_path else "local")
 
35
  return cls(load_settings())
36
 
37
  def health(self, timeout: float = 0.75) -> dict[str, Any]:
38
+ if self.settings.llm_provider == "zerogpu_transformers":
39
+ from llm import zerogpu_backend
40
+ return zerogpu_backend.health()
41
  if self.settings.llm_provider in {"llama_cpp_server", "external_llama_cpp_server"}:
42
  base_url = self.settings.llamacpp_base_url.rstrip("/")
43
  try:
 
361
  {"role": "system", "content": system_prompt},
362
  {"role": "user", "content": user_prompt},
363
  ])]
364
+ if self.settings.llm_provider == "zerogpu_transformers":
365
+ from llm import zerogpu_backend
366
+ return zerogpu_backend.chat_completion(
367
+ content,
368
+ temperature=temperature,
369
+ max_tokens=max_tokens,
370
+ json_mode=json_mode,
371
+ )
372
  if self.settings.llm_provider in {"llama_cpp_server", "external_llama_cpp_server"}:
373
  text_url = f"{self.settings.llamacpp_base_url.rstrip('/')}/chat/completions"
374
  model = self.settings.llm_model or (self.settings.llamacpp_model_path.name if self.settings.llamacpp_model_path else "local")
llm/zerogpu_backend.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-process transformers backend for HF Spaces ZeroGPU.
2
+
3
+ Loaded only when PHANTOM_GRID_LLM_PROVIDER=zerogpu_transformers. Model weights
4
+ live in CPU RAM and are moved to cuda inside the @spaces.GPU function, which is
5
+ how ZeroGPU's per-call GPU acquisition expects PyTorch models to behave.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import threading
13
+ from typing import Any
14
+
15
+ _MODEL_ID = os.getenv("PHANTOM_GRID_ZEROGPU_MODEL_ID", "openbmb/MiniCPM4.1-8B")
16
+ _DEFAULT_DURATION = int(os.getenv("PHANTOM_GRID_ZEROGPU_DURATION", "90"))
17
+
18
+ _lock = threading.Lock()
19
+ _model = None
20
+ _tokenizer = None
21
+ _loaded_to_cuda = False
22
+
23
+
24
+ def _ensure_loaded() -> None:
25
+ """Load the model + tokenizer onto CPU. Called once at module init."""
26
+ global _model, _tokenizer
27
+ if _model is not None and _tokenizer is not None:
28
+ return
29
+ import torch
30
+ from transformers import AutoModelForCausalLM, AutoTokenizer
31
+
32
+ with _lock:
33
+ if _tokenizer is None:
34
+ _tokenizer = AutoTokenizer.from_pretrained(_MODEL_ID, trust_remote_code=True)
35
+ if _model is None:
36
+ _model = AutoModelForCausalLM.from_pretrained(
37
+ _MODEL_ID,
38
+ torch_dtype=torch.bfloat16,
39
+ trust_remote_code=True,
40
+ low_cpu_mem_usage=True,
41
+ )
42
+ _model.eval()
43
+
44
+
45
+ try:
46
+ import spaces # type: ignore
47
+
48
+ _spaces_gpu = spaces.GPU
49
+ except ImportError: # pragma: no cover — local dev without `spaces` installed
50
+ def _spaces_gpu(*_args, **_kwargs):
51
+ def _wrap(fn):
52
+ return fn
53
+ return _wrap
54
+
55
+
56
+ @_spaces_gpu(duration=_DEFAULT_DURATION)
57
+ def _generate_on_gpu(
58
+ messages: list[dict[str, Any]],
59
+ max_new_tokens: int,
60
+ temperature: float,
61
+ json_mode: bool,
62
+ ) -> str:
63
+ """Runs on the H200 only when ZeroGPU has granted the slot."""
64
+ import torch
65
+
66
+ global _loaded_to_cuda
67
+ _ensure_loaded()
68
+ assert _model is not None and _tokenizer is not None
69
+
70
+ if not _loaded_to_cuda:
71
+ _model.to("cuda")
72
+ _loaded_to_cuda = True
73
+
74
+ prompt = _tokenizer.apply_chat_template(
75
+ messages,
76
+ tokenize=False,
77
+ add_generation_prompt=True,
78
+ )
79
+ inputs = _tokenizer(prompt, return_tensors="pt").to("cuda")
80
+
81
+ do_sample = temperature > 0
82
+ gen_kwargs = {
83
+ "max_new_tokens": max_new_tokens,
84
+ "do_sample": do_sample,
85
+ "pad_token_id": _tokenizer.eos_token_id,
86
+ }
87
+ if do_sample:
88
+ gen_kwargs["temperature"] = temperature
89
+ gen_kwargs["top_p"] = 0.9
90
+
91
+ with torch.inference_mode():
92
+ outputs = _model.generate(**inputs, **gen_kwargs)
93
+
94
+ new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
95
+ text = _tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
96
+
97
+ if json_mode:
98
+ text = _extract_json(text)
99
+ return text
100
+
101
+
102
+ def _extract_json(text: str) -> str:
103
+ """Strip code fences and isolate the first JSON object."""
104
+ cleaned = text.strip()
105
+ if cleaned.startswith("```"):
106
+ cleaned = cleaned.strip("`")
107
+ if cleaned.lower().startswith("json"):
108
+ cleaned = cleaned[4:].strip()
109
+ start = cleaned.find("{")
110
+ end = cleaned.rfind("}")
111
+ if start >= 0 and end > start:
112
+ return cleaned[start : end + 1]
113
+ return text
114
+
115
+
116
+ _NO_THINK_HINTS = ("minicpm4", "minicpm-4", "minicpm_4", "qwen3", "qwen-3")
117
+
118
+
119
+ def _maybe_inject_no_think(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
120
+ """MiniCPM4 / Qwen3 emit hidden <think> blocks that eat the token budget on
121
+ small max_new_tokens. Append '/no_think' to the system turn so the model
122
+ skips it. Mirrors llm/omni_client.py:_ensure_no_think."""
123
+ if not any(hint in _MODEL_ID.lower() for hint in _NO_THINK_HINTS):
124
+ return messages
125
+ directive = "/no_think"
126
+ for item in messages:
127
+ if item.get("role") == "system" and isinstance(item.get("content"), str):
128
+ if directive not in item["content"]:
129
+ item["content"] = f"{item['content'].rstrip()} {directive}".strip()
130
+ return messages
131
+ return [{"role": "system", "content": directive}, *messages]
132
+
133
+
134
+ def chat_completion(
135
+ messages: list[dict[str, Any]],
136
+ *,
137
+ temperature: float = 0.4,
138
+ max_tokens: int = 512,
139
+ json_mode: bool = False,
140
+ ) -> str:
141
+ """Synchronous, drop-in replacement for an OpenAI chat-completions POST."""
142
+ messages = _maybe_inject_no_think([dict(m) for m in messages])
143
+ return _generate_on_gpu(messages, max_tokens, temperature, json_mode)
144
+
145
+
146
+ def health() -> dict[str, Any]:
147
+ """Cheap probe — does NOT touch the GPU. Reflects whether the module imported."""
148
+ return {
149
+ "reachable": True,
150
+ "ready": _tokenizer is not None,
151
+ "detail": {"model_id": _MODEL_ID, "loaded_to_cuda": _loaded_to_cuda},
152
+ }
153
+
154
+
155
+ # Kick off the CPU-side load eagerly so the first user turn doesn't pay for it.
156
+ # Wrapped in try/except so an import failure (e.g. during local dev without the
157
+ # huge model cache) doesn't crash app startup — the health probe will report it.
158
+ try:
159
+ _ensure_loaded()
160
+ except Exception as exc: # pragma: no cover
161
+ _LOAD_ERROR = f"{exc.__class__.__name__}: {exc}"
162
+ else:
163
+ _LOAD_ERROR = None
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime dependencies for the Gradio SDK / ZeroGPU Hugging Face Space.
2
+ # Read by HF Spaces' Gradio runner at build time.
3
+
4
+ gradio>=6.17.3
5
+ spaces>=0.30.0
6
+ huggingface_hub>=1.2
7
+ pydantic>=2.7
8
+ websockets>=14.0
9
+ fastapi
10
+ uvicorn
11
+
12
+ # Text LLM backend: HF transformers + PyTorch, MiniCPM4.1-8B is loaded
13
+ # CPU-side at import and moved to cuda inside the @spaces.GPU function.
14
+ torch>=2.4.0
15
+ transformers>=4.46.0
16
+ accelerate>=1.0.0
17
+ sentencepiece