Electro0023 commited on
Commit
d330add
·
verified ·
1 Parent(s): 4621b74

v2: Alpine base + runtime /admin/load (deepseek defaults + template kept)

Browse files
Files changed (3) hide show
  1. Dockerfile +13 -33
  2. main.py +101 -216
  3. requirements.txt +8 -7
Dockerfile CHANGED
@@ -1,52 +1,32 @@
1
- # ── GGUF LLM API — Hugging Face Spaces (Docker SDK), CPU tier by default ───────
2
- # Builds llama-cpp-python from source, so the C/C++ toolchain is installed first.
3
- FROM python:3.11-slim
 
 
4
 
5
- # 1) Build toolchain required to compile llama-cpp-python.
6
- # build-essential = gcc + g++ + make; cmake is what its build backend invokes.
7
- RUN apt-get update && apt-get install -y --no-install-recommends \
8
- build-essential \
9
- gcc \
10
- g++ \
11
- make \
12
- cmake \
13
- git \
14
- && rm -rf /var/lib/apt/lists/*
15
 
16
- # 2) Hugging Face Spaces runs the container as a non-root user (uid 1000).
17
- # Create it and put ALL writable paths under its home so model downloads work.
18
- RUN useradd -m -u 1000 user
19
  USER user
20
  ENV HOME=/home/user \
21
  PATH=/home/user/.local/bin:$PATH \
22
- # HF_HOME is where huggingface_hub caches the downloaded .gguf at startup.
23
- # It MUST be writable by uid 1000 — /home/user is, /root and /app often are not.
24
  HF_HOME=/home/user/.cache/huggingface \
25
- # HF CPU-basic = 2 vCPUs. Pin llama.cpp + BLAS to 2 threads so they don't
26
- # over-subscribe the host's core count and thrash. Raise these (or set the
27
- # N_THREADS Space variable) if you move to bigger CPU/GPU hardware.
28
  N_THREADS=2 \
29
  OMP_NUM_THREADS=2
30
 
31
  WORKDIR /home/user/app
32
 
33
- # 3) Install Python deps first (better layer caching than copying everything).
34
- # The --extra-index-url hosts PREBUILT CPU wheels for llama-cpp-python, so pip
35
- # installs a wheel instead of compiling from source. This avoids the #1 cause
36
- # of failed HF CPU builds: the source compile running out of memory. The gcc/
37
- # cmake toolchain above stays only as a fallback if no wheel matches.
38
  COPY --chown=user requirements.txt ./
 
 
39
  RUN pip install --no-cache-dir --upgrade pip \
40
- && pip install --no-cache-dir -r requirements.txt \
41
  --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
42
 
43
- # 4) Copy the (lightweight) application code. No model file is baked in — it is
44
- # pulled from the HF model repo on startup.
45
  COPY --chown=user . ./
46
 
47
- # 5) Hugging Face Spaces routes public traffic to port 7860.
48
  EXPOSE 7860
49
-
50
- # 6) Single worker: llama.cpp holds one non-thread-safe context; the app serialises
51
- # calls internally. Do NOT add --workers > 1 (each would reload the whole model).
52
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ # ── GGUF LLM API — HF Spaces (Docker SDK), Alpine base ────────────────────────
2
+ # Alpine (musl libc) is REQUIRED here: the prebuilt llama-cpp-python CPU wheels
3
+ # on abetlen's index are musl builds, and compiling from source exceeds HF's
4
+ # build-job timeout on the free builder. With the wheel, the build is minutes.
5
+ FROM python:3.11-alpine
6
 
7
+ # Runtime libraries the llama.cpp shared objects need (C++/OpenMP), plus a
8
+ # compiler-free environment no gcc/cmake on purpose: wheels only.
9
+ RUN apk add --no-cache libstdc++ libgomp curl
 
 
 
 
 
 
 
10
 
11
+ # HF Spaces runs containers as uid 1000 with writable paths under its home.
12
+ RUN adduser -D -u 1000 user
 
13
  USER user
14
  ENV HOME=/home/user \
15
  PATH=/home/user/.local/bin:$PATH \
 
 
16
  HF_HOME=/home/user/.cache/huggingface \
 
 
 
17
  N_THREADS=2 \
18
  OMP_NUM_THREADS=2
19
 
20
  WORKDIR /home/user/app
21
 
 
 
 
 
 
22
  COPY --chown=user requirements.txt ./
23
+ # --only-binary: fail loudly if any dependency would need a compiler instead of
24
+ # silently hitting the build timeout again.
25
  RUN pip install --no-cache-dir --upgrade pip \
26
+ && pip install --no-cache-dir --only-binary=:all: -r requirements.txt \
27
  --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
28
 
 
 
29
  COPY --chown=user . ./
30
 
 
31
  EXPOSE 7860
 
 
 
32
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py CHANGED
@@ -1,26 +1,13 @@
1
- """
2
- GGUF LLM API for Hugging Face Spaces (Docker SDK) — "Download on Startup".
3
-
4
- On boot this app downloads a .gguf model from a Hugging Face **Model** repo with
5
- huggingface_hub, loads it once with llama-cpp-python, and serves it behind an
6
- OpenAI-compatible chat endpoint plus a simple /generate endpoint.
7
-
8
- Nothing here is model-specific: which repo/file to pull is chosen entirely by
9
- environment variables, so you redeploy a new model by changing a variable in the
10
- Space settings no code edits.
11
-
12
- Environment variables
13
- ----------------------
14
- REPO_ID (required) HF model repo, e.g. "your-username/my-gguf-models"
15
- FILENAME (required) the .gguf file inside that repo,
16
- e.g. "mistral-7b-instruct-v0.2.Q4_K_M.gguf"
17
- HF_TOKEN (optional) a READ token — ONLY needed if the model repo is PRIVATE
18
- MODEL_ID (optional) name reported to clients (default: FILENAME)
19
- N_CTX (optional) context window in tokens (default 4096)
20
- N_THREADS (optional) CPU threads for inference (default: all cores)
21
- N_GPU_LAYERS (optional) layers to offload to GPU (default 0 = CPU only)
22
- CHAT_FORMAT (optional) e.g. "llama-3", "chatml", "mistral-instruct"
23
- DEFAULT_MAX_TOKENS (optional) fallback max_tokens per request (default 512)
24
  """
25
  import os
26
  import time
@@ -39,9 +26,10 @@ from llama_cpp import llama_chat_format
39
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
40
  log = logging.getLogger("gguf-api")
41
 
42
- # DeepSeek-LLM chat template — NOT built into llama-cpp-python (and TheBloke's
43
- # 2023 GGUFs predate embedded chat_template metadata), so register it ourselves.
44
- # Official format: [system\n\n]User: {msg}\n\nAssistant: {reply}<eos>...Assistant:
 
45
  @llama_chat_format.register_chat_format("deepseek")
46
  def _format_deepseek(messages, **kwargs) -> llama_chat_format.ChatFormatterResponse:
47
  prompt = ""
@@ -56,100 +44,65 @@ def _format_deepseek(messages, **kwargs) -> llama_chat_format.ChatFormatterRespo
56
  prompt += "Assistant:"
57
  return llama_chat_format.ChatFormatterResponse(prompt=prompt, stop=["User:"])
58
 
59
-
60
- # ── Configuration (all via environment) ───────────────────────────────────────
61
- # Defaults point at DeepSeek-LLM-7B-CHAT (Q4_K_M), a PUBLIC GGUF — so the Space
62
- # runs with zero setup. Override REPO_ID/FILENAME in the Space settings to swap
63
- # models without touching the code.
64
- REPO_ID = os.getenv("REPO_ID", "TheBloke/deepseek-llm-7B-chat-GGUF").strip()
65
- FILENAME = os.getenv("FILENAME", "deepseek-llm-7b-chat.Q4_K_M.gguf").strip()
66
- HF_TOKEN = os.getenv("HF_TOKEN") or None # None => anonymous (public repo)
67
- MODEL_ID = os.getenv("MODEL_ID", "") or "deepseek-7b-chat" # name reported to clients
68
  N_CTX = int(os.getenv("N_CTX", "4096"))
69
- # Default to the number of *usable* CPUs (respects cgroup cpuset), NOT os.cpu_count()
70
- # which over-reports the host's cores inside a container and causes thread thrash.
71
- _USABLE_CPUS = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else (os.cpu_count() or 4)
72
- N_THREADS = int(os.getenv("N_THREADS", str(_USABLE_CPUS)))
73
- N_GPU_LAYERS = int(os.getenv("N_GPU_LAYERS", "0")) # 0 = CPU only; -1 = all layers on GPU
74
- # "deepseek" = the custom template registered above, matching the default chat
75
- # model. Set to "" (empty) when running a BASE model — the endpoints then bypass
76
- # chat templating and use raw text completion instead.
77
- CHAT_FORMAT = os.getenv("CHAT_FORMAT", "deepseek").strip()
78
  DEFAULT_MAX_TOKENS = int(os.getenv("DEFAULT_MAX_TOKENS", "512"))
79
 
80
- # The loaded model lives here. It is None until startup finishes (or if it failed).
81
- # llama-cpp is NOT thread-safe for concurrent generation on one context, so every
82
- # call into the model is serialised behind this lock.
83
  _llm: Optional[Llama] = None
84
  _llm_lock = threading.Lock()
85
  _load_error: Optional[str] = None
86
-
87
-
88
- def _download_and_load() -> None:
89
- """Download the .gguf from the HF model repo, then load it with llama-cpp."""
90
- global _llm, _load_error
91
-
92
- if not REPO_ID or not FILENAME:
93
- _load_error = (
94
- "REPO_ID and FILENAME must both be set. In your Space go to "
95
- "Settings -> Variables and secrets and add REPO_ID (e.g. "
96
- "'your-username/my-gguf-models') and FILENAME (e.g. 'model.Q4_K_M.gguf')."
97
- )
98
- log.error(_load_error)
99
- return
100
-
101
  try:
102
- log.info("Downloading model '%s' from repo '%s' ...", FILENAME, REPO_ID)
103
  t0 = time.time()
104
- # hf_hub_download caches under HF_HOME and returns the local file path.
105
- # Re-runs are instant once the file is cached in the container layer.
106
- model_path = hf_hub_download(
107
- repo_id=REPO_ID,
108
- filename=FILENAME,
109
- token=HF_TOKEN, # ignored for public repos, required for private
110
- )
111
- log.info("Model downloaded to %s (%.1fs)", model_path, time.time() - t0)
112
-
113
- kwargs: Dict[str, Any] = dict(
114
- model_path=model_path,
115
- n_ctx=N_CTX,
116
- n_threads=N_THREADS,
117
- n_gpu_layers=N_GPU_LAYERS,
118
- verbose=False,
119
- )
120
- if CHAT_FORMAT:
121
- kwargs["chat_format"] = CHAT_FORMAT
122
-
123
- log.info("Loading model into memory (n_ctx=%d, n_gpu_layers=%d) ...", N_CTX, N_GPU_LAYERS)
124
- t0 = time.time()
125
- _llm = Llama(**kwargs)
126
- log.info("Model ready (%.1fs).", time.time() - t0)
127
- except Exception as exc: # keep the server up so /health can report the reason
128
- _load_error = f"Failed to download/load model: {exc}"
129
  log.exception(_load_error)
 
 
130
 
131
 
132
  @asynccontextmanager
133
  async def lifespan(app: FastAPI):
134
- """Start loading the model in a BACKGROUND thread so the HTTP port opens
135
- immediately.
136
-
137
- Hugging Face marks a Space "in error" if port 7860 doesn't open within its
138
- startup window. Downloading a ~2 GB model and loading it *before* the server
139
- binds would blow past that window (this is exactly what crashes naive Spaces).
140
- Loading off-thread lets uvicorn bind 7860 in seconds; /health reports
141
- "loading_or_error" until the model is ready, and inference endpoints return a
142
- clear 503 until then instead of failing the whole container.
143
- """
144
- threading.Thread(target=_download_and_load, name="model-loader", daemon=True).start()
145
  yield
146
- # (nothing to clean up: process exit releases the model)
147
 
148
 
149
- app = FastAPI(title="GGUF LLM API (HF Spaces)", version="1.0.0", lifespan=lifespan)
150
 
151
 
152
- # ── Request/response schemas ──────────────────────────────────────────────────
153
  class ChatMessage(BaseModel):
154
  role: str
155
  content: str = ""
@@ -162,164 +115,96 @@ class ChatCompletionRequest(BaseModel):
162
  top_p: float = 0.95
163
  max_tokens: Optional[int] = None
164
  stop: Optional[List[str]] = None
165
- # OpenAI JSON mode: {"type":"json_object"} -> llama.cpp grammar-constrains the
166
- # reply to VALID JSON. Essential for small models, which otherwise emit
167
- # unbalanced brackets (e.g. a stray "]" after each array element).
168
  response_format: Optional[Dict[str, Any]] = None
169
 
170
 
171
  class GenerateRequest(BaseModel):
172
- prompt: str = Field(..., description="Plain text prompt for the model.",
173
- examples=["State Newton's second law of motion in one sentence."])
174
- max_tokens: int = Field(DEFAULT_MAX_TOKENS, description="Max tokens to generate.")
175
- temperature: float = Field(0.7, description="Sampling temperature (0 = deterministic).")
176
-
177
-
178
- def _require_model() -> Llama:
179
- """Return the loaded model or raise 503 with a helpful message."""
180
- if _llm is None:
181
- raise HTTPException(
182
- status_code=503,
183
- detail=_load_error or "Model is still loading, try again in a few seconds.",
184
- )
185
- return _llm
186
-
187
 
188
- def _messages_to_raw_prompt(messages: List["ChatMessage"]) -> str:
189
- """Flatten chat messages into a plain-text prompt for BASE (non-chat) models.
190
 
191
- A base model was never trained on a chat template — wrapping its input in
192
- one (e.g. llama-2 [INST] tags) makes it emit only <s>/</s> tokens. Instead
193
- we join the message contents into plain text and let the model continue it.
194
- """
195
- parts = [m.content.strip() for m in messages if m.content and m.content.strip()]
196
- return "\n\n".join(parts)
197
 
198
 
199
- def _raw_chat_completion(llm: Llama, req: "ChatCompletionRequest") -> Dict[str, Any]:
200
- """Text-completion path used when CHAT_FORMAT is empty (base model)."""
201
- prompt = _messages_to_raw_prompt(req.messages)
202
- result = llm.create_completion(
203
- prompt=prompt,
204
- temperature=req.temperature,
205
- top_p=req.top_p,
206
- max_tokens=req.max_tokens if req.max_tokens is not None else DEFAULT_MAX_TOKENS,
207
- stop=req.stop,
208
- )
209
- text = result["choices"][0]["text"]
210
- # Re-shape the completion result into an OpenAI chat-completion response so
211
- # clients (which always call /v1/chat/completions) need no changes.
212
- return {
213
- "id": f"chatcmpl-{uuid.uuid4().hex}",
214
- "object": "chat.completion",
215
- "created": int(time.time()),
216
- "model": MODEL_ID,
217
- "choices": [{
218
- "index": 0,
219
- "message": {"role": "assistant", "content": text},
220
- "finish_reason": result["choices"][0].get("finish_reason", "stop"),
221
- }],
222
- "usage": result.get("usage", {}),
223
- }
224
 
225
 
226
- # ── Endpoints ─────────────────────────────────────────────────────────────────
227
  @app.get("/health")
228
  def health() -> Dict[str, Any]:
229
- """Liveness probe. status='ok' only once the model is loaded and ready."""
230
- # Report the CPU picture so thread over-subscription (the #1 cause of slow
231
- # CPU generation) is visible: if cpu_count >> usable cores, llama.cpp threads
232
- # thrash. N_THREADS is what we actually hand to llama.cpp.
233
- usable = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else None
234
  return {
235
- "status": "ok" if _llm is not None else "loading_or_error",
236
- "model": MODEL_ID,
237
- "repo_id": REPO_ID,
238
- "filename": FILENAME,
239
- "n_ctx": N_CTX,
240
- "n_gpu_layers": N_GPU_LAYERS,
241
- "n_threads": N_THREADS,
242
- "cpu_count": os.cpu_count(),
243
- "usable_cpus": usable,
244
- "error": _load_error,
245
  }
246
 
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  @app.get("/v1/models")
249
  def list_models() -> Dict[str, Any]:
250
- """OpenAI-shaped model list (a single loaded model)."""
251
- return {"object": "list", "data": [{"id": MODEL_ID, "object": "model", "owned_by": "local"}]}
252
 
253
 
254
  @app.post("/v1/chat/completions")
255
  def chat_completions(req: ChatCompletionRequest) -> Dict[str, Any]:
256
- """OpenAI-compatible chat completion (non-streaming)."""
257
  llm = _require_model()
258
  if not req.messages:
259
- raise HTTPException(status_code=400, detail="`messages` must not be empty.")
260
-
261
- # Base model (no chat template): use raw text completion instead of a chat
262
- # template the model was never trained on.
263
- if not CHAT_FORMAT:
264
- try:
265
- with _llm_lock:
266
- return _raw_chat_completion(llm, req)
267
- except Exception as exc:
268
- raise HTTPException(status_code=500, detail=f"generation failed: {exc}") from exc
269
-
270
  kwargs: Dict[str, Any] = dict(
271
  messages=[m.model_dump() for m in req.messages],
272
- temperature=req.temperature,
273
- top_p=req.top_p,
274
  max_tokens=req.max_tokens if req.max_tokens is not None else DEFAULT_MAX_TOKENS,
275
  stop=req.stop,
276
  )
277
- # Honour OpenAI JSON mode -> llama.cpp constrains generation to valid JSON.
278
  if req.response_format and req.response_format.get("type") == "json_object":
279
  kwargs["response_format"] = {"type": "json_object"}
280
-
281
  try:
282
- with _llm_lock: # serialise access to the single llama.cpp context
283
  result = llm.create_chat_completion(**kwargs)
284
  except Exception as exc:
285
- raise HTTPException(status_code=500, detail=f"generation failed: {exc}") from exc
286
-
287
- # llama-cpp already returns an OpenAI-shaped dict; normalise id/model/created.
288
  result["id"] = result.get("id") or f"chatcmpl-{uuid.uuid4().hex}"
289
- result["model"] = MODEL_ID
290
  result["created"] = result.get("created") or int(time.time())
291
  return result
292
 
293
 
294
  @app.post("/generate")
295
  def generate(req: GenerateRequest) -> Dict[str, Any]:
296
- """Simplified endpoint: one prompt in, generated text out (plus timing)."""
297
  llm = _require_model()
298
  t0 = time.time()
299
  try:
300
  with _llm_lock:
301
- if not CHAT_FORMAT: # base model -> raw continuation of the prompt
302
- raw = llm.create_completion(prompt=req.prompt,
303
- temperature=req.temperature,
304
- max_tokens=req.max_tokens)
305
- result = {
306
- "choices": [{"message": {"content": raw["choices"][0]["text"]}}],
307
- "usage": raw.get("usage", {}),
308
- }
309
- else:
310
- result = llm.create_chat_completion(
311
- messages=[{"role": "user", "content": req.prompt}],
312
- temperature=req.temperature,
313
- max_tokens=req.max_tokens,
314
- )
315
  except Exception as exc:
316
- raise HTTPException(status_code=500, detail=f"generation failed: {exc}") from exc
317
-
318
  usage = result.get("usage", {}) or {}
319
- return {
320
- "prompt": req.prompt,
321
- "response": result["choices"][0]["message"]["content"],
322
- "model": MODEL_ID,
323
- "time_seconds": round(time.time() - t0, 2),
324
- "output_tokens": usage.get("completion_tokens"),
325
- }
 
1
+ """GGUF LLM API for Hugging Face Spaces (Docker SDK) — runtime-swappable models.
2
+
3
+ Downloads a .gguf from a HF model repo, loads it with llama-cpp-python and
4
+ serves an OpenAI-compatible API. The initial model comes from environment
5
+ variables; afterwards a NEW model can be loaded at runtime via POST /admin/load
6
+ no Space variable change, therefore NO image rebuild.
7
+
8
+ Environment variables (initial model only):
9
+ REPO_ID, FILENAME, HF_TOKEN, MODEL_ID, N_CTX, N_THREADS, N_GPU_LAYERS,
10
+ CHAT_FORMAT ("" = auto from GGUF metadata), DEFAULT_MAX_TOKENS
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  """
12
  import os
13
  import time
 
26
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
27
  log = logging.getLogger("gguf-api")
28
 
29
+
30
+ # DeepSeek-LLM chat template not built into llama-cpp-python, and TheBloke's
31
+ # 2023 GGUFs predate embedded chat_template metadata. Registered here so this
32
+ # Space can keep serving deepseek-7b-chat (its default model).
33
  @llama_chat_format.register_chat_format("deepseek")
34
  def _format_deepseek(messages, **kwargs) -> llama_chat_format.ChatFormatterResponse:
35
  prompt = ""
 
44
  prompt += "Assistant:"
45
  return llama_chat_format.ChatFormatterResponse(prompt=prompt, stop=["User:"])
46
 
47
+ HF_TOKEN = os.getenv("HF_TOKEN") or None
 
 
 
 
 
 
 
 
48
  N_CTX = int(os.getenv("N_CTX", "4096"))
49
+ _USABLE = len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else (os.cpu_count() or 4)
50
+ N_THREADS = int(os.getenv("N_THREADS", str(_USABLE)))
51
+ N_GPU_LAYERS = int(os.getenv("N_GPU_LAYERS", "0"))
 
 
 
 
 
 
52
  DEFAULT_MAX_TOKENS = int(os.getenv("DEFAULT_MAX_TOKENS", "512"))
53
 
54
+ # Current model state (mutable at runtime via /admin/load).
 
 
55
  _llm: Optional[Llama] = None
56
  _llm_lock = threading.Lock()
57
  _load_error: Optional[str] = None
58
+ _loading: bool = False
59
+ _cfg: Dict[str, str] = {
60
+ "repo_id": os.getenv("REPO_ID", "TheBloke/deepseek-llm-7B-chat-GGUF").strip(),
61
+ "filename": os.getenv("FILENAME", "deepseek-llm-7b-chat.Q4_K_M.gguf").strip(),
62
+ "model_id": (os.getenv("MODEL_ID", "") or "deepseek-7b-chat").strip(),
63
+ "chat_format": os.getenv("CHAT_FORMAT", "deepseek").strip(),
64
+ }
65
+
66
+
67
+ def _download_and_load(cfg: Dict[str, str]) -> None:
68
+ """Download cfg's .gguf and load it, replacing the current model."""
69
+ global _llm, _load_error, _loading, _cfg
70
+ _loading = True
71
+ _load_error = None
 
72
  try:
73
+ log.info("Downloading %s from %s ...", cfg["filename"], cfg["repo_id"])
74
  t0 = time.time()
75
+ path = hf_hub_download(repo_id=cfg["repo_id"], filename=cfg["filename"],
76
+ token=HF_TOKEN)
77
+ log.info("Downloaded in %.1fs; loading ...", time.time() - t0)
78
+ kwargs: Dict[str, Any] = dict(model_path=path, n_ctx=N_CTX,
79
+ n_threads=N_THREADS, n_gpu_layers=N_GPU_LAYERS,
80
+ verbose=False)
81
+ if cfg["chat_format"]:
82
+ kwargs["chat_format"] = cfg["chat_format"]
83
+ new_llm = Llama(**kwargs)
84
+ with _llm_lock: # swap atomically; old model is freed
85
+ _llm = new_llm
86
+ _cfg = dict(cfg)
87
+ log.info("Model '%s' ready.", cfg["model_id"])
88
+ except Exception as exc:
89
+ _load_error = f"Failed to load {cfg['model_id']}: {exc}"
 
 
 
 
 
 
 
 
 
 
90
  log.exception(_load_error)
91
+ finally:
92
+ _loading = False
93
 
94
 
95
  @asynccontextmanager
96
  async def lifespan(app: FastAPI):
97
+ # Load the initial model in the background so port 7860 opens immediately.
98
+ threading.Thread(target=_download_and_load, args=(dict(_cfg),),
99
+ name="model-loader", daemon=True).start()
 
 
 
 
 
 
 
 
100
  yield
 
101
 
102
 
103
+ app = FastAPI(title="GGUF LLM API (runtime-swappable)", version="2.0.0", lifespan=lifespan)
104
 
105
 
 
106
  class ChatMessage(BaseModel):
107
  role: str
108
  content: str = ""
 
115
  top_p: float = 0.95
116
  max_tokens: Optional[int] = None
117
  stop: Optional[List[str]] = None
 
 
 
118
  response_format: Optional[Dict[str, Any]] = None
119
 
120
 
121
  class GenerateRequest(BaseModel):
122
+ prompt: str
123
+ max_tokens: int = Field(default=512)
124
+ temperature: float = 0.7
 
 
 
 
 
 
 
 
 
 
 
 
125
 
 
 
126
 
127
+ class LoadRequest(BaseModel):
128
+ repo_id: str
129
+ filename: str
130
+ model_id: str
131
+ chat_format: str = "" # "" = auto from GGUF metadata
 
132
 
133
 
134
+ def _require_model() -> Llama:
135
+ if _llm is None or _loading:
136
+ raise HTTPException(503, _load_error or "Model is loading, try again shortly.")
137
+ return _llm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
 
 
140
  @app.get("/health")
141
  def health() -> Dict[str, Any]:
 
 
 
 
 
142
  return {
143
+ "status": "ok" if (_llm is not None and not _loading) else "loading_or_error",
144
+ "model": _cfg["model_id"], "repo_id": _cfg["repo_id"],
145
+ "filename": _cfg["filename"], "chat_format": _cfg["chat_format"],
146
+ "loading": _loading, "n_ctx": N_CTX, "n_threads": N_THREADS,
147
+ "n_gpu_layers": N_GPU_LAYERS, "error": _load_error,
 
 
 
 
 
148
  }
149
 
150
 
151
+ @app.post("/admin/load")
152
+ def admin_load(req: LoadRequest) -> Dict[str, Any]:
153
+ """Load a different GGUF at runtime (async; poll /health until status ok)."""
154
+ global _loading
155
+ if _loading:
156
+ raise HTTPException(409, "another model is already loading")
157
+ cfg = {"repo_id": req.repo_id.strip(), "filename": req.filename.strip(),
158
+ "model_id": req.model_id.strip(), "chat_format": req.chat_format.strip()}
159
+ threading.Thread(target=_download_and_load, args=(cfg,),
160
+ name="model-loader", daemon=True).start()
161
+ return {"status": "loading", "model": cfg["model_id"]}
162
+
163
+
164
  @app.get("/v1/models")
165
  def list_models() -> Dict[str, Any]:
166
+ return {"object": "list",
167
+ "data": [{"id": _cfg["model_id"], "object": "model", "owned_by": "local"}]}
168
 
169
 
170
  @app.post("/v1/chat/completions")
171
  def chat_completions(req: ChatCompletionRequest) -> Dict[str, Any]:
 
172
  llm = _require_model()
173
  if not req.messages:
174
+ raise HTTPException(400, "`messages` must not be empty.")
 
 
 
 
 
 
 
 
 
 
175
  kwargs: Dict[str, Any] = dict(
176
  messages=[m.model_dump() for m in req.messages],
177
+ temperature=req.temperature, top_p=req.top_p,
 
178
  max_tokens=req.max_tokens if req.max_tokens is not None else DEFAULT_MAX_TOKENS,
179
  stop=req.stop,
180
  )
 
181
  if req.response_format and req.response_format.get("type") == "json_object":
182
  kwargs["response_format"] = {"type": "json_object"}
 
183
  try:
184
+ with _llm_lock:
185
  result = llm.create_chat_completion(**kwargs)
186
  except Exception as exc:
187
+ raise HTTPException(500, f"generation failed: {exc}") from exc
 
 
188
  result["id"] = result.get("id") or f"chatcmpl-{uuid.uuid4().hex}"
189
+ result["model"] = _cfg["model_id"]
190
  result["created"] = result.get("created") or int(time.time())
191
  return result
192
 
193
 
194
  @app.post("/generate")
195
  def generate(req: GenerateRequest) -> Dict[str, Any]:
 
196
  llm = _require_model()
197
  t0 = time.time()
198
  try:
199
  with _llm_lock:
200
+ result = llm.create_chat_completion(
201
+ messages=[{"role": "user", "content": req.prompt}],
202
+ temperature=req.temperature, max_tokens=req.max_tokens)
 
 
 
 
 
 
 
 
 
 
 
203
  except Exception as exc:
204
+ raise HTTPException(500, f"generation failed: {exc}") from exc
 
205
  usage = result.get("usage", {}) or {}
206
+ return {"prompt": req.prompt,
207
+ "response": result["choices"][0]["message"]["content"],
208
+ "model": _cfg["model_id"],
209
+ "time_seconds": round(time.time() - t0, 2),
210
+ "output_tokens": usage.get("completion_tokens")}
 
 
requirements.txt CHANGED
@@ -1,12 +1,13 @@
1
- # Pinned for reproducible Hugging Face Space builds.
2
- # Bump these only deliberately a floating llama-cpp-python version is the most
3
- # common cause of a Space that built yesterday and fails to build today.
4
 
5
  fastapi==0.115.6
6
- uvicorn[standard]==0.34.0
 
 
7
  pydantic==2.10.4
8
  huggingface_hub==0.27.1
9
 
10
- # llama-cpp-python is compiled from source in the Dockerfile (needs gcc/g++/make/cmake).
11
- # 0.3.x ships the current llama.cpp; CPU-only build is the default.
12
- llama-cpp-python==0.3.7
 
1
+ # Pinned for reproducible builds. ALL packages must install as prebuilt wheels
2
+ # (the Alpine image ships no compiler - see Dockerfile --only-binary).
 
3
 
4
  fastapi==0.115.6
5
+ # plain uvicorn (not [standard]): avoids uvloop/httptools, which may lack
6
+ # musllinux wheels for this pin - h11 worker is fine for a single-model server.
7
+ uvicorn==0.34.0
8
  pydantic==2.10.4
9
  huggingface_hub==0.27.1
10
 
11
+ # musl (Alpine) prebuilt CPU wheel from abetlen's index - 2025 llama.cpp with
12
+ # qwen3 / gemma3 / granite architecture support.
13
+ llama-cpp-python==0.3.19