Spaces:
Running
Running
v2: Alpine base + runtime /admin/load (deepseek defaults + template kept)
Browse files- Dockerfile +13 -33
- main.py +101 -216
- requirements.txt +8 -7
Dockerfile
CHANGED
|
@@ -1,52 +1,32 @@
|
|
| 1 |
-
# ── GGUF LLM API —
|
| 2 |
-
#
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
RUN
|
| 8 |
-
build-essential \
|
| 9 |
-
gcc \
|
| 10 |
-
g++ \
|
| 11 |
-
make \
|
| 12 |
-
cmake \
|
| 13 |
-
git \
|
| 14 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
|
| 43 |
-
#
|
| 44 |
-
#
|
|
|
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 89 |
-
""
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
try:
|
| 102 |
-
log.info("Downloading
|
| 103 |
t0 = time.time()
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 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 |
-
|
| 135 |
-
|
| 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 (
|
| 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
|
| 173 |
-
|
| 174 |
-
|
| 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 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
return "\n\n".join(parts)
|
| 197 |
|
| 198 |
|
| 199 |
-
def
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 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":
|
| 237 |
-
"
|
| 238 |
-
"
|
| 239 |
-
"
|
| 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 |
-
""
|
| 251 |
-
|
| 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(
|
| 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:
|
| 283 |
result = llm.create_chat_completion(**kwargs)
|
| 284 |
except Exception as exc:
|
| 285 |
-
raise HTTPException(
|
| 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"] =
|
| 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 |
-
|
| 302 |
-
|
| 303 |
-
|
| 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(
|
| 317 |
-
|
| 318 |
usage = result.get("usage", {}) or {}
|
| 319 |
-
return {
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 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
|
| 2 |
-
#
|
| 3 |
-
# common cause of a Space that built yesterday and fails to build today.
|
| 4 |
|
| 5 |
fastapi==0.115.6
|
| 6 |
-
uvicorn[standard]
|
|
|
|
|
|
|
| 7 |
pydantic==2.10.4
|
| 8 |
huggingface_hub==0.27.1
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
#
|
| 12 |
-
llama-cpp-python==0.3.
|
|
|
|
| 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
|